Skip to main content

ff_preview/playback/
player.rs

1//! `PreviewPlayer` — main playback driver for ff-preview.
2//!
3//! All safe Rust logic lives here. Unsafe `FFmpeg` calls are isolated in
4//! `playback_inner`.
5
6use std::collections::VecDeque;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::sync::Mutex;
10use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
11use std::thread::{self, JoinHandle};
12use std::time::{Duration, Instant};
13
14use ff_decode::{AudioDecoder, SeekMode};
15use ff_format::SampleFormat;
16
17use super::clock::MasterClock;
18use super::decode_buffer::{DecodeBuffer, FrameResult, SeekEvent};
19use super::sink::FrameSink;
20use crate::error::PreviewError;
21
22// ── Constants ─────────────────────────────────────────────────────────────────
23
24/// Maximum number of interleaved stereo `f32` samples to buffer for audio
25/// playback (2 s × 48 kHz × 2 channels = 96 000).
26const AUDIO_MAX_BUF: usize = 96_000;
27
28// ── PreviewPlayer ─────────────────────────────────────────────────────────────
29
30/// Drives real-time playback of a single media file.
31///
32/// `PreviewPlayer` decodes a video/audio file, synchronises video frame
33/// presentation to an audio master clock, and delivers frames to a
34/// registered [`FrameSink`].
35///
36/// # Usage
37///
38/// ```ignore
39/// let mut player = PreviewPlayer::open(Path::new("clip.mp4"))?;
40/// player.set_sink(Box::new(MySink::new()));
41/// player.play();
42/// player.run()?;
43/// ```
44pub struct PreviewPlayer {
45    /// Path to the media file; retained so the audio decode thread can be
46    /// restarted from a new position after a seek.
47    path: PathBuf,
48    /// Pre-decoded frame buffer driven by a background thread.
49    /// `None` for audio-only files that have no video stream.
50    decode_buf: Option<DecodeBuffer>,
51    /// Video frame rate; used to compute the frame period for A/V sync.
52    fps: f64,
53    /// Frame sink registered via [`set_sink`](Self::set_sink). Optional;
54    /// frames are discarded silently if no sink is set.
55    sink: Option<Box<dyn FrameSink>>,
56    /// Set to `true` while the presentation loop is paused.
57    paused: Arc<AtomicBool>,
58    /// Set to `true` to signal [`run`](Self::run) to stop after the current frame.
59    stopped: Arc<AtomicBool>,
60    /// Master clock for A/V sync: audio samples counter or `Instant` wall clock.
61    clock: MasterClock,
62    /// A/V offset correction in milliseconds (default: 0).
63    ///
64    /// Positive: video is delayed (video PTS adjusted down).
65    /// Negative: audio is delayed (video PTS adjusted up).
66    av_offset_ms: Arc<AtomicI64>,
67    /// Decoded audio samples (interleaved f32 stereo at 48 kHz).
68    /// `None` when the media file has no audio track.
69    audio_buf: Option<Arc<Mutex<VecDeque<f32>>>>,
70    /// Cancel flag for the background audio decode thread.
71    /// `None` when the media file has no audio track.
72    audio_cancel: Option<Arc<AtomicBool>>,
73    /// Handle for the background audio decode thread.
74    audio_handle: Option<JoinHandle<()>>,
75    /// Lazy `sws_scale` converter that converts each frame to packed RGBA.
76    /// Re-creates the `SwsContext` automatically when frame geometry changes.
77    sws: super::playback_inner::SwsRgbaConverter,
78    /// Scratch buffer reused by `present_frame` for the RGBA output of `sws.convert()`.
79    rgba_buf: Vec<u8>,
80    /// The path currently being decoded — either the original or an activated proxy.
81    /// Starts as a clone of `path`; updated by `use_proxy_if_available`.
82    active_path: PathBuf,
83    /// Set to `true` by `play()` to prevent `use_proxy_if_available` from being
84    /// called after playback has started.
85    started: AtomicBool,
86    /// PTS of the most recently presented frame, in milliseconds.
87    /// Updated atomically inside `present_frame()`; readable from any thread.
88    current_pts_millis: AtomicU64,
89    /// Container-reported duration in milliseconds.
90    /// `u64::MAX` when the container does not report a duration (live/streaming sources).
91    duration_millis: u64,
92    /// Playback rate stored as `f64` bits in an atomic (default: `1.0`).
93    /// Read once per frame in `run()` to scale the frame-pacing sleep duration.
94    /// Use `rate_handle()` to share this across threads.
95    rate_bits: Arc<AtomicU64>,
96}
97
98impl PreviewPlayer {
99    /// Open a media file and prepare for playback.
100    ///
101    /// Probes the file to detect audio/video streams, then opens a
102    /// [`DecodeBuffer`] for the video stream (when present). Returns
103    /// [`PreviewError`] if the file is missing, unreadable, or contains
104    /// neither a video nor an audio stream.
105    ///
106    /// Audio-only files (MP3, AAC, WAV, FLAC, …) are fully supported:
107    /// `run()` will pace itself via the audio master clock and deliver no
108    /// video frames. Callers should drain samples via
109    /// [`pop_audio_samples`](Self::pop_audio_samples).
110    ///
111    /// # Errors
112    ///
113    /// Returns [`PreviewError`] if the file cannot be probed or decoded.
114    pub fn open(path: &Path) -> Result<Self, PreviewError> {
115        let info = ff_probe::open(path)?;
116
117        if !info.has_video() && !info.has_audio() {
118            return Err(PreviewError::Ffmpeg {
119                code: -1,
120                message: "file has neither a video nor an audio stream".into(),
121            });
122        }
123
124        let fps = info.frame_rate().unwrap_or(30.0).max(1.0);
125
126        // Duration::ZERO means the container does not report a duration (live/streaming).
127        let d = info.duration();
128        let duration_millis = if d.is_zero() {
129            u64::MAX
130        } else {
131            u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
132        };
133
134        let clock = if info.has_audio() {
135            let sample_rate = info.sample_rate().unwrap_or(48_000);
136            MasterClock::Audio {
137                samples_consumed: Arc::new(AtomicU64::new(0)),
138                sample_rate,
139            }
140        } else {
141            log::debug!(
142                "using system clock fallback path={} no_audio=true",
143                path.display()
144            );
145            MasterClock::System {
146                started_at: Instant::now(),
147                base_pts: Duration::ZERO,
148            }
149        };
150
151        // Open the video decode buffer only when a video stream is present.
152        // Audio-only files skip this step and use audio-clock pacing in run().
153        let decode_buf = if info.has_video() {
154            Some(DecodeBuffer::open(path).build()?)
155        } else {
156            log::debug!(
157                "audio-only file; skipping video decode buffer path={}",
158                path.display()
159            );
160            None
161        };
162
163        // Spawn a background audio decode thread when an audio track is present.
164        let (audio_buf, audio_cancel, audio_handle) = if let MasterClock::Audio { .. } = &clock {
165            let buf = Arc::new(Mutex::new(VecDeque::<f32>::new()));
166            let cancel = Arc::new(AtomicBool::new(false));
167            let handle = spawn_audio_thread(
168                path.to_path_buf(),
169                Duration::ZERO,
170                Arc::clone(&buf),
171                Arc::clone(&cancel),
172            );
173            (Some(buf), Some(cancel), Some(handle))
174        } else {
175            (None, None, None)
176        };
177
178        Ok(PreviewPlayer {
179            path: path.to_path_buf(),
180            decode_buf,
181            fps,
182            sink: None,
183            paused: Arc::new(AtomicBool::new(false)),
184            stopped: Arc::new(AtomicBool::new(false)),
185            clock,
186            av_offset_ms: Arc::new(AtomicI64::new(0)),
187            audio_buf,
188            audio_cancel,
189            audio_handle,
190            sws: super::playback_inner::SwsRgbaConverter::new(),
191            rgba_buf: Vec::new(),
192            active_path: path.to_path_buf(),
193            started: AtomicBool::new(false),
194            current_pts_millis: AtomicU64::new(0),
195            duration_millis,
196            rate_bits: Arc::new(AtomicU64::new(1.0_f64.to_bits())),
197        })
198    }
199
200    /// Register the frame sink. Must be called before [`run`](Self::run).
201    pub fn set_sink(&mut self, sink: Box<dyn FrameSink>) {
202        self.sink = Some(sink);
203    }
204
205    /// Start (or resume) playback.
206    ///
207    /// Clears the `paused` and `stopped` flags. Must be called before
208    /// [`run`](Self::run).
209    pub fn play(&self) {
210        self.started.store(true, Ordering::Release);
211        self.paused.store(false, Ordering::Release);
212        self.stopped.store(false, Ordering::Release);
213    }
214
215    /// Pause playback. [`run`](Self::run) will spin-sleep until
216    /// [`play`](Self::play) is called again.
217    pub fn pause(&self) {
218        self.paused.store(true, Ordering::Release);
219    }
220
221    /// Stop playback.
222    ///
223    /// [`run`](Self::run) returns after the current frame completes.
224    pub fn stop(&mut self) {
225        self.stopped.store(true, Ordering::Release);
226    }
227
228    /// Returns a cloneable handle to the stop signal.
229    ///
230    /// Storing `true` into the returned [`Arc<AtomicBool>`] has the same effect
231    /// as calling [`stop`](Self::stop) and is safe to call from any context,
232    /// including from within a [`FrameSink::push_frame`] callback.
233    ///
234    /// # Example
235    ///
236    /// ```ignore
237    /// let stop = player.stop_handle();
238    /// player.set_sink(Box::new(MySink { stop, max_frames: 10 }));
239    /// player.play();
240    /// player.run()?;
241    /// ```
242    pub fn stop_handle(&self) -> Arc<AtomicBool> {
243        Arc::clone(&self.stopped)
244    }
245
246    /// Returns a cloneable handle to the pause flag.
247    ///
248    /// Storing `true` pauses [`run`](Self::run); storing `false` resumes it.
249    /// Safe to call from any context, including from a UI thread running
250    /// concurrently with [`run`](Self::run).
251    ///
252    /// # Example
253    ///
254    /// ```ignore
255    /// let pause = player.pause_handle();
256    /// let stop  = player.stop_handle();
257    ///
258    /// std::thread::spawn(move || { player.play(); let _ = player.run(); });
259    ///
260    /// pause.store(true, Ordering::Release);   // pause from UI thread
261    /// pause.store(false, Ordering::Release);  // resume
262    /// stop.store(true, Ordering::Release);    // stop
263    /// ```
264    pub fn pause_handle(&self) -> Arc<AtomicBool> {
265        Arc::clone(&self.paused)
266    }
267
268    /// Pop the next decoded video frame.
269    ///
270    /// Delegates to [`DecodeBuffer::pop_frame`]. Blocks until a frame is available.
271    /// Returns [`FrameResult::Eof`] at end of file or for audio-only files.
272    pub fn pop_frame(&mut self) -> FrameResult {
273        match self.decode_buf.as_mut() {
274            Some(buf) => buf.pop_frame(),
275            None => FrameResult::Eof,
276        }
277    }
278
279    /// Frame-accurate seek to `target_pts`.
280    ///
281    /// Delegates to [`DecodeBuffer::seek`]. Returns `Ok(())` immediately for
282    /// audio-only files (no video stream to seek).
283    ///
284    /// # Errors
285    ///
286    /// Returns [`PreviewError`] if the seek fails.
287    pub fn seek(&mut self, target_pts: Duration) -> Result<(), PreviewError> {
288        match self.decode_buf.as_mut() {
289            Some(buf) => buf.seek(target_pts),
290            None => Ok(()),
291        }
292    }
293
294    /// Coarse seek to the nearest I-frame at or before `target_pts`.
295    ///
296    /// Delegates to [`DecodeBuffer::seek_coarse`]. Faster than
297    /// [`seek`](Self::seek) because it skips the forward-decode discard phase.
298    /// The first frame after this call will be at the nearest preceding I-frame,
299    /// which may be up to ±½ GOP from `target_pts` (typically ±1–2 s for H.264
300    /// at default settings).
301    ///
302    /// **Typical use:** call repeatedly while a scrub bar is being dragged;
303    /// call [`seek`](Self::seek) on drag release for frame accuracy.
304    ///
305    /// ```ignore
306    /// // Scrub-bar drag handler:
307    /// player.seek_coarse(drag_pts)?;  // fast, called many times
308    ///
309    /// // Drag released:
310    /// player.seek(release_pts)?;      // exact, called once
311    /// ```
312    ///
313    /// # Errors
314    ///
315    /// Returns [`PreviewError`] if the seek fails.
316    pub fn seek_coarse(&mut self, target_pts: Duration) -> Result<(), PreviewError> {
317        match self.decode_buf.as_mut() {
318            Some(buf) => buf.seek_coarse(target_pts),
319            None => Ok(()),
320        }
321    }
322
323    /// If a proxy file for this media exists in `proxy_dir`, use it transparently.
324    ///
325    /// Must be called before [`play`](Self::play). Returns `true` if a proxy was
326    /// found and activated; returns `false` if no proxy exists (original file
327    /// continues to be used).
328    ///
329    /// Proxy lookup order: `half` → `quarter` → `eighth`; first match wins.
330    ///
331    /// When a proxy is active, [`FrameSink::push_frame`] delivers frames at the
332    /// proxy's native resolution. Callers should not assume a fixed resolution.
333    ///
334    /// If called after [`play`](Self::play), logs a warning and returns `false`.
335    pub fn use_proxy_if_available(&mut self, proxy_dir: &Path) -> bool {
336        if self.started.load(Ordering::Acquire) {
337            log::warn!("use_proxy_if_available called after play; ignored");
338            return false;
339        }
340        let stem = self
341            .path
342            .file_stem()
343            .and_then(|s| s.to_str())
344            .unwrap_or("output")
345            .to_owned();
346
347        for suffix in ["half", "quarter", "eighth"] {
348            let candidate = proxy_dir.join(format!("{stem}_proxy_{suffix}.mp4"));
349            if candidate.exists() {
350                match self.activate_proxy(&candidate) {
351                    Ok(()) => {
352                        log::debug!("proxy activated path={}", candidate.display());
353                        return true;
354                    }
355                    Err(e) => {
356                        log::warn!(
357                            "proxy activation failed path={} error={e}",
358                            candidate.display()
359                        );
360                    }
361                }
362            }
363        }
364        false
365    }
366
367    /// Returns the path currently being decoded — either the original file or
368    /// the activated proxy.
369    pub fn active_source(&self) -> &Path {
370        &self.active_path
371    }
372
373    /// Replace the internal decode buffer and audio thread with those backed by
374    /// `proxy_path`. Called exclusively from `use_proxy_if_available`.
375    fn activate_proxy(&mut self, proxy_path: &Path) -> Result<(), PreviewError> {
376        let info = ff_probe::open(proxy_path)?;
377        let fps = info.frame_rate().unwrap_or(30.0).max(1.0);
378        let decode_buf = DecodeBuffer::open(proxy_path).build()?;
379
380        // Cancel existing audio thread; clear stale samples.
381        if let Some(cancel) = &self.audio_cancel {
382            cancel.store(true, Ordering::Release);
383        }
384        if let Some(buf) = &self.audio_buf {
385            buf.lock()
386                .unwrap_or_else(std::sync::PoisonError::into_inner)
387                .clear();
388        }
389        // Detach — the old thread exits on its own when cancel fires.
390        drop(self.audio_handle.take());
391
392        let (clock, audio_buf, audio_cancel, audio_handle) = if info.has_audio() {
393            let sample_rate = info.sample_rate().unwrap_or(48_000);
394            let buf = Arc::new(Mutex::new(VecDeque::<f32>::new()));
395            let cancel = Arc::new(AtomicBool::new(false));
396            let handle = spawn_audio_thread(
397                proxy_path.to_path_buf(),
398                Duration::ZERO,
399                Arc::clone(&buf),
400                Arc::clone(&cancel),
401            );
402            let clock = MasterClock::Audio {
403                samples_consumed: Arc::new(AtomicU64::new(0)),
404                sample_rate,
405            };
406            (clock, Some(buf), Some(cancel), Some(handle))
407        } else {
408            log::debug!(
409                "proxy has no audio, using system clock path={}",
410                proxy_path.display()
411            );
412            let clock = MasterClock::System {
413                started_at: Instant::now(),
414                base_pts: Duration::ZERO,
415            };
416            (clock, None, None, None)
417        };
418
419        self.active_path = proxy_path.to_path_buf();
420        self.fps = fps;
421        self.decode_buf = Some(decode_buf);
422        self.clock = clock;
423        self.audio_buf = audio_buf;
424        self.audio_cancel = audio_cancel;
425        self.audio_handle = audio_handle;
426        Ok(())
427    }
428
429    /// Set the A/V offset correction in milliseconds.
430    ///
431    /// - **Positive** value: video is delayed by `ms` ms relative to the audio
432    ///   clock (video PTS is shifted down in the sync comparison).
433    /// - **Negative** value: audio is delayed by `ms` ms relative to video
434    ///   (video PTS is shifted up in the sync comparison).
435    ///
436    /// Values outside ±5 000 ms are clamped and a warning is logged.
437    /// Safe to call from any thread while [`run`](Self::run) is executing.
438    pub fn set_av_offset(&self, ms: i64) {
439        const MAX_OFFSET_MS: i64 = 5_000;
440        let clamped = if ms.abs() > MAX_OFFSET_MS {
441            log::warn!("av_offset clamped value={ms}");
442            ms.clamp(-MAX_OFFSET_MS, MAX_OFFSET_MS)
443        } else {
444            ms
445        };
446        self.av_offset_ms.store(clamped, Ordering::Relaxed);
447    }
448
449    /// Returns the current A/V offset in milliseconds (default: `0`).
450    ///
451    /// Safe to call from any thread while [`run`](Self::run) is executing.
452    pub fn av_offset(&self) -> i64 {
453        self.av_offset_ms.load(Ordering::Relaxed)
454    }
455
456    /// Returns a cloneable handle to the A/V offset atomic.
457    ///
458    /// Writing a value into the returned [`Arc<AtomicI64>`] has the same effect
459    /// as calling [`set_av_offset`](Self::set_av_offset) and is safe to do from
460    /// any thread while [`run`](Self::run) is executing.
461    ///
462    /// Note: the handle stores the raw millisecond value without clamping.
463    /// Values outside ±5 000 ms written directly to the handle will be applied
464    /// as-is by `run()`; prefer [`set_av_offset`](Self::set_av_offset) when
465    /// clamping is desired.
466    ///
467    /// # Example
468    ///
469    /// ```ignore
470    /// let av_handle   = player.av_offset_handle();
471    /// let stop_handle = player.stop_handle();
472    ///
473    /// std::thread::spawn(move || { player.play(); let _ = player.run(); });
474    ///
475    /// // Adjust A/V sync from the UI thread without stopping playback.
476    /// av_handle.store(200, std::sync::atomic::Ordering::Relaxed);
477    /// stop_handle.store(true, std::sync::atomic::Ordering::Release);
478    /// ```
479    pub fn av_offset_handle(&self) -> Arc<AtomicI64> {
480        Arc::clone(&self.av_offset_ms)
481    }
482
483    /// Set the playback rate.
484    ///
485    /// Values ≤ 0.0 are silently ignored — the rate remains unchanged.
486    ///
487    /// The new rate takes effect on the next frame's sleep calculation inside
488    /// [`run`](Self::run). Safe to call from any thread while `run()` is
489    /// executing (same contract as [`set_av_offset`](Self::set_av_offset)).
490    ///
491    /// - `1.0` — real-time (default)
492    /// - `2.0` — twice real-time (sleep halved)
493    /// - `0.5` — half real-time (sleep doubled)
494    pub fn set_rate(&self, rate: f64) {
495        if rate > 0.0 {
496            self.rate_bits.store(rate.to_bits(), Ordering::Relaxed);
497        }
498    }
499
500    /// Returns a cloneable handle to the rate atomic.
501    ///
502    /// Writing `new_rate.to_bits()` into the returned [`Arc<AtomicU64>`] has
503    /// the same effect as calling [`set_rate`](Self::set_rate) and is safe to
504    /// do from any thread while [`run`](Self::run) is executing.
505    ///
506    /// Note: the handle does **not** validate the value; storing bits that
507    /// correspond to `≤ 0.0` or `NaN` will produce undefined sleep behaviour.
508    /// Prefer [`set_rate`](Self::set_rate) when the validation guard is desired.
509    ///
510    /// # Example
511    ///
512    /// ```ignore
513    /// let rate   = player.rate_handle();
514    /// let stop   = player.stop_handle();
515    ///
516    /// std::thread::spawn(move || { player.play(); let _ = player.run(); });
517    ///
518    /// // Double speed from the UI thread without stopping playback.
519    /// rate.store(2.0_f64.to_bits(), std::sync::atomic::Ordering::Relaxed);
520    /// stop.store(true, std::sync::atomic::Ordering::Release);
521    /// ```
522    pub fn rate_handle(&self) -> Arc<AtomicU64> {
523        Arc::clone(&self.rate_bits)
524    }
525
526    /// Returns the PTS of the most recently presented frame.
527    ///
528    /// Returns [`Duration::ZERO`] before the first frame has been presented.
529    /// Safe to call from any thread while [`run`](Self::run) is executing.
530    ///
531    /// # Example
532    ///
533    /// ```ignore
534    /// let pts_handle = Arc::new(Mutex::new(Duration::ZERO));
535    /// let pts_clone  = Arc::clone(&pts_handle);
536    ///
537    /// std::thread::spawn(move || {
538    ///     player.play();
539    ///     let _ = player.run();
540    /// });
541    ///
542    /// // UI thread: poll current position to drive a seek bar.
543    /// loop {
544    ///     let pos = player.current_pts();
545    ///     update_seek_bar(pos);
546    ///     std::thread::sleep(std::time::Duration::from_millis(16));
547    /// }
548    /// ```
549    pub fn current_pts(&self) -> Duration {
550        Duration::from_millis(self.current_pts_millis.load(Ordering::Relaxed))
551    }
552
553    /// Returns the container-reported duration of the media file, if known.
554    ///
555    /// Returns `None` for live or streaming sources where the container does
556    /// not report a duration. Use the returned value to size a seek bar range:
557    ///
558    /// ```ignore
559    /// if let Some(total) = player.duration() {
560    ///     let progress = player.current_pts().as_secs_f64() / total.as_secs_f64();
561    ///     seek_bar.set_fraction(progress);
562    /// }
563    /// ```
564    pub fn duration(&self) -> Option<Duration> {
565        if self.duration_millis == u64::MAX {
566            None
567        } else {
568            Some(Duration::from_millis(self.duration_millis))
569        }
570    }
571
572    /// Pull up to `n_samples` interleaved stereo `f32` PCM samples at 48 kHz.
573    ///
574    /// Intended for use inside an audio output callback:
575    /// ```ignore
576    /// let samples = player.pop_audio_samples(buffer_size);
577    /// output_buffer[..samples.len()].copy_from_slice(&samples);
578    /// // fill remainder with silence when samples.len() < buffer_size (underrun)
579    /// ```
580    ///
581    /// Advances the audio master clock by the number of stereo frames consumed
582    /// (`samples.len() / 2`).
583    ///
584    /// Returns an empty `Vec` when:
585    /// - the file has no audio track,
586    /// - `n_samples` is `0`,
587    /// - playback is paused or stopped, or
588    /// - the ring buffer is empty (underrun — caller should output silence).
589    pub fn pop_audio_samples(&self, n_samples: usize) -> Vec<f32> {
590        if self.paused.load(Ordering::Relaxed) || self.stopped.load(Ordering::Relaxed) {
591            return Vec::new();
592        }
593        let MasterClock::Audio {
594            samples_consumed, ..
595        } = &self.clock
596        else {
597            return Vec::new();
598        };
599        if n_samples == 0 {
600            return Vec::new();
601        }
602        let Some(buf) = &self.audio_buf else {
603            return Vec::new();
604        };
605        let mut guard = buf
606            .lock()
607            .unwrap_or_else(std::sync::PoisonError::into_inner);
608        let take = n_samples.min(guard.len());
609        if take == 0 {
610            return Vec::new();
611        }
612        let samples: Vec<f32> = guard.drain(..take).collect();
613        // Stereo: 2 interleaved samples per frame.
614        // Divide by 2 to get mono-equivalent frame count for the audio clock.
615        samples_consumed.fetch_add((take / 2) as u64, Ordering::Relaxed);
616        samples
617    }
618
619    /// A/V sync presentation loop.
620    ///
621    /// Blocks until [`stop`](Self::stop) is called or the end of file is
622    /// reached. Must be called from the presentation thread.
623    ///
624    /// Video PTS is compared against the master clock:
625    /// - **Early frames** (video PTS > clock + 1 frame period): sleep.
626    /// - **Late frames** (video PTS < clock − 1 frame period): dropped.
627    ///
628    /// For video-only files the `System` clock (`Instant`) drives real-time
629    /// pacing. For files with audio the `Audio` clock drives sync once
630    /// [`pop_audio_samples`](Self::pop_audio_samples) has been called at least
631    /// once; before that, frames are presented immediately.
632    ///
633    /// # Errors
634    ///
635    /// Returns [`PreviewError`] if a frame cannot be presented to the sink.
636    pub fn run(&mut self) -> Result<(), PreviewError> {
637        let fps = self.fps.max(1.0);
638        let frame_period = Duration::from_secs_f64(1.0 / fps);
639
640        // Start the system clock from position 0.
641        // Seek events update base_pts during playback.
642        self.clock.reset(Duration::ZERO);
643
644        loop {
645            if self.stopped.load(Ordering::Acquire) {
646                break;
647            }
648            if self.paused.load(Ordering::Acquire) {
649                thread::sleep(Duration::from_millis(5));
650                continue;
651            }
652
653            // ── Audio-only path ───────────────────────────────────────────────
654            // When there is no video stream, pace via a short sleep and exit
655            // once the audio thread has finished and the ring buffer is empty
656            // (meaning all samples have been consumed by the caller).
657            if self.decode_buf.is_none() {
658                thread::sleep(Duration::from_millis(10));
659                if let Some(audio_buf) = &self.audio_buf {
660                    let empty = audio_buf
661                        .lock()
662                        .unwrap_or_else(std::sync::PoisonError::into_inner)
663                        .is_empty();
664                    if empty
665                        && self
666                            .audio_handle
667                            .as_ref()
668                            .is_none_or(JoinHandle::is_finished)
669                    {
670                        break;
671                    }
672                } else {
673                    // No audio either — nothing to do.
674                    break;
675                }
676                continue;
677            }
678
679            // ── Video decode path ─────────────────────────────────────────────
680            // decode_buf is Some (verified by is_none() check above).
681            let pop_result = if let Some(buf) = self.decode_buf.as_mut() {
682                buf.pop_frame()
683            } else {
684                FrameResult::Eof // unreachable: handled by is_none() above
685            };
686            match pop_result {
687                FrameResult::Eof => break,
688                FrameResult::Seeking(last) => {
689                    if let Some(ref f) = last {
690                        self.present_frame(f);
691                    }
692                    // Non-blocking — loop immediately to check stopped/paused.
693                }
694                FrameResult::Frame(frame) => {
695                    // Drain all pending seek-completion events. Collect pts
696                    // values first so the borrow on decode_buf ends before
697                    // restart_audio_from() takes &mut self.
698                    let seek_pts: Vec<Duration> = match self.decode_buf.as_ref() {
699                        Some(buf) => {
700                            let mut v = Vec::new();
701                            while let Ok(SeekEvent::Completed { pts }) =
702                                buf.seek_events().try_recv()
703                            {
704                                v.push(pts);
705                            }
706                            v
707                        }
708                        None => Vec::new(),
709                    };
710                    for pts in seek_pts {
711                        self.clock.reset(pts);
712                        // Flush stale audio and restart the audio thread from
713                        // the seek position so audio and video stay aligned.
714                        self.restart_audio_from(pts);
715                    }
716
717                    if self.clock.should_sync() {
718                        let video_pts = if frame.timestamp().is_valid() {
719                            frame.timestamp().as_duration()
720                        } else {
721                            Duration::ZERO
722                        };
723
724                        // Apply A/V offset correction.
725                        let offset_ms = self.av_offset_ms.load(Ordering::Relaxed);
726                        let offset = Duration::from_millis(offset_ms.unsigned_abs());
727                        let adjusted_video_pts = if offset_ms >= 0 {
728                            // Positive: video delayed — subtract offset so the
729                            // frame appears "earlier" relative to the clock.
730                            video_pts.saturating_sub(offset)
731                        } else {
732                            // Negative: audio delayed — add offset so the frame
733                            // appears "later" relative to the clock.
734                            video_pts + offset
735                        };
736
737                        let clock_pts = self.clock.current_pts();
738                        let diff = adjusted_video_pts.as_secs_f64() - clock_pts.as_secs_f64();
739                        let fp = frame_period.as_secs_f64();
740
741                        if diff > fp {
742                            // Frame is early — sleep until it aligns with the clock.
743                            // Divide by rate so higher rates shorten the sleep proportionally.
744                            let rate = f64::from_bits(self.rate_bits.load(Ordering::Relaxed));
745                            let sleep_secs =
746                                (diff - fp / 2.0).max(0.0) / rate.max(f64::MIN_POSITIVE);
747                            thread::sleep(Duration::from_secs_f64(sleep_secs));
748                        } else if diff < -fp {
749                            // Frame is more than one period late — drop silently.
750                            log::debug!(
751                                "dropped late frame video_pts={video_pts:?} \
752                                 clock_pts={clock_pts:?}"
753                            );
754                            continue;
755                        }
756                    }
757
758                    self.present_frame(&frame);
759                }
760            }
761        }
762        if let Some(sink) = self.sink.as_mut() {
763            sink.flush();
764        }
765        Ok(())
766    }
767
768    /// Convert `frame` to RGBA and pass it to the registered sink, if any.
769    fn present_frame(&mut self, frame: &ff_format::VideoFrame) {
770        let Some(sink) = self.sink.as_mut() else {
771            return;
772        };
773        let width = frame.width();
774        let height = frame.height();
775        let pts = frame.timestamp().as_duration();
776        // Store PTS so current_pts() can be read from any thread.
777        // Saturate at u64::MAX for videos longer than ~585 million years.
778        self.current_pts_millis.store(
779            u64::try_from(pts.as_millis()).unwrap_or(u64::MAX),
780            Ordering::Relaxed,
781        );
782        if self.sws.convert(frame, &mut self.rgba_buf) {
783            sink.push_frame(&self.rgba_buf, width, height, pts);
784        }
785    }
786
787    /// Flush the audio ring buffer and restart the background audio decode
788    /// thread from `pts`.
789    ///
790    /// Called after a video seek completes so that audio samples stay aligned
791    /// with the video timeline. The old thread's cancel flag is set; it exits
792    /// at its next cancel check and is detached.
793    fn restart_audio_from(&mut self, pts: Duration) {
794        // Flush stale samples so the new thread fills only fresh audio.
795        if let Some(buf) = &self.audio_buf {
796            buf.lock()
797                .unwrap_or_else(std::sync::PoisonError::into_inner)
798                .clear();
799        }
800        // Signal the running audio thread to stop.
801        if let Some(cancel) = &self.audio_cancel {
802            cancel.store(true, Ordering::Release);
803        }
804        // Detach the old handle — the thread exits on its own when cancel fires.
805        drop(self.audio_handle.take());
806        // Spawn a fresh thread that decodes from the seek position.
807        if let Some(buf) = &self.audio_buf {
808            let new_cancel = Arc::new(AtomicBool::new(false));
809            let handle = spawn_audio_thread(
810                self.active_path.clone(),
811                pts,
812                Arc::clone(buf),
813                Arc::clone(&new_cancel),
814            );
815            self.audio_cancel = Some(new_cancel);
816            self.audio_handle = Some(handle);
817        }
818    }
819}
820
821impl Drop for PreviewPlayer {
822    fn drop(&mut self) {
823        // Cancel the audio background thread before dropping so it does not
824        // outlive the player (the Arc<Mutex<VecDeque>> it holds would stay
825        // alive until the thread exits otherwise).
826        if let Some(cancel) = &self.audio_cancel {
827            cancel.store(true, Ordering::Release);
828        }
829        if let Some(h) = self.audio_handle.take() {
830            let _ = h.join();
831        }
832    }
833}
834
835// ── spawn_audio_thread ────────────────────────────────────────────────────────
836
837/// Open an [`AudioDecoder`] configured for stereo f32 at 48 kHz, optionally
838/// seek to `start_pts`, and push decoded samples into `buf` until the cancel
839/// flag is set or EOF is reached.
840///
841/// The buffer is capped at [`AUDIO_MAX_BUF`] samples; the thread sleeps 1 ms
842/// when the buffer is full to avoid busy-waiting.
843fn spawn_audio_thread(
844    path: PathBuf,
845    start_pts: Duration,
846    buf: Arc<Mutex<VecDeque<f32>>>,
847    cancel: Arc<AtomicBool>,
848) -> JoinHandle<()> {
849    thread::spawn(move || {
850        let mut decoder = match AudioDecoder::open(&path)
851            .output_format(SampleFormat::F32)
852            .output_sample_rate(48_000)
853            .output_channels(2)
854            .build()
855        {
856            Ok(d) => d,
857            Err(e) => {
858                log::warn!("audio decode thread open failed error={e}");
859                return;
860            }
861        };
862
863        if start_pts != Duration::ZERO
864            && let Err(e) = decoder.seek(start_pts, SeekMode::Backward)
865        {
866            log::warn!("audio seek failed pts={start_pts:?} error={e}");
867        }
868
869        loop {
870            if cancel.load(Ordering::Acquire) {
871                break;
872            }
873
874            let buf_len = buf
875                .lock()
876                .unwrap_or_else(std::sync::PoisonError::into_inner)
877                .len();
878            if buf_len >= AUDIO_MAX_BUF {
879                thread::sleep(Duration::from_millis(1));
880                continue;
881            }
882
883            match decoder.decode_one() {
884                Ok(Some(frame)) => {
885                    let samples = super::playback_inner::audio_frame_to_f32(&frame);
886                    if !samples.is_empty() {
887                        let mut guard = buf
888                            .lock()
889                            .unwrap_or_else(std::sync::PoisonError::into_inner);
890                        let space = AUDIO_MAX_BUF.saturating_sub(guard.len());
891                        guard.extend(samples.into_iter().take(space));
892                    }
893                }
894                Ok(None) => break, // EOF
895                Err(e) => {
896                    log::warn!("audio decode error error={e}");
897                    break;
898                }
899            }
900        }
901    })
902}
903
904// ── Tests ─────────────────────────────────────────────────────────────────────
905
906#[cfg(test)]
907mod tests {
908    use super::*;
909    use std::path::Path;
910
911    fn test_video_path() -> std::path::PathBuf {
912        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/video/gameplay.mp4")
913    }
914
915    fn test_audio_path() -> std::path::PathBuf {
916        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
917            .join("../../assets/audio/konekonoosanpo.mp3")
918    }
919
920    // ── PreviewPlayer tests ───────────────────────────────────────────────────
921
922    #[test]
923    fn preview_player_open_should_fail_for_nonexistent_file() {
924        let result = PreviewPlayer::open(Path::new("nonexistent_preview.mp4"));
925        assert!(
926            result.is_err(),
927            "open() must return Err for a non-existent file"
928        );
929    }
930
931    #[test]
932    fn preview_player_play_pause_stop_should_update_state() {
933        let path = test_video_path();
934        let mut player = match PreviewPlayer::open(&path) {
935            Ok(p) => p,
936            Err(e) => {
937                println!("skipping: video file not available: {e}");
938                return;
939            }
940        };
941
942        // Initial state: not paused, not stopped.
943        assert!(!player.paused.load(Ordering::Relaxed));
944        assert!(!player.stopped.load(Ordering::Relaxed));
945
946        player.pause();
947        assert!(player.paused.load(Ordering::Relaxed));
948
949        player.play();
950        assert!(!player.paused.load(Ordering::Relaxed));
951        assert!(!player.stopped.load(Ordering::Relaxed));
952
953        player.stop();
954        assert!(player.stopped.load(Ordering::Relaxed));
955    }
956
957    #[test]
958    fn preview_player_run_should_deliver_frames_to_sink() {
959        use std::sync::{Arc, Mutex};
960
961        struct CountingSink(Arc<Mutex<usize>>);
962        impl FrameSink for CountingSink {
963            fn push_frame(&mut self, _rgba: &[u8], _width: u32, _height: u32, _pts: Duration) {
964                *self
965                    .0
966                    .lock()
967                    .unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
968            }
969        }
970
971        let path = test_video_path();
972        let mut player = match PreviewPlayer::open(&path) {
973            Ok(p) => p,
974            Err(e) => {
975                println!("skipping: video file not available: {e}");
976                return;
977            }
978        };
979
980        let count = Arc::new(Mutex::new(0usize));
981        player.set_sink(Box::new(CountingSink(Arc::clone(&count))));
982        player.play();
983
984        // run() blocks until EOF; short test file finishes quickly.
985        match player.run() {
986            Ok(()) => {}
987            Err(e) => {
988                println!("skipping: run() error: {e}");
989                return;
990            }
991        }
992
993        let frames = *count
994            .lock()
995            .unwrap_or_else(std::sync::PoisonError::into_inner);
996        assert!(
997            frames > 0,
998            "run() must deliver at least one frame to the sink"
999        );
1000    }
1001
1002    // ── pop_audio_samples tests ───────────────────────────────────────────────
1003
1004    #[test]
1005    fn pop_audio_samples_should_return_empty_when_paused() {
1006        let path = test_video_path();
1007        let player = match PreviewPlayer::open(&path) {
1008            Ok(p) => p,
1009            Err(e) => {
1010                println!("skipping: video file not available: {e}");
1011                return;
1012            }
1013        };
1014        player.pause();
1015        let samples = player.pop_audio_samples(1024);
1016        assert!(
1017            samples.is_empty(),
1018            "pop_audio_samples() must return empty while paused"
1019        );
1020    }
1021
1022    #[test]
1023    fn pop_audio_samples_should_return_empty_when_stopped() {
1024        let path = test_video_path();
1025        let mut player = match PreviewPlayer::open(&path) {
1026            Ok(p) => p,
1027            Err(e) => {
1028                println!("skipping: video file not available: {e}");
1029                return;
1030            }
1031        };
1032        player.stop();
1033        let samples = player.pop_audio_samples(1024);
1034        assert!(
1035            samples.is_empty(),
1036            "pop_audio_samples() must return empty while stopped"
1037        );
1038    }
1039
1040    #[test]
1041    fn pop_audio_samples_should_return_empty_for_zero_n_samples() {
1042        let path = test_video_path();
1043        let player = match PreviewPlayer::open(&path) {
1044            Ok(p) => p,
1045            Err(e) => {
1046                println!("skipping: video file not available: {e}");
1047                return;
1048            }
1049        };
1050        player.play();
1051        let samples = player.pop_audio_samples(0);
1052        assert!(
1053            samples.is_empty(),
1054            "pop_audio_samples(0) must always return empty"
1055        );
1056    }
1057
1058    #[test]
1059    fn pause_handle_should_control_paused_flag_from_shared_reference() {
1060        let path = test_video_path();
1061        let player = match PreviewPlayer::open(&path) {
1062            Ok(p) => p,
1063            Err(e) => {
1064                println!("skipping: video file not available: {e}");
1065                return;
1066            }
1067        };
1068        let handle = player.pause_handle();
1069
1070        handle.store(true, Ordering::Release);
1071        assert!(
1072            player.paused.load(Ordering::Acquire),
1073            "handle must set paused flag"
1074        );
1075
1076        handle.store(false, Ordering::Release);
1077        assert!(
1078            !player.paused.load(Ordering::Acquire),
1079            "handle must clear paused flag"
1080        );
1081
1082        // Arc clone proves the thread-sharing pattern compiles.
1083        let cloned = Arc::clone(&handle);
1084        cloned.store(true, Ordering::Release);
1085        assert!(
1086            player.paused.load(Ordering::Acquire),
1087            "cloned handle must set paused flag"
1088        );
1089    }
1090
1091    #[test]
1092    fn play_and_pause_should_be_callable_via_shared_reference() {
1093        // No `mut` binding — only possible with &self receivers.
1094        let path = test_video_path();
1095        let player = match PreviewPlayer::open(&path) {
1096            Ok(p) => p,
1097            Err(e) => {
1098                println!("skipping: video file not available: {e}");
1099                return;
1100            }
1101        };
1102        player.pause();
1103        assert!(
1104            player.paused.load(Ordering::Relaxed),
1105            "pause() via &self must set paused flag"
1106        );
1107        player.play();
1108        assert!(
1109            !player.paused.load(Ordering::Relaxed),
1110            "play() via &self must clear paused flag"
1111        );
1112    }
1113
1114    #[test]
1115    fn pop_audio_samples_should_be_callable_via_shared_reference() {
1116        // With &self receiver: works through an immutable binding and Arc<T>.
1117        // This is the compile-time proof that enables cpal-callback usage.
1118        let path = test_video_path();
1119        let player = match PreviewPlayer::open(&path) {
1120            Ok(p) => p,
1121            Err(e) => {
1122                println!("skipping: video file not available: {e}");
1123                return;
1124            }
1125        };
1126        // No `mut` binding — only possible with a &self receiver.
1127        let samples = player.pop_audio_samples(0);
1128        assert!(samples.is_empty(), "pop_audio_samples(0) must return empty");
1129
1130        // Via Arc — the canonical pattern for sharing with an audio callback.
1131        let shared = std::sync::Arc::new(player);
1132        let _samples = shared.pop_audio_samples(0);
1133    }
1134
1135    #[test]
1136    fn pop_audio_samples_clock_increment_should_equal_half_sample_count() {
1137        // Verify the stereo-frame → clock-tick formula: n_samples / 2.
1138        // 9600 stereo samples at 48 kHz stereo = 4800 frames = 100 ms.
1139        let stereo_samples: usize = 9_600;
1140        let expected_frames: u64 = (stereo_samples / 2) as u64;
1141        assert_eq!(
1142            expected_frames, 4_800,
1143            "9600 stereo samples must yield 4800 clock frames"
1144        );
1145        // At 48 kHz, 4800 frames = 0.1 s.
1146        let pts = Duration::from_secs_f64(f64::from(48_000u32).recip() * expected_frames as f64);
1147        assert!(
1148            (pts.as_secs_f64() - 0.1).abs() < 1e-6,
1149            "4800 frames at 48 kHz must equal 100 ms; got {pts:?}"
1150        );
1151    }
1152
1153    // ── current_pts / duration tests ─────────────────────────────────────────
1154
1155    #[test]
1156    fn current_pts_should_return_zero_before_first_frame() {
1157        let path = test_video_path();
1158        let player = match PreviewPlayer::open(&path) {
1159            Ok(p) => p,
1160            Err(e) => {
1161                println!("skipping: video file not available: {e}");
1162                return;
1163            }
1164        };
1165        assert_eq!(
1166            player.current_pts(),
1167            Duration::ZERO,
1168            "current_pts() must be ZERO before any frame is presented"
1169        );
1170    }
1171
1172    #[test]
1173    fn duration_should_return_some_for_file_with_known_duration() {
1174        let path = test_video_path();
1175        let player = match PreviewPlayer::open(&path) {
1176            Ok(p) => p,
1177            Err(e) => {
1178                println!("skipping: video file not available: {e}");
1179                return;
1180            }
1181        };
1182        assert!(
1183            player.duration().is_some(),
1184            "duration() must return Some for a file with a known container duration"
1185        );
1186        let d = player.duration().unwrap();
1187        assert!(
1188            d > Duration::ZERO,
1189            "duration() must be positive for a valid media file; got {d:?}"
1190        );
1191    }
1192
1193    #[test]
1194    fn duration_should_return_none_when_duration_millis_is_sentinel() {
1195        // Verify the sentinel logic: u64::MAX → None.
1196        // We cannot easily get a live stream in a unit test, so we test the
1197        // conversion formula directly.
1198        let sentinel = u64::MAX;
1199        let result: Option<Duration> = if sentinel == u64::MAX {
1200            None
1201        } else {
1202            Some(Duration::from_millis(sentinel))
1203        };
1204        assert!(result.is_none(), "sentinel u64::MAX must map to None");
1205
1206        // A valid value maps to Some.
1207        let valid = 5_000u64; // 5 seconds
1208        let result: Option<Duration> = if valid == u64::MAX {
1209            None
1210        } else {
1211            Some(Duration::from_millis(valid))
1212        };
1213        assert_eq!(result, Some(Duration::from_secs(5)));
1214    }
1215
1216    #[test]
1217    fn current_pts_should_advance_after_frames_are_presented() {
1218        use std::sync::{Arc, Mutex};
1219
1220        struct PtsSink(Arc<Mutex<Option<Duration>>>);
1221        impl FrameSink for PtsSink {
1222            fn push_frame(&mut self, _rgba: &[u8], _width: u32, _height: u32, pts: Duration) {
1223                let mut g = self
1224                    .0
1225                    .lock()
1226                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1227                *g = Some(pts);
1228            }
1229        }
1230
1231        let path = test_video_path();
1232        let mut player = match PreviewPlayer::open(&path) {
1233            Ok(p) => p,
1234            Err(e) => {
1235                println!("skipping: video file not available: {e}");
1236                return;
1237            }
1238        };
1239
1240        let last_pts = Arc::new(Mutex::new(None::<Duration>));
1241        player.set_sink(Box::new(PtsSink(Arc::clone(&last_pts))));
1242        player.play();
1243        let _ = player.run();
1244
1245        // After run() returns, current_pts() must be within 1 ms of the PTS
1246        // delivered to the sink. current_pts() stores millisecond precision, so
1247        // sub-millisecond differences between the stored and sink values are expected.
1248        let sink_pts = last_pts
1249            .lock()
1250            .unwrap_or_else(std::sync::PoisonError::into_inner)
1251            .unwrap_or(Duration::ZERO);
1252        let player_pts = player.current_pts();
1253        let diff = if player_pts >= sink_pts {
1254            player_pts - sink_pts
1255        } else {
1256            sink_pts - player_pts
1257        };
1258        assert!(
1259            diff <= Duration::from_millis(1),
1260            "current_pts() must be within 1 ms of the last sink PTS; \
1261             player_pts={player_pts:?} sink_pts={sink_pts:?} diff={diff:?}"
1262        );
1263    }
1264
1265    // ── seek_coarse tests ─────────────────────────────────────────────────────
1266
1267    #[test]
1268    fn seek_coarse_should_delegate_to_decode_buffer() {
1269        let path = test_video_path();
1270        let mut player = match PreviewPlayer::open(&path) {
1271            Ok(p) => p,
1272            Err(e) => {
1273                println!("skipping: video file not available: {e}");
1274                return;
1275            }
1276        };
1277        // Consume a few frames so the decoder has advanced past the start.
1278        for _ in 0..3 {
1279            if matches!(player.pop_frame(), FrameResult::Eof) {
1280                println!("skipping: EOF before seek target");
1281                return;
1282            }
1283        }
1284        let target = Duration::from_secs(1);
1285        match player.seek_coarse(target) {
1286            Ok(()) => {}
1287            Err(e) => {
1288                println!("skipping: seek_coarse not supported or failed: {e}");
1289                return;
1290            }
1291        }
1292        // After a coarse seek the next frame must be available (not EOF).
1293        match player.pop_frame() {
1294            FrameResult::Frame(_) | FrameResult::Seeking(_) => {}
1295            FrameResult::Eof => panic!("pop_frame() returned Eof immediately after seek_coarse"),
1296        }
1297    }
1298
1299    #[test]
1300    fn seek_coarse_should_be_faster_than_seek_for_same_target() {
1301        // Structural test: both methods must return Ok for the same target.
1302        // Timing comparison is environment-dependent and marked #[ignore].
1303        let path = test_video_path();
1304        let mut player_exact = match PreviewPlayer::open(&path) {
1305            Ok(p) => p,
1306            Err(e) => {
1307                println!("skipping: video file not available: {e}");
1308                return;
1309            }
1310        };
1311        let mut player_coarse = match PreviewPlayer::open(&path) {
1312            Ok(p) => p,
1313            Err(e) => {
1314                println!("skipping: video file not available: {e}");
1315                return;
1316            }
1317        };
1318
1319        let target = Duration::from_secs(1);
1320        let exact_ok = player_exact.seek(target).is_ok();
1321        let coarse_ok = player_coarse.seek_coarse(target).is_ok();
1322
1323        // Both must either succeed or fail (seek support depends on the codec).
1324        assert_eq!(
1325            exact_ok, coarse_ok,
1326            "seek() and seek_coarse() must both succeed or both fail for the same file"
1327        );
1328    }
1329
1330    // ── av_offset_handle tests ────────────────────────────────────────────────
1331
1332    #[test]
1333    fn av_offset_handle_should_control_offset_from_shared_reference() {
1334        let path = test_video_path();
1335        let player = match PreviewPlayer::open(&path) {
1336            Ok(p) => p,
1337            Err(e) => {
1338                println!("skipping: video file not available: {e}");
1339                return;
1340            }
1341        };
1342        let handle = player.av_offset_handle();
1343
1344        handle.store(300, Ordering::Relaxed);
1345        assert_eq!(
1346            player.av_offset(),
1347            300,
1348            "handle must update av_offset visible through av_offset()"
1349        );
1350
1351        handle.store(-150, Ordering::Relaxed);
1352        assert_eq!(player.av_offset(), -150);
1353
1354        // Arc clone proves the thread-sharing pattern compiles.
1355        let cloned = Arc::clone(&handle);
1356        cloned.store(500, Ordering::Relaxed);
1357        assert_eq!(
1358            player.av_offset(),
1359            500,
1360            "cloned handle must update av_offset"
1361        );
1362    }
1363
1364    #[test]
1365    fn av_offset_handle_should_have_same_signature_as_stop_handle() {
1366        // Structural test: both methods must be callable on an immutable binding.
1367        // No `mut` — proves both return &self handles.
1368        let path = test_video_path();
1369        let player = match PreviewPlayer::open(&path) {
1370            Ok(p) => p,
1371            Err(e) => {
1372                println!("skipping: video file not available: {e}");
1373                return;
1374            }
1375        };
1376        let _av: Arc<AtomicI64> = player.av_offset_handle();
1377        let _stop: Arc<AtomicBool> = player.stop_handle();
1378    }
1379
1380    // ── A/V offset tests ──────────────────────────────────────────────────────
1381
1382    #[test]
1383    fn av_offset_default_should_be_zero() {
1384        use std::sync::atomic::{AtomicI64, Ordering};
1385        // AtomicI64 default matches the expected API default of 0 ms.
1386        let offset = AtomicI64::new(0);
1387        assert_eq!(offset.load(Ordering::Relaxed), 0);
1388    }
1389
1390    #[test]
1391    fn set_av_offset_should_clamp_large_positive_value() {
1392        let path = test_video_path();
1393        let player = match PreviewPlayer::open(&path) {
1394            Ok(p) => p,
1395            Err(e) => {
1396                println!("skipping: video file not available: {e}");
1397                return;
1398            }
1399        };
1400        player.set_av_offset(10_000);
1401        assert_eq!(player.av_offset(), 5_000, "offset must be clamped to +5000");
1402    }
1403
1404    #[test]
1405    fn set_av_offset_should_clamp_large_negative_value() {
1406        let path = test_video_path();
1407        let player = match PreviewPlayer::open(&path) {
1408            Ok(p) => p,
1409            Err(e) => {
1410                println!("skipping: video file not available: {e}");
1411                return;
1412            }
1413        };
1414        player.set_av_offset(-10_000);
1415        assert_eq!(
1416            player.av_offset(),
1417            -5_000,
1418            "offset must be clamped to -5000"
1419        );
1420    }
1421
1422    #[test]
1423    fn positive_av_offset_should_reduce_adjusted_video_pts() {
1424        // Simulate the offset adjustment: positive offset subtracts from video_pts.
1425        let video_pts = Duration::from_millis(1_000);
1426        let offset_ms: i64 = 200;
1427        let adjusted = if offset_ms >= 0 {
1428            let offset = Duration::from_millis(offset_ms as u64);
1429            video_pts.saturating_sub(offset)
1430        } else {
1431            let offset = Duration::from_millis(offset_ms.unsigned_abs());
1432            video_pts + offset
1433        };
1434        assert_eq!(
1435            adjusted,
1436            Duration::from_millis(800),
1437            "positive offset must reduce adjusted_video_pts by offset amount"
1438        );
1439    }
1440
1441    #[test]
1442    fn negative_av_offset_should_increase_adjusted_video_pts() {
1443        let video_pts = Duration::from_millis(1_000);
1444        let offset_ms: i64 = -200;
1445        let adjusted = if offset_ms >= 0 {
1446            let offset = Duration::from_millis(offset_ms as u64);
1447            video_pts.saturating_sub(offset)
1448        } else {
1449            let offset = Duration::from_millis(offset_ms.unsigned_abs());
1450            video_pts + offset
1451        };
1452        assert_eq!(
1453            adjusted,
1454            Duration::from_millis(1_200),
1455            "negative offset must increase adjusted_video_pts by offset amount"
1456        );
1457    }
1458
1459    #[test]
1460    fn positive_av_offset_at_zero_pts_should_saturate_to_zero() {
1461        let video_pts = Duration::ZERO;
1462        let offset_ms: i64 = 100;
1463        let adjusted = video_pts.saturating_sub(Duration::from_millis(offset_ms as u64));
1464        assert_eq!(
1465            adjusted,
1466            Duration::ZERO,
1467            "saturating_sub on zero pts must clamp to zero not underflow"
1468        );
1469    }
1470
1471    // ── use_proxy_if_available / active_source tests ──────────────────────────
1472
1473    #[test]
1474    fn use_proxy_if_available_should_return_false_when_no_proxy_in_dir() {
1475        let path = test_video_path();
1476        let mut player = match PreviewPlayer::open(&path) {
1477            Ok(p) => p,
1478            Err(e) => {
1479                println!("skipping: video file not available: {e}");
1480                return;
1481            }
1482        };
1483        let tmp = std::env::temp_dir().join("ff_preview_no_proxy_dir_test");
1484        let _ = std::fs::create_dir_all(&tmp);
1485        let found = player.use_proxy_if_available(&tmp);
1486        assert!(
1487            !found,
1488            "must return false when no proxy files exist in the directory"
1489        );
1490    }
1491
1492    #[test]
1493    fn use_proxy_if_available_should_return_false_after_play() {
1494        let path = test_video_path();
1495        let mut player = match PreviewPlayer::open(&path) {
1496            Ok(p) => p,
1497            Err(e) => {
1498                println!("skipping: video file not available: {e}");
1499                return;
1500            }
1501        };
1502        player.play();
1503        let found = player.use_proxy_if_available(Path::new("."));
1504        assert!(!found, "must return false when called after play()");
1505    }
1506
1507    #[test]
1508    fn active_source_should_return_original_path_before_proxy_activation() {
1509        let path = test_video_path();
1510        let player = match PreviewPlayer::open(&path) {
1511            Ok(p) => p,
1512            Err(e) => {
1513                println!("skipping: video file not available: {e}");
1514                return;
1515            }
1516        };
1517        assert_eq!(
1518            player.active_source(),
1519            path.as_path(),
1520            "active_source() must equal the original path before any proxy activation"
1521        );
1522    }
1523
1524    // ── set_rate / rate_handle tests ──────────────────────────────────────────
1525
1526    #[test]
1527    fn set_rate_should_update_rate_bits() {
1528        let path = test_video_path();
1529        let player = match PreviewPlayer::open(&path) {
1530            Ok(p) => p,
1531            Err(e) => {
1532                println!("skipping: video file not available: {e}");
1533                return;
1534            }
1535        };
1536        // Default rate is 1.0.
1537        let default_rate = f64::from_bits(player.rate_bits.load(Ordering::Relaxed));
1538        assert!(
1539            (default_rate - 1.0).abs() < f64::EPSILON,
1540            "default rate must be 1.0; got {default_rate}"
1541        );
1542
1543        player.set_rate(2.0);
1544        let rate = f64::from_bits(player.rate_bits.load(Ordering::Relaxed));
1545        assert!(
1546            (rate - 2.0).abs() < f64::EPSILON,
1547            "set_rate(2.0) must store 2.0; got {rate}"
1548        );
1549
1550        player.set_rate(0.5);
1551        let rate = f64::from_bits(player.rate_bits.load(Ordering::Relaxed));
1552        assert!(
1553            (rate - 0.5).abs() < f64::EPSILON,
1554            "set_rate(0.5) must store 0.5; got {rate}"
1555        );
1556    }
1557
1558    #[test]
1559    fn set_rate_should_ignore_non_positive_values() {
1560        let path = test_video_path();
1561        let player = match PreviewPlayer::open(&path) {
1562            Ok(p) => p,
1563            Err(e) => {
1564                println!("skipping: video file not available: {e}");
1565                return;
1566            }
1567        };
1568        player.set_rate(2.0);
1569
1570        // 0.0 must be a no-op.
1571        player.set_rate(0.0);
1572        let rate = f64::from_bits(player.rate_bits.load(Ordering::Relaxed));
1573        assert!(
1574            (rate - 2.0).abs() < f64::EPSILON,
1575            "set_rate(0.0) must be a no-op; rate must remain 2.0, got {rate}"
1576        );
1577
1578        // Negative must also be a no-op.
1579        player.set_rate(-1.0);
1580        let rate = f64::from_bits(player.rate_bits.load(Ordering::Relaxed));
1581        assert!(
1582            (rate - 2.0).abs() < f64::EPSILON,
1583            "set_rate(-1.0) must be a no-op; rate must remain 2.0, got {rate}"
1584        );
1585    }
1586
1587    #[test]
1588    fn rate_handle_should_return_shared_reference_to_rate_bits() {
1589        let path = test_video_path();
1590        let player = match PreviewPlayer::open(&path) {
1591            Ok(p) => p,
1592            Err(e) => {
1593                println!("skipping: video file not available: {e}");
1594                return;
1595            }
1596        };
1597        let handle = player.rate_handle();
1598
1599        handle.store(3.0_f64.to_bits(), Ordering::Relaxed);
1600        let rate = f64::from_bits(player.rate_bits.load(Ordering::Relaxed));
1601        assert!(
1602            (rate - 3.0).abs() < f64::EPSILON,
1603            "rate_handle() write must be visible through rate_bits; got {rate}"
1604        );
1605
1606        // Arc clone proves the thread-sharing pattern compiles.
1607        let cloned = Arc::clone(&handle);
1608        cloned.store(0.25_f64.to_bits(), Ordering::Relaxed);
1609        let rate = f64::from_bits(player.rate_bits.load(Ordering::Relaxed));
1610        assert!(
1611            (rate - 0.25).abs() < f64::EPSILON,
1612            "cloned rate_handle write must be visible; got {rate}"
1613        );
1614    }
1615
1616    #[test]
1617    fn set_rate_should_be_callable_via_shared_reference() {
1618        // No `mut` binding — proves &self receiver.
1619        let path = test_video_path();
1620        let player = match PreviewPlayer::open(&path) {
1621            Ok(p) => p,
1622            Err(e) => {
1623                println!("skipping: video file not available: {e}");
1624                return;
1625            }
1626        };
1627        player.set_rate(2.0);
1628        let rate_handle: Arc<AtomicU64> = player.rate_handle();
1629        let _ = rate_handle;
1630    }
1631
1632    // ── audio-only tests ──────────────────────────────────────────────────────
1633
1634    #[test]
1635    fn audio_only_open_should_succeed() {
1636        let path = test_audio_path();
1637        match PreviewPlayer::open(&path) {
1638            Ok(player) => {
1639                // Opened successfully; verify the player has no video decode buffer.
1640                assert!(
1641                    player.decode_buf.is_none(),
1642                    "audio-only player must have no video decode buffer"
1643                );
1644                // Audio buffer should be present.
1645                assert!(
1646                    player.audio_buf.is_some(),
1647                    "audio-only player must have an audio ring buffer"
1648                );
1649            }
1650            Err(e) => {
1651                println!("skipping: audio file not available: {e}");
1652            }
1653        }
1654    }
1655
1656    #[test]
1657    fn audio_only_pop_frame_should_return_eof() {
1658        let path = test_audio_path();
1659        let mut player = match PreviewPlayer::open(&path) {
1660            Ok(p) => p,
1661            Err(e) => {
1662                println!("skipping: audio file not available: {e}");
1663                return;
1664            }
1665        };
1666        // For an audio-only player, pop_frame() must return Eof immediately.
1667        assert!(
1668            matches!(player.pop_frame(), FrameResult::Eof),
1669            "pop_frame() on an audio-only player must return Eof"
1670        );
1671    }
1672
1673    #[test]
1674    fn audio_only_run_should_return_ok_without_video_frames() {
1675        let path = test_audio_path();
1676        let mut player = match PreviewPlayer::open(&path) {
1677            Ok(p) => p,
1678            Err(e) => {
1679                println!("skipping: audio file not available: {e}");
1680                return;
1681            }
1682        };
1683
1684        // Count frames delivered to the sink — must remain zero for audio-only.
1685        struct CountingSink(usize);
1686        impl FrameSink for CountingSink {
1687            fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1688                self.0 += 1;
1689            }
1690        }
1691        player.set_sink(Box::new(CountingSink(0)));
1692
1693        // Stop after 150 ms so the test doesn't block for the full audio duration.
1694        let stop = player.stop_handle();
1695        let _ = thread::spawn(move || {
1696            thread::sleep(Duration::from_millis(150));
1697            stop.store(true, Ordering::Release);
1698        });
1699
1700        player.play();
1701        let result = player.run();
1702        assert!(
1703            result.is_ok(),
1704            "run() on an audio-only player must return Ok; got {result:?}"
1705        );
1706        // The sink should have received zero video frames.
1707        if let Some(sink) = player.sink.as_ref() {
1708            // Access the frame count via downcast is not available here, so we
1709            // verify indirectly: current_pts() stays at zero (no frames presented).
1710            let _ = sink;
1711        }
1712        assert_eq!(
1713            player.current_pts(),
1714            Duration::ZERO,
1715            "current_pts() must remain ZERO for audio-only playback (no video frames)"
1716        );
1717    }
1718
1719    #[test]
1720    fn audio_only_seek_should_return_ok() {
1721        let path = test_audio_path();
1722        let mut player = match PreviewPlayer::open(&path) {
1723            Ok(p) => p,
1724            Err(e) => {
1725                println!("skipping: audio file not available: {e}");
1726                return;
1727            }
1728        };
1729        // seek() and seek_coarse() must return Ok(()) for audio-only files.
1730        assert!(
1731            player.seek(Duration::from_secs(1)).is_ok(),
1732            "seek() on audio-only player must return Ok"
1733        );
1734        assert!(
1735            player.seek_coarse(Duration::from_secs(1)).is_ok(),
1736            "seek_coarse() on audio-only player must return Ok"
1737        );
1738    }
1739}