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