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