Skip to main content

ff_preview/playback/
player.rs

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