Skip to main content

ff_preview/playback/
player.rs

1//! Actor-model playback types for ff-preview.
2//!
3//! # Overview
4//!
5//! [`PreviewPlayer`] opens a media file and is a thin builder.  Call
6//! [`PreviewPlayer::split`] to obtain a ([`PlayerRunner`], [`PlayerHandle`]) pair:
7//!
8//! - **[`PlayerRunner`]** — owns the decode buffers, audio thread, and
9//!   presentation clock. Move it to a dedicated thread and call
10//!   [`PlayerRunner::run`]. The method runs until EOF or a [`PlayerCommand::Stop`]
11//!   command arrives.
12//! - **[`PlayerHandle`]** — `Clone + Send + Sync`. Holds a command sender and
13//!   read-only state atomics. All control methods use `try_send` — they never
14//!   block. If the command channel (capacity 64) is full the send is silently
15//!   dropped.
16
17use std::collections::VecDeque;
18use std::path::{Path, PathBuf};
19use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
20use std::sync::{Arc, Mutex, mpsc};
21use std::thread::{self, JoinHandle};
22use std::time::{Duration, Instant};
23
24use ff_decode::{AudioDecoder, HardwareAccel, SeekMode};
25use ff_format::SampleFormat;
26
27use super::clock::MasterClock;
28use super::decode_buffer::{DecodeBuffer, FrameResult};
29use super::sink::FrameSink;
30use crate::audio::AudioMixer;
31use crate::cache::FrameCache;
32use crate::error::PreviewError;
33use crate::event::PlayerEvent;
34
35// ── Constants ─────────────────────────────────────────────────────────────────
36
37const AUDIO_MAX_BUF: usize = 96_000;
38const CHANNEL_CAP: usize = 64;
39
40// ── PlayerCommand ─────────────────────────────────────────────────────────────
41
42/// Commands sent from [`PlayerHandle`] to [`PlayerRunner`] via a
43/// bounded sync channel (capacity 64).
44pub enum PlayerCommand {
45    /// Resume playback (clear the paused flag).
46    Play,
47    /// Pause playback.
48    Pause,
49    /// Stop the presentation loop; [`PlayerRunner::run`] returns after the
50    /// current frame.
51    Stop,
52    /// Seek to `pts`. Consecutive seeks are coalesced — only the last one
53    /// executes.
54    Seek(Duration),
55    /// Set the playback rate. Values ≤ 0.0 are ignored.
56    SetRate(f64),
57    /// Set the A/V offset in milliseconds. Clamped to ±5 000 ms.
58    SetAvOffset(i64),
59}
60
61// ── PlayerHandle ─────────────────────────────────────────────────────────────
62
63/// Shared, cloneable handle to a running [`PlayerRunner`].
64///
65/// All methods are non-blocking. Commands that cannot be queued immediately
66/// (channel full) are silently dropped.
67///
68/// # Thread safety
69///
70/// `PlayerHandle` is `Clone + Send + Sync` and can be shared freely across
71/// threads without locking.
72#[derive(Clone)]
73pub struct PlayerHandle {
74    cmd_tx: mpsc::SyncSender<PlayerCommand>,
75    event_rx: Arc<Mutex<mpsc::Receiver<PlayerEvent>>>,
76    /// Current PTS in microseconds. Written by [`PlayerRunner`] on each frame.
77    current_pts: Arc<AtomicU64>,
78    audio_buf: Option<Arc<Mutex<VecDeque<f32>>>>,
79    /// Advances the audio master clock when `pop_audio_samples` drains samples.
80    samples_consumed: Option<Arc<AtomicU64>>,
81    /// Mirrors the runner's paused state; updated immediately by `play`/`pause`.
82    paused: Arc<AtomicBool>,
83    /// Mirrors the runner's stopped state; updated immediately by `stop`.
84    stopped: Arc<AtomicBool>,
85    duration_millis: u64,
86    /// Multi-track mixer — present when the runner was created by `TimelinePlayer`.
87    audio_mixer: Option<Arc<Mutex<AudioMixer>>>,
88}
89
90impl PlayerHandle {
91    /// Resume playback.
92    pub fn play(&self) {
93        self.stopped.store(false, Ordering::Release);
94        self.paused.store(false, Ordering::Release);
95        let _ = self.cmd_tx.try_send(PlayerCommand::Play);
96    }
97
98    /// Pause playback.
99    pub fn pause(&self) {
100        self.paused.store(true, Ordering::Release);
101        let _ = self.cmd_tx.try_send(PlayerCommand::Pause);
102    }
103
104    /// Stop the presentation loop.
105    pub fn stop(&self) {
106        self.stopped.store(true, Ordering::Release);
107        let _ = self.cmd_tx.try_send(PlayerCommand::Stop);
108    }
109
110    /// Seek to `pts`.
111    ///
112    /// Consecutive calls before the runner processes them are coalesced —
113    /// only the most recent `pts` executes.
114    pub fn seek(&self, pts: Duration) {
115        let _ = self.cmd_tx.try_send(PlayerCommand::Seek(pts));
116    }
117
118    /// Set the playback rate.
119    ///
120    /// Values ≤ 0.0 are silently ignored by the runner.
121    pub fn set_rate(&self, rate: f64) {
122        let _ = self.cmd_tx.try_send(PlayerCommand::SetRate(rate));
123    }
124
125    /// Set the A/V offset correction in milliseconds.
126    ///
127    /// Positive: video PTS is shifted down relative to audio (video appears
128    /// delayed). Negative: video PTS is shifted up (audio appears delayed).
129    pub fn set_av_offset(&self, ms: i64) {
130        let _ = self.cmd_tx.try_send(PlayerCommand::SetAvOffset(ms));
131    }
132
133    /// PTS of the most recently presented frame.
134    ///
135    /// Returns [`Duration::ZERO`] before the first frame is presented.
136    #[must_use]
137    pub fn current_pts(&self) -> Duration {
138        Duration::from_micros(self.current_pts.load(Ordering::Relaxed))
139    }
140
141    /// Container-reported duration, or `None` for live / streaming sources.
142    #[must_use]
143    pub fn duration(&self) -> Option<Duration> {
144        if self.duration_millis == u64::MAX {
145            None
146        } else {
147            Some(Duration::from_millis(self.duration_millis))
148        }
149    }
150
151    /// Pull up to `n` interleaved stereo `f32` PCM samples at 48 kHz.
152    ///
153    /// Returns an empty `Vec` when:
154    /// - playback is paused or stopped,
155    /// - `n` is 0,
156    /// - there is no audio track, or
157    /// - the ring buffer is empty (underrun — caller should output silence).
158    ///
159    /// Advances the audio master clock by `samples.len() / 2` stereo frames.
160    #[allow(clippy::cast_precision_loss)]
161    pub fn pop_audio_samples(&self, n: usize) -> Vec<f32> {
162        if self.paused.load(Ordering::Relaxed) || self.stopped.load(Ordering::Relaxed) {
163            return Vec::new();
164        }
165        if n == 0 {
166            return Vec::new();
167        }
168        // Mixer path — used when the handle was created by TimelinePlayer.
169        // The timeline clock is System-based so samples_consumed is not advanced here.
170        if let Some(mixer) = &self.audio_mixer {
171            return mixer
172                .lock()
173                .unwrap_or_else(std::sync::PoisonError::into_inner)
174                .mix(n);
175        }
176        // Legacy ring-buffer path — used by PlayerRunner (single-track audio).
177        let Some(buf) = &self.audio_buf else {
178            return Vec::new();
179        };
180        let mut guard = buf
181            .lock()
182            .unwrap_or_else(std::sync::PoisonError::into_inner);
183        let take = n.min(guard.len());
184        if take == 0 {
185            return Vec::new();
186        }
187        let samples: Vec<f32> = guard.drain(..take).collect();
188        if let Some(sc) = &self.samples_consumed {
189            sc.fetch_add((take / 2) as u64, Ordering::Relaxed);
190        }
191        samples
192    }
193
194    /// Poll for the next [`PlayerEvent`] without blocking.
195    ///
196    /// Returns `None` when no events are pending.
197    #[must_use]
198    pub fn poll_event(&self) -> Option<PlayerEvent> {
199        self.event_rx.lock().ok()?.try_recv().ok()
200    }
201
202    /// Block until the next [`PlayerEvent`] arrives or the channel closes.
203    ///
204    /// Returns `None` when the runner has exited and all events have been
205    /// drained. Intended for use inside `spawn_blocking`.
206    #[must_use]
207    pub fn recv_event(&self) -> Option<PlayerEvent> {
208        self.event_rx.lock().ok()?.recv().ok()
209    }
210
211    /// Construct a handle for a non-`PlayerRunner` runner (e.g., `TimelineRunner`).
212    ///
213    /// Audio fields are set to `None`; the handle's
214    /// [`pop_audio_samples`](Self::pop_audio_samples) always returns an empty `Vec`.
215    #[cfg(feature = "timeline")]
216    pub(crate) fn for_timeline(
217        cmd_tx: mpsc::SyncSender<PlayerCommand>,
218        event_rx: Arc<Mutex<mpsc::Receiver<PlayerEvent>>>,
219        current_pts: Arc<AtomicU64>,
220        paused: Arc<AtomicBool>,
221        stopped: Arc<AtomicBool>,
222        duration_millis: u64,
223        audio_mixer: Option<Arc<Mutex<AudioMixer>>>,
224    ) -> Self {
225        Self {
226            cmd_tx,
227            event_rx,
228            current_pts,
229            audio_buf: None,
230            samples_consumed: None,
231            audio_mixer,
232            paused,
233            stopped,
234            duration_millis,
235        }
236    }
237}
238
239// ── PlayerRunner ─────────────────────────────────────────────────────────────
240
241/// Exclusive owner of the decode pipeline. Move to a background thread and
242/// call [`run`](Self::run).
243///
244/// Configure with [`set_sink`](Self::set_sink),
245/// [`use_proxy_if_available`](Self::use_proxy_if_available), and
246/// [`set_hardware_accel`](Self::set_hardware_accel) **before** calling `run`.
247pub struct PlayerRunner {
248    path: PathBuf,
249    cmd_rx: mpsc::Receiver<PlayerCommand>,
250    event_tx: mpsc::SyncSender<PlayerEvent>,
251    decode_buf: Option<DecodeBuffer>,
252    fps: f64,
253    sink: Option<Box<dyn FrameSink>>,
254    clock: MasterClock,
255    audio_buf: Option<Arc<Mutex<VecDeque<f32>>>>,
256    audio_cancel: Option<Arc<AtomicBool>>,
257    audio_handle: Option<JoinHandle<()>>,
258    sws: super::playback_inner::SwsRgbaConverter,
259    rgba_buf: Vec<u8>,
260    active_path: PathBuf,
261    current_pts: Arc<AtomicU64>,
262    paused: Arc<AtomicBool>,
263    stopped: Arc<AtomicBool>,
264    av_offset_ms: i64,
265    rate: f64,
266    duration_millis: u64,
267    frame_cache: Option<FrameCache>,
268    hw_accel: HardwareAccel,
269}
270
271impl PlayerRunner {
272    /// Register the frame sink. Call before [`run`](Self::run).
273    pub fn set_sink(&mut self, sink: Box<dyn FrameSink>) {
274        self.sink = Some(sink);
275    }
276
277    /// Configure hardware acceleration. Call before [`run`](Self::run).
278    ///
279    /// The setting takes effect at the start of `run()`. [`HardwareAccel::Auto`]
280    /// (the default) probes available backends and falls back to software.
281    /// [`HardwareAccel::None`] forces CPU-only decoding.
282    pub fn set_hardware_accel(&mut self, accel: HardwareAccel) -> &mut Self {
283        self.hw_accel = accel;
284        self
285    }
286
287    /// Returns the path currently being decoded (original or active proxy).
288    #[must_use]
289    pub fn active_source(&self) -> &Path {
290        &self.active_path
291    }
292
293    /// Enable an in-memory RGBA frame cache with the given byte budget.
294    ///
295    /// When the budget is set, frames decoded during playback are stored
296    /// and served on cache hit without re-decoding, enabling instant scrubbing.
297    /// The cache is invalidated automatically whenever a seek targets a PTS
298    /// outside the currently cached range.
299    ///
300    /// Example: `runner.with_frame_cache_budget(512 * 1024 * 1024)` for 512 MB.
301    #[must_use]
302    pub fn with_frame_cache_budget(mut self, bytes: usize) -> Self {
303        self.frame_cache = Some(FrameCache::new(bytes));
304        self
305    }
306
307    /// Container-reported duration, or `None` for live / streaming sources.
308    #[must_use]
309    pub fn duration(&self) -> Option<Duration> {
310        if self.duration_millis == u64::MAX {
311            None
312        } else {
313            Some(Duration::from_millis(self.duration_millis))
314        }
315    }
316
317    /// Activate a lower-resolution proxy if one exists in `proxy_dir`.
318    ///
319    /// Must be called before [`run`](Self::run). Returns `true` if a proxy was
320    /// found and activated; `false` if no proxy exists or activation failed.
321    ///
322    /// Proxy lookup order: `half` → `quarter` → `eighth`; first match wins.
323    pub fn use_proxy_if_available(&mut self, proxy_dir: &Path) -> bool {
324        let stem = self
325            .path
326            .file_stem()
327            .and_then(|s| s.to_str())
328            .unwrap_or("output")
329            .to_owned();
330
331        for suffix in ["half", "quarter", "eighth"] {
332            let candidate = proxy_dir.join(format!("{stem}_proxy_{suffix}.mp4"));
333            if candidate.exists() {
334                match self.activate_proxy(&candidate) {
335                    Ok(()) => {
336                        log::debug!("proxy activated path={}", candidate.display());
337                        return true;
338                    }
339                    Err(e) => {
340                        log::warn!(
341                            "proxy activation failed path={} error={e}",
342                            candidate.display()
343                        );
344                    }
345                }
346            }
347        }
348        false
349    }
350
351    /// A/V sync presentation loop.
352    ///
353    /// Blocks until a [`PlayerCommand::Stop`] is received, the end of file is
354    /// reached, or an unrecoverable decode error occurs.
355    ///
356    /// At the top of each frame, all pending commands are drained from the
357    /// channel. Consecutive [`PlayerCommand::Seek`] commands are coalesced —
358    /// only the last one executes.
359    ///
360    /// Emits [`PlayerEvent::SeekCompleted`] after each successful seek,
361    /// [`PlayerEvent::PositionUpdate`] after each presented video frame,
362    /// [`PlayerEvent::Error`] on non-fatal decode errors, and
363    /// [`PlayerEvent::Eof`] before returning.
364    ///
365    /// # Errors
366    ///
367    /// Returns [`PreviewError`] if a seek fails.
368    #[allow(clippy::too_many_lines)]
369    pub fn run(mut self) -> Result<(), PreviewError> {
370        let fps = self.fps.max(1.0);
371        let frame_period = Duration::from_secs_f64(1.0 / fps);
372
373        // Rebuild the decode buffer when the caller has explicitly configured a
374        // hardware acceleration mode other than the default (Auto). The initial
375        // buffer is always built with Auto by PreviewPlayer::open(); rebuilding
376        // here ensures the user's explicit setting is respected.
377        if self.hw_accel != HardwareAccel::Auto && self.decode_buf.is_some() {
378            match DecodeBuffer::open(&self.active_path)
379                .hardware_accel(self.hw_accel)
380                .build()
381            {
382                Ok(buf) => {
383                    self.decode_buf = Some(buf);
384                }
385                Err(e) => {
386                    log::warn!(
387                        "hwaccel decode buffer rebuild failed accel={} error={e}",
388                        self.hw_accel.name()
389                    );
390                }
391            }
392        }
393
394        self.clock.reset(Duration::ZERO);
395
396        loop {
397            // ── Drain commands ────────────────────────────────────────────────
398            let mut pending_seek: Option<Duration> = None;
399            while let Ok(cmd) = self.cmd_rx.try_recv() {
400                match cmd {
401                    PlayerCommand::Seek(pts) => pending_seek = Some(pts),
402                    PlayerCommand::Play => {
403                        self.stopped.store(false, Ordering::Release);
404                        self.paused.store(false, Ordering::Release);
405                    }
406                    PlayerCommand::Pause => {
407                        self.paused.store(true, Ordering::Release);
408                    }
409                    PlayerCommand::Stop => {
410                        self.stopped.store(true, Ordering::Release);
411                    }
412                    PlayerCommand::SetRate(r) => {
413                        if r > 0.0 {
414                            self.rate = r;
415                        }
416                    }
417                    PlayerCommand::SetAvOffset(ms) => {
418                        const MAX_OFFSET_MS: i64 = 5_000;
419                        self.av_offset_ms = ms.clamp(-MAX_OFFSET_MS, MAX_OFFSET_MS);
420                    }
421                }
422            }
423
424            // ── Apply pending seek ────────────────────────────────────────────
425            if let Some(pts) = pending_seek {
426                // Invalidate the frame cache when seeking outside its range.
427                if let Some(cache) = &mut self.frame_cache {
428                    let in_range = cache
429                        .pts_range()
430                        .is_some_and(|(lo, hi)| pts >= lo && pts <= hi);
431                    if !in_range {
432                        cache.invalidate();
433                    }
434                }
435                if let Some(buf) = self.decode_buf.as_mut() {
436                    buf.seek(pts)?;
437                }
438                self.clock.reset(pts);
439                self.restart_audio_from(pts);
440                let _ = self.event_tx.try_send(PlayerEvent::SeekCompleted(pts));
441            }
442
443            // Surface non-fatal decode errors from the background thread.
444            if let Some(buf) = self.decode_buf.as_ref() {
445                while let Ok(msg) = buf.error_events().try_recv() {
446                    let _ = self.event_tx.try_send(PlayerEvent::Error(msg));
447                }
448            }
449
450            if self.stopped.load(Ordering::Acquire) {
451                break;
452            }
453            if self.paused.load(Ordering::Acquire) {
454                thread::sleep(Duration::from_millis(5));
455                continue;
456            }
457
458            // ── Audio-only path ───────────────────────────────────────────────
459            if self.decode_buf.is_none() {
460                thread::sleep(Duration::from_millis(10));
461                if let Some(audio_buf) = &self.audio_buf {
462                    let empty = audio_buf
463                        .lock()
464                        .unwrap_or_else(std::sync::PoisonError::into_inner)
465                        .is_empty();
466                    if empty
467                        && self
468                            .audio_handle
469                            .as_ref()
470                            .is_none_or(JoinHandle::is_finished)
471                    {
472                        break;
473                    }
474                } else {
475                    break;
476                }
477                continue;
478            }
479
480            // ── Frame cache hit ───────────────────────────────────────────────
481            let current = self.clock.current_pts();
482            let cache_hit = self
483                .frame_cache
484                .as_ref()
485                .and_then(|c| c.get(current))
486                .map(|f| (f.rgba.clone(), f.width, f.height));
487            if let Some((rgba, width, height)) = cache_hit {
488                if let Some(sink) = self.sink.as_mut() {
489                    sink.push_frame(&rgba, width, height, current);
490                }
491                self.current_pts.store(
492                    u64::try_from(current.as_micros()).unwrap_or(u64::MAX),
493                    Ordering::Relaxed,
494                );
495                let _ = self.event_tx.try_send(PlayerEvent::PositionUpdate(current));
496                continue;
497            }
498
499            // ── Video decode path ─────────────────────────────────────────────
500            let pop_result = if let Some(buf) = self.decode_buf.as_mut() {
501                buf.pop_frame()
502            } else {
503                FrameResult::Eof
504            };
505
506            match pop_result {
507                FrameResult::Eof => break,
508                FrameResult::Seeking(last) => {
509                    if let Some(ref f) = last {
510                        self.present_frame(f);
511                    }
512                }
513                FrameResult::Frame(frame) => {
514                    if self.clock.should_sync() {
515                        let video_pts = if frame.timestamp().is_valid() {
516                            frame.timestamp().as_duration()
517                        } else {
518                            Duration::ZERO
519                        };
520
521                        let offset_ms = self.av_offset_ms;
522                        let offset = Duration::from_millis(offset_ms.unsigned_abs());
523                        let adjusted_video_pts = if offset_ms >= 0 {
524                            video_pts.saturating_sub(offset)
525                        } else {
526                            video_pts + offset
527                        };
528
529                        let clock_pts = self.clock.current_pts();
530                        let diff = adjusted_video_pts.as_secs_f64() - clock_pts.as_secs_f64();
531                        let fp = frame_period.as_secs_f64();
532
533                        if diff > fp {
534                            let sleep_secs =
535                                (diff - fp / 2.0).max(0.0) / self.rate.max(f64::MIN_POSITIVE);
536                            thread::sleep(Duration::from_secs_f64(sleep_secs));
537                        } else if diff < -fp {
538                            log::debug!(
539                                "dropped late frame video_pts={video_pts:?} \
540                                 clock_pts={clock_pts:?}"
541                            );
542                            continue;
543                        }
544                    }
545
546                    self.present_frame(&frame);
547                    let pts = frame.timestamp().as_duration();
548                    let _ = self.event_tx.try_send(PlayerEvent::PositionUpdate(pts));
549
550                    // Grace period: after the first frame, arm the wall-clock fallback
551                    // if no audio consumer has started consuming samples yet.
552                    // This ensures real-time pacing even when pop_audio_samples() is
553                    // never called (e.g. no cpal stream attached to the handle).
554                    self.clock.activate_fallback_if_no_audio(pts);
555
556                    // Populate cache after conversion (rgba_buf holds the converted frame).
557                    if let Some(cache) = &mut self.frame_cache
558                        && !self.rgba_buf.is_empty()
559                    {
560                        cache.insert(pts, self.rgba_buf.clone(), frame.width(), frame.height());
561                    }
562                }
563            }
564        }
565
566        let _ = self.event_tx.try_send(PlayerEvent::Eof);
567        if let Some(sink) = self.sink.as_mut() {
568            sink.flush();
569        }
570        Ok(())
571    }
572
573    fn present_frame(&mut self, frame: &ff_format::VideoFrame) {
574        let pts = frame.timestamp().as_duration();
575        self.current_pts.store(
576            u64::try_from(pts.as_micros()).unwrap_or(u64::MAX),
577            Ordering::Relaxed,
578        );
579        let Some(sink) = self.sink.as_mut() else {
580            return;
581        };
582        let width = frame.width();
583        let height = frame.height();
584        if self.sws.convert(frame, &mut self.rgba_buf) {
585            sink.push_frame(&self.rgba_buf, width, height, pts);
586        }
587    }
588
589    fn restart_audio_from(&mut self, pts: Duration) {
590        if let Some(buf) = &self.audio_buf {
591            buf.lock()
592                .unwrap_or_else(std::sync::PoisonError::into_inner)
593                .clear();
594        }
595        if let Some(cancel) = &self.audio_cancel {
596            cancel.store(true, Ordering::Release);
597        }
598        drop(self.audio_handle.take());
599        if let Some(buf) = &self.audio_buf {
600            let new_cancel = Arc::new(AtomicBool::new(false));
601            let handle = spawn_audio_thread(
602                self.active_path.clone(),
603                pts,
604                Arc::clone(buf),
605                Arc::clone(&new_cancel),
606            );
607            self.audio_cancel = Some(new_cancel);
608            self.audio_handle = Some(handle);
609        }
610    }
611
612    fn activate_proxy(&mut self, proxy_path: &Path) -> Result<(), PreviewError> {
613        let info = ff_probe::open(proxy_path)?;
614        let fps = info.frame_rate().unwrap_or(30.0).max(1.0);
615        let decode_buf = DecodeBuffer::open(proxy_path)
616            .hardware_accel(self.hw_accel)
617            .build()?;
618
619        if let Some(cancel) = &self.audio_cancel {
620            cancel.store(true, Ordering::Release);
621        }
622        if let Some(buf) = &self.audio_buf {
623            buf.lock()
624                .unwrap_or_else(std::sync::PoisonError::into_inner)
625                .clear();
626        }
627        drop(self.audio_handle.take());
628
629        let (clock, audio_buf, audio_cancel, audio_handle) = if info.has_audio() {
630            let sample_rate = info.sample_rate().unwrap_or(48_000);
631            let buf = Arc::new(Mutex::new(VecDeque::<f32>::new()));
632            let cancel = Arc::new(AtomicBool::new(false));
633            let handle = spawn_audio_thread(
634                proxy_path.to_path_buf(),
635                Duration::ZERO,
636                Arc::clone(&buf),
637                Arc::clone(&cancel),
638            );
639            let clock = MasterClock::Audio {
640                samples_consumed: Arc::new(AtomicU64::new(0)),
641                sample_rate,
642                fallback: None,
643            };
644            (clock, Some(buf), Some(cancel), Some(handle))
645        } else {
646            log::debug!(
647                "proxy has no audio, using system clock path={}",
648                proxy_path.display()
649            );
650            let clock = MasterClock::System {
651                started_at: Instant::now(),
652                base_pts: Duration::ZERO,
653            };
654            (clock, None, None, None)
655        };
656
657        self.active_path = proxy_path.to_path_buf();
658        self.fps = fps;
659        self.decode_buf = Some(decode_buf);
660        self.clock = clock;
661        self.audio_buf = audio_buf;
662        self.audio_cancel = audio_cancel;
663        self.audio_handle = audio_handle;
664        Ok(())
665    }
666}
667
668impl Drop for PlayerRunner {
669    fn drop(&mut self) {
670        if let Some(cancel) = &self.audio_cancel {
671            cancel.store(true, Ordering::Release);
672        }
673        if let Some(h) = self.audio_handle.take() {
674            let _ = h.join();
675        }
676    }
677}
678
679// ── PreviewPlayer (thin builder) ──────────────────────────────────────────────
680
681/// Thin builder for a ([`PlayerRunner`], [`PlayerHandle`]) pair.
682///
683/// # Usage
684///
685/// ```ignore
686/// let (mut runner, handle) = PreviewPlayer::open("clip.mp4")?.split();
687///
688/// runner.set_sink(Box::new(MySink::new()));
689///
690/// let handle_audio = handle.clone();
691///
692/// std::thread::spawn(move || { let _ = runner.run(); });
693///
694/// handle.seek(Duration::from_secs(30));
695/// handle.play();
696///
697/// // cpal audio callback:
698/// device.build_output_stream(&cfg, move |buf: &mut [f32], _| {
699///     let s = handle_audio.pop_audio_samples(buf.len());
700///     buf[..s.len()].copy_from_slice(&s);
701/// }, ...);
702/// ```
703pub struct PreviewPlayer {
704    path: PathBuf,
705    /// `None` after `split()` consumes it.
706    decode_buf: Option<DecodeBuffer>,
707    fps: f64,
708    /// `None` after `split()` consumes it.
709    clock: Option<MasterClock>,
710    audio_buf: Option<Arc<Mutex<VecDeque<f32>>>>,
711    audio_cancel: Option<Arc<AtomicBool>>,
712    audio_handle: Option<JoinHandle<()>>,
713    duration_millis: u64,
714    active_path: PathBuf,
715}
716
717impl PreviewPlayer {
718    /// Open a media file and prepare for playback.
719    ///
720    /// Probes the file to detect audio/video streams, opens a
721    /// [`DecodeBuffer`] for the video stream (when present), and spawns a
722    /// background audio decode thread (when present). Returns
723    /// [`PreviewError`] if the file is missing or contains neither stream.
724    ///
725    /// # Errors
726    ///
727    /// Returns [`PreviewError`] if the file cannot be probed or decoded.
728    pub fn open(path: impl AsRef<Path>) -> Result<Self, PreviewError> {
729        let path = path.as_ref();
730        let info = ff_probe::open(path)?;
731
732        if !info.has_video() && !info.has_audio() {
733            return Err(PreviewError::Ffmpeg {
734                code: -1,
735                message: "file has neither a video nor an audio stream".into(),
736            });
737        }
738
739        let fps = info.frame_rate().unwrap_or(30.0).max(1.0);
740
741        let d = info.duration();
742        let duration_millis = if d.is_zero() {
743            u64::MAX
744        } else {
745            u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
746        };
747
748        let clock = if info.has_audio() {
749            let sample_rate = info.sample_rate().unwrap_or(48_000);
750            MasterClock::Audio {
751                samples_consumed: Arc::new(AtomicU64::new(0)),
752                sample_rate,
753                fallback: None,
754            }
755        } else {
756            log::debug!(
757                "using system clock fallback path={} no_audio=true",
758                path.display()
759            );
760            MasterClock::System {
761                started_at: Instant::now(),
762                base_pts: Duration::ZERO,
763            }
764        };
765
766        let decode_buf = if info.has_video() {
767            Some(DecodeBuffer::open(path).build()?)
768        } else {
769            log::debug!(
770                "audio-only file; skipping video decode buffer path={}",
771                path.display()
772            );
773            None
774        };
775
776        let (audio_buf, audio_cancel, audio_handle) = if let MasterClock::Audio { .. } = &clock {
777            let buf = Arc::new(Mutex::new(VecDeque::<f32>::new()));
778            let cancel = Arc::new(AtomicBool::new(false));
779            let handle = spawn_audio_thread(
780                path.to_path_buf(),
781                Duration::ZERO,
782                Arc::clone(&buf),
783                Arc::clone(&cancel),
784            );
785            (Some(buf), Some(cancel), Some(handle))
786        } else {
787            (None, None, None)
788        };
789
790        Ok(PreviewPlayer {
791            path: path.to_path_buf(),
792            decode_buf,
793            fps,
794            clock: Some(clock),
795            audio_buf,
796            audio_cancel,
797            audio_handle,
798            duration_millis,
799            active_path: path.to_path_buf(),
800        })
801    }
802
803    /// Consume `self` and return an exclusive [`PlayerRunner`] and a shared
804    /// [`PlayerHandle`].
805    ///
806    /// The runner owns the decode pipeline; move it to a background thread
807    /// and call [`PlayerRunner::run`].
808    /// The handle is `Clone + Send + Sync` and can be shared freely.
809    ///
810    /// # Panics
811    ///
812    /// Never panics in practice — the internal clock is always `Some` when
813    /// `split` is first called.
814    #[must_use]
815    #[allow(clippy::expect_used)]
816    pub fn split(mut self) -> (PlayerRunner, PlayerHandle) {
817        let current_pts = Arc::new(AtomicU64::new(0));
818        let paused = Arc::new(AtomicBool::new(false));
819        let stopped = Arc::new(AtomicBool::new(false));
820        let (cmd_tx, cmd_rx) = mpsc::sync_channel(CHANNEL_CAP);
821        let (event_tx, event_rx) = mpsc::sync_channel(CHANNEL_CAP);
822
823        let clock = self.clock.take().expect("clock consumed before split");
824        let samples_consumed = match &clock {
825            MasterClock::Audio {
826                samples_consumed, ..
827            } => Some(Arc::clone(samples_consumed)),
828            MasterClock::System { .. } => None,
829        };
830
831        let audio_buf_for_handle = self.audio_buf.clone();
832        let duration_millis = self.duration_millis;
833
834        let runner = PlayerRunner {
835            path: self.path.clone(),
836            cmd_rx,
837            event_tx,
838            decode_buf: self.decode_buf.take(),
839            fps: self.fps,
840            sink: None,
841            clock,
842            audio_buf: self.audio_buf.take(),
843            audio_cancel: self.audio_cancel.take(),
844            audio_handle: self.audio_handle.take(),
845            sws: super::playback_inner::SwsRgbaConverter::new(),
846            rgba_buf: Vec::new(),
847            active_path: self.active_path.clone(),
848            current_pts: Arc::clone(&current_pts),
849            paused: Arc::clone(&paused),
850            stopped: Arc::clone(&stopped),
851            av_offset_ms: 0,
852            rate: 1.0,
853            duration_millis,
854            frame_cache: None,
855            hw_accel: HardwareAccel::Auto,
856        };
857
858        let handle = PlayerHandle {
859            cmd_tx,
860            event_rx: Arc::new(Mutex::new(event_rx)),
861            current_pts,
862            audio_buf: audio_buf_for_handle,
863            samples_consumed,
864            audio_mixer: None,
865            paused,
866            stopped,
867            duration_millis,
868        };
869
870        (runner, handle)
871    }
872}
873
874impl Drop for PreviewPlayer {
875    fn drop(&mut self) {
876        if let Some(cancel) = &self.audio_cancel {
877            cancel.store(true, Ordering::Release);
878        }
879        if let Some(h) = self.audio_handle.take() {
880            let _ = h.join();
881        }
882    }
883}
884
885// ── spawn_audio_thread ────────────────────────────────────────────────────────
886
887fn spawn_audio_thread(
888    path: PathBuf,
889    start_pts: Duration,
890    buf: Arc<Mutex<VecDeque<f32>>>,
891    cancel: Arc<AtomicBool>,
892) -> JoinHandle<()> {
893    thread::spawn(move || {
894        let mut decoder = match AudioDecoder::open(&path)
895            .output_format(SampleFormat::F32)
896            .output_sample_rate(48_000)
897            .output_channels(2)
898            .build()
899        {
900            Ok(d) => d,
901            Err(e) => {
902                log::warn!("audio decode thread open failed error={e}");
903                return;
904            }
905        };
906
907        if start_pts != Duration::ZERO
908            && let Err(e) = decoder.seek(start_pts, SeekMode::Backward)
909        {
910            log::warn!("audio seek failed pts={start_pts:?} error={e}");
911        }
912
913        loop {
914            if cancel.load(Ordering::Acquire) {
915                break;
916            }
917
918            let buf_len = buf
919                .lock()
920                .unwrap_or_else(std::sync::PoisonError::into_inner)
921                .len();
922            if buf_len >= AUDIO_MAX_BUF {
923                thread::sleep(Duration::from_millis(1));
924                continue;
925            }
926
927            match decoder.decode_one() {
928                Ok(Some(frame)) => {
929                    let samples = super::playback_inner::audio_frame_to_f32(&frame);
930                    if !samples.is_empty() {
931                        let mut guard = buf
932                            .lock()
933                            .unwrap_or_else(std::sync::PoisonError::into_inner);
934                        let space = AUDIO_MAX_BUF.saturating_sub(guard.len());
935                        guard.extend(samples.into_iter().take(space));
936                    }
937                }
938                Ok(None) => break,
939                Err(e) => {
940                    log::warn!("audio decode error error={e}");
941                    break;
942                }
943            }
944        }
945    })
946}
947
948// ── Tests ─────────────────────────────────────────────────────────────────────
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953
954    fn test_video_path() -> PathBuf {
955        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/video/gameplay.mp4")
956    }
957
958    fn test_audio_path() -> PathBuf {
959        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/audio/konekonoosanpo.mp3")
960    }
961
962    // ── open ──────────────────────────────────────────────────────────────────
963
964    #[test]
965    fn preview_player_open_should_fail_for_nonexistent_file() {
966        let result = PreviewPlayer::open("nonexistent_preview.mp4");
967        assert!(
968            result.is_err(),
969            "open() must return Err for a non-existent file"
970        );
971    }
972
973    // ── play / pause / stop via handle ───────────────────────────────────────
974
975    #[test]
976    fn player_handle_play_pause_should_update_paused_flag_immediately() {
977        let path = test_video_path();
978        let (_runner, handle) = match PreviewPlayer::open(&path) {
979            Ok(p) => p.split(),
980            Err(e) => {
981                println!("skipping: video file not available: {e}");
982                return;
983            }
984        };
985
986        assert!(!handle.paused.load(Ordering::Relaxed));
987        assert!(!handle.stopped.load(Ordering::Relaxed));
988
989        handle.pause();
990        assert!(handle.paused.load(Ordering::Relaxed));
991
992        handle.play();
993        assert!(!handle.paused.load(Ordering::Relaxed));
994        assert!(!handle.stopped.load(Ordering::Relaxed));
995
996        handle.stop();
997        assert!(handle.stopped.load(Ordering::Relaxed));
998    }
999
1000    // ── run with sink ─────────────────────────────────────────────────────────
1001
1002    #[test]
1003    fn player_runner_run_should_deliver_frames_to_sink() {
1004        struct CountSink(Arc<Mutex<usize>>);
1005        impl FrameSink for CountSink {
1006            fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1007                *self
1008                    .0
1009                    .lock()
1010                    .unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
1011            }
1012        }
1013
1014        let path = test_video_path();
1015        let (mut runner, _handle) = match PreviewPlayer::open(&path) {
1016            Ok(p) => p.split(),
1017            Err(e) => {
1018                println!("skipping: video file not available: {e}");
1019                return;
1020            }
1021        };
1022
1023        let count = Arc::new(Mutex::new(0usize));
1024        runner.set_sink(Box::new(CountSink(Arc::clone(&count))));
1025
1026        match runner.run() {
1027            Ok(()) => {}
1028            Err(e) => {
1029                println!("skipping: run() error: {e}");
1030                return;
1031            }
1032        }
1033
1034        let frames = *count
1035            .lock()
1036            .unwrap_or_else(std::sync::PoisonError::into_inner);
1037        assert!(
1038            frames > 0,
1039            "run() must deliver at least one frame to the sink"
1040        );
1041    }
1042
1043    // ── pop_audio_samples ────────────────────────────────────────────────────
1044
1045    #[test]
1046    fn pop_audio_samples_should_return_empty_when_paused() {
1047        let path = test_video_path();
1048        let (_runner, handle) = match PreviewPlayer::open(&path) {
1049            Ok(p) => p.split(),
1050            Err(e) => {
1051                println!("skipping: video file not available: {e}");
1052                return;
1053            }
1054        };
1055        handle.pause();
1056        let samples = handle.pop_audio_samples(1024);
1057        assert!(
1058            samples.is_empty(),
1059            "pop_audio_samples() must return empty while paused"
1060        );
1061    }
1062
1063    #[test]
1064    fn pop_audio_samples_should_return_empty_when_stopped() {
1065        let path = test_video_path();
1066        let (_runner, handle) = match PreviewPlayer::open(&path) {
1067            Ok(p) => p.split(),
1068            Err(e) => {
1069                println!("skipping: video file not available: {e}");
1070                return;
1071            }
1072        };
1073        handle.stop();
1074        let samples = handle.pop_audio_samples(1024);
1075        assert!(
1076            samples.is_empty(),
1077            "pop_audio_samples() must return empty while stopped"
1078        );
1079    }
1080
1081    #[test]
1082    fn pop_audio_samples_should_return_empty_for_zero_n_samples() {
1083        let path = test_video_path();
1084        let (_runner, handle) = match PreviewPlayer::open(&path) {
1085            Ok(p) => p.split(),
1086            Err(e) => {
1087                println!("skipping: video file not available: {e}");
1088                return;
1089            }
1090        };
1091        handle.play();
1092        let samples = handle.pop_audio_samples(0);
1093        assert!(
1094            samples.is_empty(),
1095            "pop_audio_samples(0) must always return empty"
1096        );
1097    }
1098
1099    #[test]
1100    fn pop_audio_samples_should_be_callable_via_cloned_handle() {
1101        let path = test_video_path();
1102        let (_runner, handle) = match PreviewPlayer::open(&path) {
1103            Ok(p) => p.split(),
1104            Err(e) => {
1105                println!("skipping: video file not available: {e}");
1106                return;
1107            }
1108        };
1109        let shared = handle.clone();
1110        let _samples = shared.pop_audio_samples(0);
1111    }
1112
1113    #[test]
1114    fn pop_audio_samples_clock_increment_should_equal_half_sample_count() {
1115        let stereo_samples: usize = 9_600;
1116        let expected_frames: u64 = (stereo_samples / 2) as u64;
1117        assert_eq!(
1118            expected_frames, 4_800,
1119            "9600 stereo samples must yield 4800 clock frames"
1120        );
1121        let pts = Duration::from_secs_f64(f64::from(48_000u32).recip() * expected_frames as f64);
1122        assert!(
1123            (pts.as_secs_f64() - 0.1).abs() < 1e-6,
1124            "4800 frames at 48 kHz must equal 100 ms; got {pts:?}"
1125        );
1126    }
1127
1128    // ── current_pts / duration ───────────────────────────────────────────────
1129
1130    #[test]
1131    fn current_pts_should_return_zero_before_first_frame() {
1132        let path = test_video_path();
1133        let (_runner, handle) = match PreviewPlayer::open(&path) {
1134            Ok(p) => p.split(),
1135            Err(e) => {
1136                println!("skipping: video file not available: {e}");
1137                return;
1138            }
1139        };
1140        assert_eq!(
1141            handle.current_pts(),
1142            Duration::ZERO,
1143            "current_pts() must be ZERO before any frame is presented"
1144        );
1145    }
1146
1147    #[test]
1148    fn duration_should_return_some_for_file_with_known_duration() {
1149        let path = test_video_path();
1150        let (_runner, handle) = match PreviewPlayer::open(&path) {
1151            Ok(p) => p.split(),
1152            Err(e) => {
1153                println!("skipping: video file not available: {e}");
1154                return;
1155            }
1156        };
1157        assert!(
1158            handle.duration().is_some(),
1159            "duration() must return Some for a file with a known container duration"
1160        );
1161        let d = handle.duration().unwrap();
1162        assert!(
1163            d > Duration::ZERO,
1164            "duration() must be positive for a valid media file; got {d:?}"
1165        );
1166    }
1167
1168    #[test]
1169    fn duration_should_return_none_when_duration_millis_is_sentinel() {
1170        let sentinel = u64::MAX;
1171        let result: Option<Duration> = if sentinel == u64::MAX {
1172            None
1173        } else {
1174            Some(Duration::from_millis(sentinel))
1175        };
1176        assert!(result.is_none(), "sentinel u64::MAX must map to None");
1177
1178        let valid = 5_000u64;
1179        let result: Option<Duration> = if valid == u64::MAX {
1180            None
1181        } else {
1182            Some(Duration::from_millis(valid))
1183        };
1184        assert_eq!(result, Some(Duration::from_secs(5)));
1185    }
1186
1187    #[test]
1188    fn current_pts_should_advance_after_frames_are_presented() {
1189        struct PtsSink(Arc<Mutex<Option<Duration>>>);
1190        impl FrameSink for PtsSink {
1191            fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, pts: Duration) {
1192                *self
1193                    .0
1194                    .lock()
1195                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(pts);
1196            }
1197        }
1198
1199        let path = test_video_path();
1200        let (mut runner, handle) = match PreviewPlayer::open(&path) {
1201            Ok(p) => p.split(),
1202            Err(e) => {
1203                println!("skipping: video file not available: {e}");
1204                return;
1205            }
1206        };
1207
1208        let last_pts = Arc::new(Mutex::new(None::<Duration>));
1209        runner.set_sink(Box::new(PtsSink(Arc::clone(&last_pts))));
1210        let _ = runner.run();
1211
1212        let sink_pts = last_pts
1213            .lock()
1214            .unwrap_or_else(std::sync::PoisonError::into_inner)
1215            .unwrap_or(Duration::ZERO);
1216        let player_pts = handle.current_pts();
1217        let diff = sink_pts.abs_diff(player_pts);
1218        assert!(
1219            diff <= Duration::from_millis(1),
1220            "current_pts() must be within 1 ms of the last sink PTS; \
1221             player_pts={player_pts:?} sink_pts={sink_pts:?} diff={diff:?}"
1222        );
1223    }
1224
1225    // ── seek ──────────────────────────────────────────────────────────────────
1226
1227    #[test]
1228    fn seek_coarse_should_delegate_to_decode_buffer() {
1229        let path = test_video_path();
1230        let (runner, handle) = match PreviewPlayer::open(&path) {
1231            Ok(p) => p.split(),
1232            Err(e) => {
1233                println!("skipping: video file not available: {e}");
1234                return;
1235            }
1236        };
1237
1238        let target = Duration::from_secs(1);
1239        handle.seek(target);
1240
1241        // Stop after a short time so the test doesn't block for the full file.
1242        let handle_thread = handle.clone();
1243        thread::spawn(move || {
1244            thread::sleep(Duration::from_millis(500));
1245            handle_thread.stop();
1246        });
1247
1248        match runner.run() {
1249            Ok(()) => {}
1250            Err(e) => {
1251                println!("skipping: run() error: {e}");
1252            }
1253        }
1254    }
1255
1256    // ── proxy ─────────────────────────────────────────────────────────────────
1257
1258    #[test]
1259    fn use_proxy_if_available_should_return_false_when_no_proxy_in_dir() {
1260        let path = test_video_path();
1261        let (mut runner, _handle) = match PreviewPlayer::open(&path) {
1262            Ok(p) => p.split(),
1263            Err(e) => {
1264                println!("skipping: video file not available: {e}");
1265                return;
1266            }
1267        };
1268        let tmp = std::env::temp_dir().join("ff_preview_no_proxy_dir_test");
1269        let _ = std::fs::create_dir_all(&tmp);
1270        let found = runner.use_proxy_if_available(&tmp);
1271        assert!(
1272            !found,
1273            "must return false when no proxy files exist in the directory"
1274        );
1275    }
1276
1277    #[test]
1278    fn active_source_should_return_original_path_before_proxy_activation() {
1279        let path = test_video_path();
1280        let (runner, _handle) = match PreviewPlayer::open(&path) {
1281            Ok(p) => p.split(),
1282            Err(e) => {
1283                println!("skipping: video file not available: {e}");
1284                return;
1285            }
1286        };
1287        assert_eq!(
1288            runner.active_source(),
1289            path.as_path(),
1290            "active_source() must equal the original path before any proxy activation"
1291        );
1292    }
1293
1294    // ── set_rate / set_av_offset ──────────────────────────────────────────────
1295
1296    #[test]
1297    fn set_rate_should_accept_positive_value() {
1298        let path = test_video_path();
1299        let (_runner, handle) = match PreviewPlayer::open(&path) {
1300            Ok(p) => p.split(),
1301            Err(e) => {
1302                println!("skipping: video file not available: {e}");
1303                return;
1304            }
1305        };
1306        // Verify that calling set_rate with a valid value does not panic.
1307        handle.set_rate(2.0);
1308        handle.set_rate(0.5);
1309    }
1310
1311    #[test]
1312    fn set_av_offset_default_should_be_zero() {
1313        use std::sync::atomic::{AtomicI64, Ordering};
1314        let offset = AtomicI64::new(0);
1315        assert_eq!(offset.load(Ordering::Relaxed), 0);
1316    }
1317
1318    #[test]
1319    fn positive_av_offset_should_reduce_adjusted_video_pts() {
1320        let video_pts = Duration::from_millis(1_000);
1321        let offset_ms: i64 = 200;
1322        let adjusted = if offset_ms >= 0 {
1323            let offset = Duration::from_millis(offset_ms as u64);
1324            video_pts.saturating_sub(offset)
1325        } else {
1326            let offset = Duration::from_millis(offset_ms.unsigned_abs());
1327            video_pts + offset
1328        };
1329        assert_eq!(
1330            adjusted,
1331            Duration::from_millis(800),
1332            "positive offset must reduce adjusted_video_pts by offset amount"
1333        );
1334    }
1335
1336    #[test]
1337    fn negative_av_offset_should_increase_adjusted_video_pts() {
1338        let video_pts = Duration::from_millis(1_000);
1339        let offset_ms: i64 = -200;
1340        let adjusted = if offset_ms >= 0 {
1341            let offset = Duration::from_millis(offset_ms as u64);
1342            video_pts.saturating_sub(offset)
1343        } else {
1344            let offset = Duration::from_millis(offset_ms.unsigned_abs());
1345            video_pts + offset
1346        };
1347        assert_eq!(
1348            adjusted,
1349            Duration::from_millis(1_200),
1350            "negative offset must increase adjusted_video_pts by offset amount"
1351        );
1352    }
1353
1354    #[test]
1355    fn positive_av_offset_at_zero_pts_should_saturate_to_zero() {
1356        let video_pts = Duration::ZERO;
1357        let offset_ms: i64 = 100;
1358        let adjusted = video_pts.saturating_sub(Duration::from_millis(offset_ms as u64));
1359        assert_eq!(
1360            adjusted,
1361            Duration::ZERO,
1362            "saturating_sub on zero pts must clamp to zero not underflow"
1363        );
1364    }
1365
1366    // ── audio-only ────────────────────────────────────────────────────────────
1367
1368    #[test]
1369    fn audio_only_open_should_succeed() {
1370        let path = test_audio_path();
1371        match PreviewPlayer::open(&path) {
1372            Ok(player) => {
1373                let (runner, handle) = player.split();
1374                // Audio-only: runner has no decode buffer.
1375                assert!(
1376                    runner.decode_buf.is_none(),
1377                    "audio-only runner must have no video decode buffer"
1378                );
1379                // Handle has an audio buffer.
1380                assert!(
1381                    handle.audio_buf.is_some(),
1382                    "audio-only handle must have an audio ring buffer"
1383                );
1384            }
1385            Err(e) => {
1386                println!("skipping: audio file not available: {e}");
1387            }
1388        }
1389    }
1390
1391    #[test]
1392    fn audio_only_run_should_return_ok_without_video_frames() {
1393        let path = test_audio_path();
1394        let (mut runner, handle) = match PreviewPlayer::open(&path) {
1395            Ok(p) => p.split(),
1396            Err(e) => {
1397                println!("skipping: audio file not available: {e}");
1398                return;
1399            }
1400        };
1401
1402        struct CountingSink(usize);
1403        impl FrameSink for CountingSink {
1404            fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1405                self.0 += 1;
1406            }
1407        }
1408        runner.set_sink(Box::new(CountingSink(0)));
1409
1410        let handle_thread = handle.clone();
1411        thread::spawn(move || {
1412            thread::sleep(Duration::from_millis(150));
1413            handle_thread.stop();
1414        });
1415
1416        let result = runner.run();
1417        assert!(
1418            result.is_ok(),
1419            "run() on an audio-only player must return Ok; got {result:?}"
1420        );
1421        assert_eq!(
1422            handle.current_pts(),
1423            Duration::ZERO,
1424            "current_pts() must remain ZERO for audio-only playback (no video frames)"
1425        );
1426    }
1427
1428    #[test]
1429    fn audio_only_seek_should_not_fail_for_valid_target() {
1430        let path = test_audio_path();
1431        let (_runner, handle) = match PreviewPlayer::open(&path) {
1432            Ok(p) => p.split(),
1433            Err(e) => {
1434                println!("skipping: audio file not available: {e}");
1435                return;
1436            }
1437        };
1438        // seek() on audio-only player sends a command without errors.
1439        handle.seek(Duration::from_secs(1));
1440    }
1441
1442    // ── seek event delivery (integration) ────────────────────────────────────
1443
1444    #[test]
1445    #[ignore = "requires assets/video/gameplay.mp4; run with -- --include-ignored"]
1446    fn seek_should_deliver_seek_completed_event_via_poll_event() {
1447        let path = test_video_path();
1448        if !path.exists() {
1449            println!("skipping: video file not found at {}", path.display());
1450            return;
1451        }
1452
1453        let (runner, handle) = match PreviewPlayer::open(&path) {
1454            Ok(p) => p.split(),
1455            Err(e) => {
1456                println!("skipping: open failed: {e}");
1457                return;
1458            }
1459        };
1460
1461        let handle_bg = handle.clone();
1462        let bg = thread::spawn(move || {
1463            let _ = runner.run();
1464        });
1465
1466        // Give the runner one frame period to start, then seek.
1467        thread::sleep(Duration::from_millis(50));
1468        let target = Duration::from_secs(1);
1469        handle.seek(target);
1470
1471        // Wait up to 2 seconds for SeekCompleted.
1472        let deadline = Instant::now() + Duration::from_secs(2);
1473        let event = loop {
1474            if let Some(e) = handle.poll_event() {
1475                break Some(e);
1476            }
1477            if Instant::now() > deadline {
1478                break None;
1479            }
1480            thread::sleep(Duration::from_millis(10));
1481        };
1482
1483        handle_bg.stop();
1484        let _ = bg.join();
1485
1486        match event {
1487            Some(PlayerEvent::SeekCompleted(pts)) => {
1488                assert!(
1489                    pts >= target.saturating_sub(Duration::from_millis(100)),
1490                    "SeekCompleted pts must be near the requested target; \
1491                     target={target:?} pts={pts:?}"
1492                );
1493            }
1494            Some(PlayerEvent::Eof) => {
1495                panic!("received Eof before SeekCompleted — file may be too short");
1496            }
1497            Some(PlayerEvent::PositionUpdate(_) | PlayerEvent::Error(_)) | None => {
1498                panic!("no PlayerEvent::SeekCompleted received within 2 seconds");
1499            }
1500        }
1501    }
1502
1503    // ── PlayerEvent: PositionUpdate + Error ───────────────────────────────────
1504
1505    #[test]
1506    fn position_update_and_error_event_variants_should_be_accessible() {
1507        let _ = PlayerEvent::PositionUpdate(Duration::ZERO);
1508        let _ = PlayerEvent::Error("test error".to_string());
1509    }
1510
1511    #[test]
1512    fn eof_event_should_be_delivered_after_run_completes() {
1513        let path = test_audio_path();
1514        let (runner, handle) = match PreviewPlayer::open(&path) {
1515            Ok(p) => p.split(),
1516            Err(e) => {
1517                println!("skipping: {e}");
1518                return;
1519            }
1520        };
1521
1522        // Stop after 150 ms so the test does not wait for the full audio duration.
1523        let handle_stop = handle.clone();
1524        thread::spawn(move || {
1525            thread::sleep(Duration::from_millis(150));
1526            handle_stop.stop();
1527        });
1528
1529        let _ = runner.run();
1530        let events: Vec<_> = std::iter::from_fn(|| handle.poll_event()).collect();
1531        assert!(
1532            events.iter().any(|e| matches!(e, PlayerEvent::Eof)),
1533            "Eof event must be delivered after run() returns; collected {} events",
1534            events.len()
1535        );
1536    }
1537
1538    #[test]
1539    #[ignore = "requires assets/video/gameplay.mp4; run with -- --include-ignored"]
1540    fn position_update_should_be_emitted_for_each_video_frame() {
1541        let path =
1542            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/video/gameplay.mp4");
1543        if !path.exists() {
1544            println!("skipping: video asset not found");
1545            return;
1546        }
1547
1548        use std::sync::{Arc, Mutex};
1549        struct CountSink {
1550            count: Arc<Mutex<usize>>,
1551            max: usize,
1552            handle: PlayerHandle,
1553        }
1554        impl FrameSink for CountSink {
1555            fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1556                let mut g = self
1557                    .count
1558                    .lock()
1559                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1560                *g += 1;
1561                if *g >= self.max {
1562                    self.handle.stop();
1563                }
1564            }
1565        }
1566
1567        let (mut runner, handle) = match PreviewPlayer::open(&path) {
1568            Ok(p) => p.split(),
1569            Err(e) => {
1570                println!("skipping: {e}");
1571                return;
1572            }
1573        };
1574
1575        let count = Arc::new(Mutex::new(0usize));
1576        runner.set_sink(Box::new(CountSink {
1577            count: Arc::clone(&count),
1578            max: 20,
1579            handle: handle.clone(),
1580        }));
1581        let _ = runner.run();
1582
1583        let frames = *count
1584            .lock()
1585            .unwrap_or_else(std::sync::PoisonError::into_inner);
1586        let position_updates: Vec<_> = std::iter::from_fn(|| handle.poll_event())
1587            .filter(|e| matches!(e, PlayerEvent::PositionUpdate(_)))
1588            .collect();
1589
1590        assert!(
1591            !position_updates.is_empty(),
1592            "at least one PositionUpdate event must be emitted; frames delivered={frames}"
1593        );
1594        assert!(
1595            position_updates.len() <= frames,
1596            "PositionUpdate count ({}) must not exceed frame count ({frames})",
1597            position_updates.len()
1598        );
1599    }
1600
1601    // ── HardwareAccel ─────────────────────────────────────────────────────────
1602
1603    #[test]
1604    fn hardware_accel_variants_should_be_accessible_on_player_runner() {
1605        // Type-check / accessibility test — no asset required.
1606        let _ = HardwareAccel::Auto;
1607        let _ = HardwareAccel::None;
1608        let _ = HardwareAccel::Nvdec;
1609        let _ = HardwareAccel::Qsv;
1610        let _ = HardwareAccel::Amf;
1611        let _ = HardwareAccel::VideoToolbox;
1612        let _ = HardwareAccel::Vaapi;
1613    }
1614
1615    #[test]
1616    fn set_hardware_accel_none_should_complete_without_error_on_audio_only_file() {
1617        // Audio-only path has no video decode buffer; the hw_accel rebuild
1618        // at run() start is skipped.  Verifies the setter is a no-op when
1619        // no decode buffer exists, and run() still returns Ok.
1620        let path = test_audio_path();
1621        let (mut runner, handle) = match PreviewPlayer::open(&path) {
1622            Ok(p) => p.split(),
1623            Err(e) => {
1624                println!("skipping: audio file not available: {e}");
1625                return;
1626            }
1627        };
1628
1629        runner.set_hardware_accel(HardwareAccel::None);
1630        assert_eq!(runner.hw_accel, HardwareAccel::None);
1631
1632        let handle_stop = handle.clone();
1633        thread::spawn(move || {
1634            thread::sleep(Duration::from_millis(150));
1635            handle_stop.stop();
1636        });
1637
1638        let result = runner.run();
1639        assert!(
1640            result.is_ok(),
1641            "run() with HardwareAccel::None must return Ok; got {result:?}"
1642        );
1643    }
1644
1645    #[test]
1646    #[ignore = "requires assets/video/gameplay.mp4 and hardware decoder; run with -- --include-ignored"]
1647    fn hardware_accel_auto_should_deliver_frames_on_video_file() {
1648        let path = test_video_path();
1649        let (mut runner, handle) = match PreviewPlayer::open(&path) {
1650            Ok(p) => p.split(),
1651            Err(e) => {
1652                println!("skipping: video file not available: {e}");
1653                return;
1654            }
1655        };
1656
1657        runner.set_hardware_accel(HardwareAccel::Auto);
1658
1659        struct CountSink {
1660            count: usize,
1661            max: usize,
1662            handle: PlayerHandle,
1663        }
1664        impl FrameSink for CountSink {
1665            fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1666                self.count += 1;
1667                if self.count >= self.max {
1668                    self.handle.stop();
1669                }
1670            }
1671        }
1672        runner.set_sink(Box::new(CountSink {
1673            count: 0,
1674            max: 5,
1675            handle: handle.clone(),
1676        }));
1677
1678        let result = runner.run();
1679        assert!(
1680            result.is_ok(),
1681            "run() with HardwareAccel::Auto must return Ok; got {result:?}"
1682        );
1683        assert!(
1684            handle.current_pts() > Duration::ZERO,
1685            "at least one frame must have been presented"
1686        );
1687    }
1688}