1use 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
35const AUDIO_MAX_BUF: usize = 96_000;
38const CHANNEL_CAP: usize = 64;
39const AUDIO_STALL_FRAMES: u32 = 5;
44const DECODED_SAMPLE_RATE: u32 = 48_000;
52
53pub enum PlayerCommand {
58 Play,
60 Pause,
62 Stop,
65 Seek(Duration),
68 SetRate(f64),
70 SetAvOffset(i64),
72}
73
74#[derive(Clone)]
86pub struct PlayerHandle {
87 cmd_tx: mpsc::SyncSender<PlayerCommand>,
88 event_rx: Arc<Mutex<mpsc::Receiver<PlayerEvent>>>,
89 current_pts: Arc<AtomicU64>,
91 audio_buf: Option<Arc<Mutex<VecDeque<f32>>>>,
92 samples_consumed: Option<Arc<AtomicU64>>,
94 paused: Arc<AtomicBool>,
96 stopped: Arc<AtomicBool>,
98 duration_millis: u64,
99 audio_mixer: Option<Arc<Mutex<AudioMixer>>>,
101}
102
103impl PlayerHandle {
104 pub fn play(&self) {
106 self.stopped.store(false, Ordering::Release);
107 self.paused.store(false, Ordering::Release);
108 let _ = self.cmd_tx.try_send(PlayerCommand::Play);
109 }
110
111 pub fn pause(&self) {
113 self.paused.store(true, Ordering::Release);
114 let _ = self.cmd_tx.try_send(PlayerCommand::Pause);
115 }
116
117 pub fn stop(&self) {
119 self.stopped.store(true, Ordering::Release);
120 let _ = self.cmd_tx.try_send(PlayerCommand::Stop);
121 }
122
123 pub fn seek(&self, pts: Duration) {
128 let _ = self.cmd_tx.try_send(PlayerCommand::Seek(pts));
129 }
130
131 pub fn set_rate(&self, rate: f64) {
135 let _ = self.cmd_tx.try_send(PlayerCommand::SetRate(rate));
136 }
137
138 pub fn set_av_offset(&self, ms: i64) {
143 let _ = self.cmd_tx.try_send(PlayerCommand::SetAvOffset(ms));
144 }
145
146 #[must_use]
150 pub fn current_pts(&self) -> Duration {
151 Duration::from_micros(self.current_pts.load(Ordering::Relaxed))
152 }
153
154 #[must_use]
156 pub fn duration(&self) -> Option<Duration> {
157 if self.duration_millis == u64::MAX {
158 None
159 } else {
160 Some(Duration::from_millis(self.duration_millis))
161 }
162 }
163
164 #[must_use]
181 pub fn audio_sample_rate(&self) -> Option<u32> {
182 self.audio_buf.as_ref().map(|_| DECODED_SAMPLE_RATE)
183 }
184
185 #[allow(clippy::cast_precision_loss)]
195 pub fn pop_audio_samples(&self, n: usize) -> Vec<f32> {
196 if self.paused.load(Ordering::Relaxed) || self.stopped.load(Ordering::Relaxed) {
197 return Vec::new();
198 }
199 if n == 0 {
200 return Vec::new();
201 }
202 if let Some(mixer) = &self.audio_mixer {
205 return mixer
206 .lock()
207 .unwrap_or_else(std::sync::PoisonError::into_inner)
208 .mix(n);
209 }
210 let Some(buf) = &self.audio_buf else {
212 return Vec::new();
213 };
214 let mut guard = buf
215 .lock()
216 .unwrap_or_else(std::sync::PoisonError::into_inner);
217 let take = n.min(guard.len());
218 if take == 0 {
219 return Vec::new();
220 }
221 let samples: Vec<f32> = guard.drain(..take).collect();
222 if let Some(sc) = &self.samples_consumed {
223 sc.fetch_add((take / 2) as u64, Ordering::Relaxed);
224 }
225 samples
226 }
227
228 #[must_use]
232 pub fn poll_event(&self) -> Option<PlayerEvent> {
233 self.event_rx.lock().ok()?.try_recv().ok()
234 }
235
236 #[must_use]
241 pub fn recv_event(&self) -> Option<PlayerEvent> {
242 self.event_rx.lock().ok()?.recv().ok()
243 }
244
245 #[cfg(feature = "timeline")]
250 pub(crate) fn for_timeline(
251 cmd_tx: mpsc::SyncSender<PlayerCommand>,
252 event_rx: Arc<Mutex<mpsc::Receiver<PlayerEvent>>>,
253 current_pts: Arc<AtomicU64>,
254 paused: Arc<AtomicBool>,
255 stopped: Arc<AtomicBool>,
256 duration_millis: u64,
257 audio_mixer: Option<Arc<Mutex<AudioMixer>>>,
258 ) -> Self {
259 Self {
260 cmd_tx,
261 event_rx,
262 current_pts,
263 audio_buf: None,
264 samples_consumed: None,
265 audio_mixer,
266 paused,
267 stopped,
268 duration_millis,
269 }
270 }
271}
272
273pub struct PlayerRunner {
282 path: PathBuf,
283 cmd_rx: mpsc::Receiver<PlayerCommand>,
284 event_tx: mpsc::SyncSender<PlayerEvent>,
285 decode_buf: Option<DecodeBuffer>,
286 fps: f64,
287 sink: Option<Box<dyn FrameSink>>,
288 clock: MasterClock,
289 audio_buf: Option<Arc<Mutex<VecDeque<f32>>>>,
290 audio_cancel: Option<Arc<AtomicBool>>,
291 audio_handle: Option<JoinHandle<()>>,
292 sws: super::playback_inner::SwsRgbaConverter,
293 rgba_buf: Vec<u8>,
294 active_path: PathBuf,
295 current_pts: Arc<AtomicU64>,
296 paused: Arc<AtomicBool>,
297 stopped: Arc<AtomicBool>,
298 av_offset_ms: i64,
299 rate: f64,
300 duration_millis: u64,
301 frame_cache: Option<FrameCache>,
302 hw_accel: HardwareAccel,
303}
304
305impl PlayerRunner {
306 pub fn set_sink(&mut self, sink: Box<dyn FrameSink>) {
308 self.sink = Some(sink);
309 }
310
311 pub fn set_hardware_accel(&mut self, accel: HardwareAccel) -> &mut Self {
317 self.hw_accel = accel;
318 self
319 }
320
321 #[must_use]
323 pub fn active_source(&self) -> &Path {
324 &self.active_path
325 }
326
327 #[must_use]
336 pub fn with_frame_cache_budget(mut self, bytes: usize) -> Self {
337 self.frame_cache = Some(FrameCache::new(bytes));
338 self
339 }
340
341 #[must_use]
343 pub fn duration(&self) -> Option<Duration> {
344 if self.duration_millis == u64::MAX {
345 None
346 } else {
347 Some(Duration::from_millis(self.duration_millis))
348 }
349 }
350
351 pub fn use_proxy_if_available(&mut self, proxy_dir: &Path) -> bool {
358 let stem = self
359 .path
360 .file_stem()
361 .and_then(|s| s.to_str())
362 .unwrap_or("output")
363 .to_owned();
364
365 for suffix in ["half", "quarter", "eighth"] {
366 let candidate = proxy_dir.join(format!("{stem}_proxy_{suffix}.mp4"));
367 if candidate.exists() {
368 match self.activate_proxy(&candidate) {
369 Ok(()) => {
370 log::debug!("proxy activated path={}", candidate.display());
371 return true;
372 }
373 Err(e) => {
374 log::warn!(
375 "proxy activation failed path={} error={e}",
376 candidate.display()
377 );
378 }
379 }
380 }
381 }
382 false
383 }
384
385 #[allow(clippy::too_many_lines)]
403 pub fn run(mut self) -> Result<(), PreviewError> {
404 let fps = self.fps.max(1.0);
405 let frame_period = Duration::from_secs_f64(1.0 / fps);
406
407 if self.hw_accel != HardwareAccel::Auto && self.decode_buf.is_some() {
412 match DecodeBuffer::open(&self.active_path)
413 .hardware_accel(self.hw_accel)
414 .build()
415 {
416 Ok(buf) => {
417 self.decode_buf = Some(buf);
418 }
419 Err(e) => {
420 log::warn!(
421 "hwaccel decode buffer rebuild failed accel={} error={e}",
422 self.hw_accel.name()
423 );
424 }
425 }
426 }
427
428 self.clock.reset(Duration::ZERO);
429
430 let mut prev_audio_samples: u64 = 0;
435 let mut audio_stall_frames: u32 = 0;
436
437 loop {
438 let mut pending_seek: Option<Duration> = None;
440 while let Ok(cmd) = self.cmd_rx.try_recv() {
441 match cmd {
442 PlayerCommand::Seek(pts) => pending_seek = Some(pts),
443 PlayerCommand::Play => {
444 self.stopped.store(false, Ordering::Release);
445 self.paused.store(false, Ordering::Release);
446 }
447 PlayerCommand::Pause => {
448 self.paused.store(true, Ordering::Release);
449 }
450 PlayerCommand::Stop => {
451 self.stopped.store(true, Ordering::Release);
452 }
453 PlayerCommand::SetRate(r) => {
454 if r > 0.0 {
455 self.rate = r;
456 }
457 }
458 PlayerCommand::SetAvOffset(ms) => {
459 const MAX_OFFSET_MS: i64 = 5_000;
460 self.av_offset_ms = ms.clamp(-MAX_OFFSET_MS, MAX_OFFSET_MS);
461 }
462 }
463 }
464
465 if let Some(pts) = pending_seek {
467 if let Some(cache) = &mut self.frame_cache {
469 let in_range = cache
470 .pts_range()
471 .is_some_and(|(lo, hi)| pts >= lo && pts <= hi);
472 if !in_range {
473 cache.invalidate();
474 }
475 }
476 if let Some(buf) = self.decode_buf.as_mut() {
477 buf.seek(pts)?;
478 }
479 self.clock.reset(pts);
480 self.restart_audio_from(pts);
481 let _ = self.event_tx.try_send(PlayerEvent::SeekCompleted(pts));
482 }
483
484 if let Some(buf) = self.decode_buf.as_ref() {
486 while let Ok(msg) = buf.error_events().try_recv() {
487 let _ = self.event_tx.try_send(PlayerEvent::Error(msg));
488 }
489 }
490
491 if self.stopped.load(Ordering::Acquire) {
492 break;
493 }
494 if self.paused.load(Ordering::Acquire) {
495 thread::sleep(Duration::from_millis(5));
496 continue;
497 }
498
499 if self.decode_buf.is_none() {
501 thread::sleep(Duration::from_millis(10));
502 if let Some(audio_buf) = &self.audio_buf {
503 let empty = audio_buf
504 .lock()
505 .unwrap_or_else(std::sync::PoisonError::into_inner)
506 .is_empty();
507 if empty
508 && self
509 .audio_handle
510 .as_ref()
511 .is_none_or(JoinHandle::is_finished)
512 {
513 break;
514 }
515 } else {
516 break;
517 }
518 continue;
519 }
520
521 let current = self.clock.current_pts();
523 let cache_hit = self
524 .frame_cache
525 .as_ref()
526 .and_then(|c| c.get(current))
527 .map(|f| (f.rgba.clone(), f.width, f.height));
528 if let Some((rgba, width, height)) = cache_hit {
529 if let Some(sink) = self.sink.as_mut() {
530 sink.push_frame(&rgba, width, height, current);
531 }
532 self.current_pts.store(
533 u64::try_from(current.as_micros()).unwrap_or(u64::MAX),
534 Ordering::Relaxed,
535 );
536 let _ = self.event_tx.try_send(PlayerEvent::PositionUpdate(current));
537 continue;
538 }
539
540 let pop_result = if let Some(buf) = self.decode_buf.as_mut() {
542 buf.pop_frame()
543 } else {
544 FrameResult::Eof
545 };
546
547 match pop_result {
548 FrameResult::Eof => break,
549 FrameResult::Seeking(last) => {
550 if let Some(ref f) = last {
551 self.present_frame(f);
552 }
553 }
554 FrameResult::Frame(frame) => {
555 if self.clock.should_sync() {
556 let video_pts = if frame.timestamp().is_valid() {
557 frame.timestamp().as_duration()
558 } else {
559 Duration::ZERO
560 };
561
562 let offset_ms = self.av_offset_ms;
563 let offset = Duration::from_millis(offset_ms.unsigned_abs());
564 let adjusted_video_pts = if offset_ms >= 0 {
565 video_pts.saturating_sub(offset)
566 } else {
567 video_pts + offset
568 };
569
570 let clock_pts = self.clock.current_pts();
571 let diff = adjusted_video_pts.as_secs_f64() - clock_pts.as_secs_f64();
572 let fp = frame_period.as_secs_f64();
573
574 if diff > fp {
575 let sleep_secs =
576 (diff - fp / 2.0).max(0.0) / self.rate.max(f64::MIN_POSITIVE);
577 thread::sleep(Duration::from_secs_f64(sleep_secs.min(fp)));
580 } else if diff < -fp {
581 log::debug!(
582 "dropped late frame video_pts={video_pts:?} \
583 clock_pts={clock_pts:?}"
584 );
585 continue;
586 }
587 }
588
589 self.present_frame(&frame);
590 let pts = frame.timestamp().as_duration();
591 let _ = self.event_tx.try_send(PlayerEvent::PositionUpdate(pts));
592
593 self.clock.activate_fallback_if_no_audio(pts);
598
599 let cur_audio = self.clock.audio_samples_snapshot();
604 if cur_audio > 0 && cur_audio == prev_audio_samples {
605 audio_stall_frames = audio_stall_frames.saturating_add(1);
606 if audio_stall_frames == AUDIO_STALL_FRAMES {
607 self.clock.rearm_fallback_at(pts);
608 }
609 } else {
610 prev_audio_samples = cur_audio;
611 audio_stall_frames = 0;
612 }
613
614 if let Some(cache) = &mut self.frame_cache
616 && !self.rgba_buf.is_empty()
617 {
618 cache.insert(pts, self.rgba_buf.clone(), frame.width(), frame.height());
619 }
620 }
621 }
622 }
623
624 let _ = self.event_tx.try_send(PlayerEvent::Eof);
625 if let Some(sink) = self.sink.as_mut() {
626 sink.flush();
627 }
628 Ok(())
629 }
630
631 fn present_frame(&mut self, frame: &ff_format::VideoFrame) {
632 let pts = frame.timestamp().as_duration();
633 self.current_pts.store(
634 u64::try_from(pts.as_micros()).unwrap_or(u64::MAX),
635 Ordering::Relaxed,
636 );
637 let Some(sink) = self.sink.as_mut() else {
638 return;
639 };
640 let width = frame.width();
641 let height = frame.height();
642 if self.sws.convert(frame, &mut self.rgba_buf) {
643 sink.push_frame(&self.rgba_buf, width, height, pts);
644 }
645 }
646
647 fn restart_audio_from(&mut self, pts: Duration) {
648 if let Some(buf) = &self.audio_buf {
649 buf.lock()
650 .unwrap_or_else(std::sync::PoisonError::into_inner)
651 .clear();
652 }
653 if let Some(cancel) = &self.audio_cancel {
654 cancel.store(true, Ordering::Release);
655 }
656 drop(self.audio_handle.take());
657 if let Some(buf) = &self.audio_buf {
658 let new_cancel = Arc::new(AtomicBool::new(false));
659 let handle = spawn_audio_thread(
660 self.active_path.clone(),
661 pts,
662 Arc::clone(buf),
663 Arc::clone(&new_cancel),
664 );
665 self.audio_cancel = Some(new_cancel);
666 self.audio_handle = Some(handle);
667 }
668 }
669
670 fn activate_proxy(&mut self, proxy_path: &Path) -> Result<(), PreviewError> {
671 let info = ff_probe::open(proxy_path)?;
672 let fps = info.frame_rate().unwrap_or(30.0).max(1.0);
673 let decode_buf = DecodeBuffer::open(proxy_path)
674 .hardware_accel(self.hw_accel)
675 .build()?;
676
677 if let Some(cancel) = &self.audio_cancel {
678 cancel.store(true, Ordering::Release);
679 }
680 if let Some(buf) = &self.audio_buf {
681 buf.lock()
682 .unwrap_or_else(std::sync::PoisonError::into_inner)
683 .clear();
684 }
685 drop(self.audio_handle.take());
686
687 let (clock, audio_buf, audio_cancel, audio_handle) = if info.has_audio() {
688 let buf = Arc::new(Mutex::new(VecDeque::<f32>::new()));
689 let cancel = Arc::new(AtomicBool::new(false));
690 let handle = spawn_audio_thread(
691 proxy_path.to_path_buf(),
692 Duration::ZERO,
693 Arc::clone(&buf),
694 Arc::clone(&cancel),
695 );
696 let clock = MasterClock::Audio {
697 samples_consumed: Arc::new(AtomicU64::new(0)),
698 sample_rate: DECODED_SAMPLE_RATE,
699 fallback: None,
700 };
701 (clock, Some(buf), Some(cancel), Some(handle))
702 } else {
703 log::debug!(
704 "proxy has no audio, using system clock path={}",
705 proxy_path.display()
706 );
707 let clock = MasterClock::System {
708 started_at: Instant::now(),
709 base_pts: Duration::ZERO,
710 };
711 (clock, None, None, None)
712 };
713
714 self.active_path = proxy_path.to_path_buf();
715 self.fps = fps;
716 self.decode_buf = Some(decode_buf);
717 self.clock = clock;
718 self.audio_buf = audio_buf;
719 self.audio_cancel = audio_cancel;
720 self.audio_handle = audio_handle;
721 Ok(())
722 }
723}
724
725impl Drop for PlayerRunner {
726 fn drop(&mut self) {
727 if let Some(cancel) = &self.audio_cancel {
728 cancel.store(true, Ordering::Release);
729 }
730 if let Some(h) = self.audio_handle.take() {
731 let _ = h.join();
732 }
733 }
734}
735
736pub struct PreviewPlayer {
761 path: PathBuf,
762 decode_buf: Option<DecodeBuffer>,
764 fps: f64,
765 clock: Option<MasterClock>,
767 audio_buf: Option<Arc<Mutex<VecDeque<f32>>>>,
768 audio_cancel: Option<Arc<AtomicBool>>,
769 audio_handle: Option<JoinHandle<()>>,
770 duration_millis: u64,
771 active_path: PathBuf,
772}
773
774impl PreviewPlayer {
775 pub fn open(path: impl AsRef<Path>) -> Result<Self, PreviewError> {
786 let path = path.as_ref();
787 let info = ff_probe::open(path)?;
788
789 if !info.has_video() && !info.has_audio() {
790 return Err(PreviewError::Ffmpeg {
791 code: -1,
792 message: "file has neither a video nor an audio stream".into(),
793 });
794 }
795
796 let fps = info.frame_rate().unwrap_or(30.0).max(1.0);
797
798 let d = info.duration();
799 let duration_millis = if d.is_zero() {
800 u64::MAX
801 } else {
802 u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
803 };
804
805 let clock = if info.has_audio() {
806 MasterClock::Audio {
807 samples_consumed: Arc::new(AtomicU64::new(0)),
808 sample_rate: DECODED_SAMPLE_RATE,
809 fallback: None,
810 }
811 } else {
812 log::debug!(
813 "using system clock fallback path={} no_audio=true",
814 path.display()
815 );
816 MasterClock::System {
817 started_at: Instant::now(),
818 base_pts: Duration::ZERO,
819 }
820 };
821
822 let decode_buf = if info.has_video() {
823 Some(DecodeBuffer::open(path).build()?)
824 } else {
825 log::debug!(
826 "audio-only file; skipping video decode buffer path={}",
827 path.display()
828 );
829 None
830 };
831
832 let (audio_buf, audio_cancel, audio_handle) = if let MasterClock::Audio { .. } = &clock {
833 let buf = Arc::new(Mutex::new(VecDeque::<f32>::new()));
834 let cancel = Arc::new(AtomicBool::new(false));
835 let handle = spawn_audio_thread(
836 path.to_path_buf(),
837 Duration::ZERO,
838 Arc::clone(&buf),
839 Arc::clone(&cancel),
840 );
841 (Some(buf), Some(cancel), Some(handle))
842 } else {
843 (None, None, None)
844 };
845
846 Ok(PreviewPlayer {
847 path: path.to_path_buf(),
848 decode_buf,
849 fps,
850 clock: Some(clock),
851 audio_buf,
852 audio_cancel,
853 audio_handle,
854 duration_millis,
855 active_path: path.to_path_buf(),
856 })
857 }
858
859 #[must_use]
871 #[allow(clippy::expect_used)]
872 pub fn split(mut self) -> (PlayerRunner, PlayerHandle) {
873 let current_pts = Arc::new(AtomicU64::new(0));
874 let paused = Arc::new(AtomicBool::new(false));
875 let stopped = Arc::new(AtomicBool::new(false));
876 let (cmd_tx, cmd_rx) = mpsc::sync_channel(CHANNEL_CAP);
877 let (event_tx, event_rx) = mpsc::sync_channel(CHANNEL_CAP);
878
879 let clock = self.clock.take().expect("clock consumed before split");
880 let samples_consumed = match &clock {
881 MasterClock::Audio {
882 samples_consumed, ..
883 } => Some(Arc::clone(samples_consumed)),
884 MasterClock::System { .. } => None,
885 };
886
887 let audio_buf_for_handle = self.audio_buf.clone();
888 let duration_millis = self.duration_millis;
889
890 let runner = PlayerRunner {
891 path: self.path.clone(),
892 cmd_rx,
893 event_tx,
894 decode_buf: self.decode_buf.take(),
895 fps: self.fps,
896 sink: None,
897 clock,
898 audio_buf: self.audio_buf.take(),
899 audio_cancel: self.audio_cancel.take(),
900 audio_handle: self.audio_handle.take(),
901 sws: super::playback_inner::SwsRgbaConverter::new(),
902 rgba_buf: Vec::new(),
903 active_path: self.active_path.clone(),
904 current_pts: Arc::clone(¤t_pts),
905 paused: Arc::clone(&paused),
906 stopped: Arc::clone(&stopped),
907 av_offset_ms: 0,
908 rate: 1.0,
909 duration_millis,
910 frame_cache: None,
911 hw_accel: HardwareAccel::Auto,
912 };
913
914 let handle = PlayerHandle {
915 cmd_tx,
916 event_rx: Arc::new(Mutex::new(event_rx)),
917 current_pts,
918 audio_buf: audio_buf_for_handle,
919 samples_consumed,
920 audio_mixer: None,
921 paused,
922 stopped,
923 duration_millis,
924 };
925
926 (runner, handle)
927 }
928}
929
930impl Drop for PreviewPlayer {
931 fn drop(&mut self) {
932 if let Some(cancel) = &self.audio_cancel {
933 cancel.store(true, Ordering::Release);
934 }
935 if let Some(h) = self.audio_handle.take() {
936 let _ = h.join();
937 }
938 }
939}
940
941fn spawn_audio_thread(
944 path: PathBuf,
945 start_pts: Duration,
946 buf: Arc<Mutex<VecDeque<f32>>>,
947 cancel: Arc<AtomicBool>,
948) -> JoinHandle<()> {
949 thread::spawn(move || {
950 let mut decoder = match AudioDecoder::open(&path)
951 .output_format(SampleFormat::F32)
952 .output_sample_rate(DECODED_SAMPLE_RATE)
953 .output_channels(2)
954 .build()
955 {
956 Ok(d) => d,
957 Err(e) => {
958 log::warn!("audio decode thread open failed error={e}");
959 return;
960 }
961 };
962
963 if start_pts != Duration::ZERO
964 && let Err(e) = decoder.seek(start_pts, SeekMode::Backward)
965 {
966 log::warn!("audio seek failed pts={start_pts:?} error={e}");
967 }
968
969 loop {
970 if cancel.load(Ordering::Acquire) {
971 break;
972 }
973
974 match decoder.decode_one() {
975 Ok(Some(frame)) => {
976 let samples = super::playback_inner::audio_frame_to_f32(&frame);
977 let mut offset = 0;
983 while offset < samples.len() {
984 if cancel.load(Ordering::Acquire) {
985 return;
986 }
987 let mut guard = buf
988 .lock()
989 .unwrap_or_else(std::sync::PoisonError::into_inner);
990 let space = AUDIO_MAX_BUF.saturating_sub(guard.len());
991 if space == 0 {
992 drop(guard);
993 thread::sleep(Duration::from_millis(1));
994 continue;
995 }
996 let take = space.min(samples.len() - offset);
997 guard.extend(samples[offset..offset + take].iter().copied());
998 offset += take;
999 }
1000 }
1001 Ok(None) => break,
1002 Err(e) => {
1003 log::warn!("audio decode error error={e}");
1004 break;
1005 }
1006 }
1007 }
1008 })
1009}
1010
1011#[cfg(test)]
1014mod tests {
1015 use super::*;
1016
1017 fn test_video_path() -> PathBuf {
1018 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/video/gameplay.mp4")
1019 }
1020
1021 fn test_audio_path() -> PathBuf {
1022 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/audio/konekonoosanpo.mp3")
1023 }
1024
1025 #[test]
1028 fn preview_player_open_should_fail_for_nonexistent_file() {
1029 let result = PreviewPlayer::open("nonexistent_preview.mp4");
1030 assert!(
1031 result.is_err(),
1032 "open() must return Err for a non-existent file"
1033 );
1034 }
1035
1036 #[test]
1039 fn player_handle_play_pause_should_update_paused_flag_immediately() {
1040 let path = test_video_path();
1041 let (_runner, handle) = match PreviewPlayer::open(&path) {
1042 Ok(p) => p.split(),
1043 Err(e) => {
1044 println!("skipping: video file not available: {e}");
1045 return;
1046 }
1047 };
1048
1049 assert!(!handle.paused.load(Ordering::Relaxed));
1050 assert!(!handle.stopped.load(Ordering::Relaxed));
1051
1052 handle.pause();
1053 assert!(handle.paused.load(Ordering::Relaxed));
1054
1055 handle.play();
1056 assert!(!handle.paused.load(Ordering::Relaxed));
1057 assert!(!handle.stopped.load(Ordering::Relaxed));
1058
1059 handle.stop();
1060 assert!(handle.stopped.load(Ordering::Relaxed));
1061 }
1062
1063 #[test]
1066 fn player_runner_run_should_deliver_frames_to_sink() {
1067 struct CountSink(Arc<Mutex<usize>>);
1068 impl FrameSink for CountSink {
1069 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1070 *self
1071 .0
1072 .lock()
1073 .unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
1074 }
1075 }
1076
1077 let path = test_video_path();
1078 let (mut runner, _handle) = match PreviewPlayer::open(&path) {
1079 Ok(p) => p.split(),
1080 Err(e) => {
1081 println!("skipping: video file not available: {e}");
1082 return;
1083 }
1084 };
1085
1086 let count = Arc::new(Mutex::new(0usize));
1087 runner.set_sink(Box::new(CountSink(Arc::clone(&count))));
1088
1089 match runner.run() {
1090 Ok(()) => {}
1091 Err(e) => {
1092 println!("skipping: run() error: {e}");
1093 return;
1094 }
1095 }
1096
1097 let frames = *count
1098 .lock()
1099 .unwrap_or_else(std::sync::PoisonError::into_inner);
1100 assert!(
1101 frames > 0,
1102 "run() must deliver at least one frame to the sink"
1103 );
1104 }
1105
1106 #[test]
1109 fn pop_audio_samples_should_return_empty_when_paused() {
1110 let path = test_video_path();
1111 let (_runner, handle) = match PreviewPlayer::open(&path) {
1112 Ok(p) => p.split(),
1113 Err(e) => {
1114 println!("skipping: video file not available: {e}");
1115 return;
1116 }
1117 };
1118 handle.pause();
1119 let samples = handle.pop_audio_samples(1024);
1120 assert!(
1121 samples.is_empty(),
1122 "pop_audio_samples() must return empty while paused"
1123 );
1124 }
1125
1126 #[test]
1127 fn pop_audio_samples_should_return_empty_when_stopped() {
1128 let path = test_video_path();
1129 let (_runner, handle) = match PreviewPlayer::open(&path) {
1130 Ok(p) => p.split(),
1131 Err(e) => {
1132 println!("skipping: video file not available: {e}");
1133 return;
1134 }
1135 };
1136 handle.stop();
1137 let samples = handle.pop_audio_samples(1024);
1138 assert!(
1139 samples.is_empty(),
1140 "pop_audio_samples() must return empty while stopped"
1141 );
1142 }
1143
1144 #[test]
1145 fn pop_audio_samples_should_return_empty_for_zero_n_samples() {
1146 let path = test_video_path();
1147 let (_runner, handle) = match PreviewPlayer::open(&path) {
1148 Ok(p) => p.split(),
1149 Err(e) => {
1150 println!("skipping: video file not available: {e}");
1151 return;
1152 }
1153 };
1154 handle.play();
1155 let samples = handle.pop_audio_samples(0);
1156 assert!(
1157 samples.is_empty(),
1158 "pop_audio_samples(0) must always return empty"
1159 );
1160 }
1161
1162 #[test]
1163 fn pop_audio_samples_should_be_callable_via_cloned_handle() {
1164 let path = test_video_path();
1165 let (_runner, handle) = match PreviewPlayer::open(&path) {
1166 Ok(p) => p.split(),
1167 Err(e) => {
1168 println!("skipping: video file not available: {e}");
1169 return;
1170 }
1171 };
1172 let shared = handle.clone();
1173 let _samples = shared.pop_audio_samples(0);
1174 }
1175
1176 #[test]
1177 fn pop_audio_samples_clock_increment_should_equal_half_sample_count() {
1178 let stereo_samples: usize = 9_600;
1179 let expected_frames: u64 = (stereo_samples / 2) as u64;
1180 assert_eq!(
1181 expected_frames, 4_800,
1182 "9600 stereo samples must yield 4800 clock frames"
1183 );
1184 let pts = Duration::from_secs_f64(f64::from(48_000u32).recip() * expected_frames as f64);
1185 assert!(
1186 (pts.as_secs_f64() - 0.1).abs() < 1e-6,
1187 "4800 frames at 48 kHz must equal 100 ms; got {pts:?}"
1188 );
1189 }
1190
1191 #[test]
1194 fn current_pts_should_return_zero_before_first_frame() {
1195 let path = test_video_path();
1196 let (_runner, handle) = match PreviewPlayer::open(&path) {
1197 Ok(p) => p.split(),
1198 Err(e) => {
1199 println!("skipping: video file not available: {e}");
1200 return;
1201 }
1202 };
1203 assert_eq!(
1204 handle.current_pts(),
1205 Duration::ZERO,
1206 "current_pts() must be ZERO before any frame is presented"
1207 );
1208 }
1209
1210 #[test]
1211 fn duration_should_return_some_for_file_with_known_duration() {
1212 let path = test_video_path();
1213 let (_runner, handle) = match PreviewPlayer::open(&path) {
1214 Ok(p) => p.split(),
1215 Err(e) => {
1216 println!("skipping: video file not available: {e}");
1217 return;
1218 }
1219 };
1220 assert!(
1221 handle.duration().is_some(),
1222 "duration() must return Some for a file with a known container duration"
1223 );
1224 let d = handle.duration().unwrap();
1225 assert!(
1226 d > Duration::ZERO,
1227 "duration() must be positive for a valid media file; got {d:?}"
1228 );
1229 }
1230
1231 #[test]
1232 fn duration_should_return_none_when_duration_millis_is_sentinel() {
1233 let sentinel = u64::MAX;
1234 let result: Option<Duration> = if sentinel == u64::MAX {
1235 None
1236 } else {
1237 Some(Duration::from_millis(sentinel))
1238 };
1239 assert!(result.is_none(), "sentinel u64::MAX must map to None");
1240
1241 let valid = 5_000u64;
1242 let result: Option<Duration> = if valid == u64::MAX {
1243 None
1244 } else {
1245 Some(Duration::from_millis(valid))
1246 };
1247 assert_eq!(result, Some(Duration::from_secs(5)));
1248 }
1249
1250 #[test]
1251 fn current_pts_should_advance_after_frames_are_presented() {
1252 struct PtsSink(Arc<Mutex<Option<Duration>>>);
1253 impl FrameSink for PtsSink {
1254 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, pts: Duration) {
1255 *self
1256 .0
1257 .lock()
1258 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(pts);
1259 }
1260 }
1261
1262 let path = test_video_path();
1263 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1264 Ok(p) => p.split(),
1265 Err(e) => {
1266 println!("skipping: video file not available: {e}");
1267 return;
1268 }
1269 };
1270
1271 let last_pts = Arc::new(Mutex::new(None::<Duration>));
1272 runner.set_sink(Box::new(PtsSink(Arc::clone(&last_pts))));
1273 let _ = runner.run();
1274
1275 let sink_pts = last_pts
1276 .lock()
1277 .unwrap_or_else(std::sync::PoisonError::into_inner)
1278 .unwrap_or(Duration::ZERO);
1279 let player_pts = handle.current_pts();
1280 let diff = sink_pts.abs_diff(player_pts);
1281 assert!(
1282 diff <= Duration::from_millis(1),
1283 "current_pts() must be within 1 ms of the last sink PTS; \
1284 player_pts={player_pts:?} sink_pts={sink_pts:?} diff={diff:?}"
1285 );
1286 }
1287
1288 #[test]
1291 fn seek_coarse_should_delegate_to_decode_buffer() {
1292 let path = test_video_path();
1293 let (runner, handle) = match PreviewPlayer::open(&path) {
1294 Ok(p) => p.split(),
1295 Err(e) => {
1296 println!("skipping: video file not available: {e}");
1297 return;
1298 }
1299 };
1300
1301 let target = Duration::from_secs(1);
1302 handle.seek(target);
1303
1304 let handle_thread = handle.clone();
1306 thread::spawn(move || {
1307 thread::sleep(Duration::from_millis(500));
1308 handle_thread.stop();
1309 });
1310
1311 match runner.run() {
1312 Ok(()) => {}
1313 Err(e) => {
1314 println!("skipping: run() error: {e}");
1315 }
1316 }
1317 }
1318
1319 #[test]
1322 fn use_proxy_if_available_should_return_false_when_no_proxy_in_dir() {
1323 let path = test_video_path();
1324 let (mut runner, _handle) = match PreviewPlayer::open(&path) {
1325 Ok(p) => p.split(),
1326 Err(e) => {
1327 println!("skipping: video file not available: {e}");
1328 return;
1329 }
1330 };
1331 let tmp = std::env::temp_dir().join("ff_preview_no_proxy_dir_test");
1332 let _ = std::fs::create_dir_all(&tmp);
1333 let found = runner.use_proxy_if_available(&tmp);
1334 assert!(
1335 !found,
1336 "must return false when no proxy files exist in the directory"
1337 );
1338 }
1339
1340 #[test]
1341 fn active_source_should_return_original_path_before_proxy_activation() {
1342 let path = test_video_path();
1343 let (runner, _handle) = match PreviewPlayer::open(&path) {
1344 Ok(p) => p.split(),
1345 Err(e) => {
1346 println!("skipping: video file not available: {e}");
1347 return;
1348 }
1349 };
1350 assert_eq!(
1351 runner.active_source(),
1352 path.as_path(),
1353 "active_source() must equal the original path before any proxy activation"
1354 );
1355 }
1356
1357 #[test]
1360 fn set_rate_should_accept_positive_value() {
1361 let path = test_video_path();
1362 let (_runner, handle) = match PreviewPlayer::open(&path) {
1363 Ok(p) => p.split(),
1364 Err(e) => {
1365 println!("skipping: video file not available: {e}");
1366 return;
1367 }
1368 };
1369 handle.set_rate(2.0);
1371 handle.set_rate(0.5);
1372 }
1373
1374 #[test]
1375 fn set_av_offset_default_should_be_zero() {
1376 use std::sync::atomic::{AtomicI64, Ordering};
1377 let offset = AtomicI64::new(0);
1378 assert_eq!(offset.load(Ordering::Relaxed), 0);
1379 }
1380
1381 #[test]
1382 fn positive_av_offset_should_reduce_adjusted_video_pts() {
1383 let video_pts = Duration::from_millis(1_000);
1384 let offset_ms: i64 = 200;
1385 let adjusted = if offset_ms >= 0 {
1386 let offset = Duration::from_millis(offset_ms as u64);
1387 video_pts.saturating_sub(offset)
1388 } else {
1389 let offset = Duration::from_millis(offset_ms.unsigned_abs());
1390 video_pts + offset
1391 };
1392 assert_eq!(
1393 adjusted,
1394 Duration::from_millis(800),
1395 "positive offset must reduce adjusted_video_pts by offset amount"
1396 );
1397 }
1398
1399 #[test]
1400 fn negative_av_offset_should_increase_adjusted_video_pts() {
1401 let video_pts = Duration::from_millis(1_000);
1402 let offset_ms: i64 = -200;
1403 let adjusted = if offset_ms >= 0 {
1404 let offset = Duration::from_millis(offset_ms as u64);
1405 video_pts.saturating_sub(offset)
1406 } else {
1407 let offset = Duration::from_millis(offset_ms.unsigned_abs());
1408 video_pts + offset
1409 };
1410 assert_eq!(
1411 adjusted,
1412 Duration::from_millis(1_200),
1413 "negative offset must increase adjusted_video_pts by offset amount"
1414 );
1415 }
1416
1417 #[test]
1418 fn positive_av_offset_at_zero_pts_should_saturate_to_zero() {
1419 let video_pts = Duration::ZERO;
1420 let offset_ms: i64 = 100;
1421 let adjusted = video_pts.saturating_sub(Duration::from_millis(offset_ms as u64));
1422 assert_eq!(
1423 adjusted,
1424 Duration::ZERO,
1425 "saturating_sub on zero pts must clamp to zero not underflow"
1426 );
1427 }
1428
1429 #[test]
1432 fn audio_sample_rate_should_return_some_48_khz_for_audio_only_file() {
1433 let path = test_audio_path();
1434 let (_runner, handle) = match PreviewPlayer::open(&path) {
1435 Ok(p) => p.split(),
1436 Err(e) => {
1437 println!("skipping: audio file not available: {e}");
1438 return;
1439 }
1440 };
1441 assert_eq!(
1442 handle.audio_sample_rate(),
1443 Some(DECODED_SAMPLE_RATE),
1444 "audio_sample_rate() must return Some(48_000) for a file with an audio stream"
1445 );
1446 }
1447
1448 #[test]
1449 fn audio_sample_rate_should_return_some_48_khz_regardless_of_source_native_rate() {
1450 let path = test_audio_path();
1455 let (_runner, handle) = match PreviewPlayer::open(&path) {
1456 Ok(p) => p.split(),
1457 Err(e) => {
1458 println!("skipping: audio file not available: {e}");
1459 return;
1460 }
1461 };
1462 if let Some(rate) = handle.audio_sample_rate() {
1463 assert_eq!(
1464 rate, DECODED_SAMPLE_RATE,
1465 "audio_sample_rate() must equal DECODED_SAMPLE_RATE=48 000 regardless of source"
1466 );
1467 }
1468 }
1469
1470 #[test]
1471 fn audio_sample_rate_should_return_none_when_no_audio_buf_present() {
1472 let buf: Option<std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<f32>>>> = None;
1476 let rate: Option<u32> = buf.as_ref().map(|_| DECODED_SAMPLE_RATE);
1477 assert_eq!(
1478 rate, None,
1479 "audio_sample_rate() must return None when no audio ring buffer is present"
1480 );
1481 }
1482
1483 #[test]
1486 fn audio_only_open_should_succeed() {
1487 let path = test_audio_path();
1488 match PreviewPlayer::open(&path) {
1489 Ok(player) => {
1490 let (runner, handle) = player.split();
1491 assert!(
1493 runner.decode_buf.is_none(),
1494 "audio-only runner must have no video decode buffer"
1495 );
1496 assert!(
1498 handle.audio_buf.is_some(),
1499 "audio-only handle must have an audio ring buffer"
1500 );
1501 }
1502 Err(e) => {
1503 println!("skipping: audio file not available: {e}");
1504 }
1505 }
1506 }
1507
1508 #[test]
1509 fn audio_only_run_should_return_ok_without_video_frames() {
1510 let path = test_audio_path();
1511 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1512 Ok(p) => p.split(),
1513 Err(e) => {
1514 println!("skipping: audio file not available: {e}");
1515 return;
1516 }
1517 };
1518
1519 struct CountingSink(usize);
1520 impl FrameSink for CountingSink {
1521 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1522 self.0 += 1;
1523 }
1524 }
1525 runner.set_sink(Box::new(CountingSink(0)));
1526
1527 let handle_thread = handle.clone();
1528 thread::spawn(move || {
1529 thread::sleep(Duration::from_millis(150));
1530 handle_thread.stop();
1531 });
1532
1533 let result = runner.run();
1534 assert!(
1535 result.is_ok(),
1536 "run() on an audio-only player must return Ok; got {result:?}"
1537 );
1538 assert_eq!(
1539 handle.current_pts(),
1540 Duration::ZERO,
1541 "current_pts() must remain ZERO for audio-only playback (no video frames)"
1542 );
1543 }
1544
1545 #[test]
1546 fn audio_only_seek_should_not_fail_for_valid_target() {
1547 let path = test_audio_path();
1548 let (_runner, handle) = match PreviewPlayer::open(&path) {
1549 Ok(p) => p.split(),
1550 Err(e) => {
1551 println!("skipping: audio file not available: {e}");
1552 return;
1553 }
1554 };
1555 handle.seek(Duration::from_secs(1));
1557 }
1558
1559 #[test]
1562 #[ignore = "requires assets/video/gameplay.mp4; run with -- --include-ignored"]
1563 fn seek_should_deliver_seek_completed_event_via_poll_event() {
1564 let path = test_video_path();
1565 if !path.exists() {
1566 println!("skipping: video file not found at {}", path.display());
1567 return;
1568 }
1569
1570 let (runner, handle) = match PreviewPlayer::open(&path) {
1571 Ok(p) => p.split(),
1572 Err(e) => {
1573 println!("skipping: open failed: {e}");
1574 return;
1575 }
1576 };
1577
1578 let handle_bg = handle.clone();
1579 let bg = thread::spawn(move || {
1580 let _ = runner.run();
1581 });
1582
1583 thread::sleep(Duration::from_millis(50));
1585 let target = Duration::from_secs(1);
1586 handle.seek(target);
1587
1588 let deadline = Instant::now() + Duration::from_secs(2);
1590 let event = loop {
1591 if let Some(e) = handle.poll_event() {
1592 break Some(e);
1593 }
1594 if Instant::now() > deadline {
1595 break None;
1596 }
1597 thread::sleep(Duration::from_millis(10));
1598 };
1599
1600 handle_bg.stop();
1601 let _ = bg.join();
1602
1603 match event {
1604 Some(PlayerEvent::SeekCompleted(pts)) => {
1605 assert!(
1606 pts >= target.saturating_sub(Duration::from_millis(100)),
1607 "SeekCompleted pts must be near the requested target; \
1608 target={target:?} pts={pts:?}"
1609 );
1610 }
1611 Some(PlayerEvent::Eof) => {
1612 panic!("received Eof before SeekCompleted — file may be too short");
1613 }
1614 Some(PlayerEvent::PositionUpdate(_) | PlayerEvent::Error(_)) | None => {
1615 panic!("no PlayerEvent::SeekCompleted received within 2 seconds");
1616 }
1617 }
1618 }
1619
1620 #[test]
1623 fn position_update_and_error_event_variants_should_be_accessible() {
1624 let _ = PlayerEvent::PositionUpdate(Duration::ZERO);
1625 let _ = PlayerEvent::Error("test error".to_string());
1626 }
1627
1628 #[test]
1629 fn eof_event_should_be_delivered_after_run_completes() {
1630 let path = test_audio_path();
1631 let (runner, handle) = match PreviewPlayer::open(&path) {
1632 Ok(p) => p.split(),
1633 Err(e) => {
1634 println!("skipping: {e}");
1635 return;
1636 }
1637 };
1638
1639 let handle_stop = handle.clone();
1641 thread::spawn(move || {
1642 thread::sleep(Duration::from_millis(150));
1643 handle_stop.stop();
1644 });
1645
1646 let _ = runner.run();
1647 let events: Vec<_> = std::iter::from_fn(|| handle.poll_event()).collect();
1648 assert!(
1649 events.iter().any(|e| matches!(e, PlayerEvent::Eof)),
1650 "Eof event must be delivered after run() returns; collected {} events",
1651 events.len()
1652 );
1653 }
1654
1655 #[test]
1656 #[ignore = "requires assets/video/gameplay.mp4; run with -- --include-ignored"]
1657 fn position_update_should_be_emitted_for_each_video_frame() {
1658 let path =
1659 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/video/gameplay.mp4");
1660 if !path.exists() {
1661 println!("skipping: video asset not found");
1662 return;
1663 }
1664
1665 use std::sync::{Arc, Mutex};
1666 struct CountSink {
1667 count: Arc<Mutex<usize>>,
1668 max: usize,
1669 handle: PlayerHandle,
1670 }
1671 impl FrameSink for CountSink {
1672 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1673 let mut g = self
1674 .count
1675 .lock()
1676 .unwrap_or_else(std::sync::PoisonError::into_inner);
1677 *g += 1;
1678 if *g >= self.max {
1679 self.handle.stop();
1680 }
1681 }
1682 }
1683
1684 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1685 Ok(p) => p.split(),
1686 Err(e) => {
1687 println!("skipping: {e}");
1688 return;
1689 }
1690 };
1691
1692 let count = Arc::new(Mutex::new(0usize));
1693 runner.set_sink(Box::new(CountSink {
1694 count: Arc::clone(&count),
1695 max: 20,
1696 handle: handle.clone(),
1697 }));
1698 let _ = runner.run();
1699
1700 let frames = *count
1701 .lock()
1702 .unwrap_or_else(std::sync::PoisonError::into_inner);
1703 let position_updates: Vec<_> = std::iter::from_fn(|| handle.poll_event())
1704 .filter(|e| matches!(e, PlayerEvent::PositionUpdate(_)))
1705 .collect();
1706
1707 assert!(
1708 !position_updates.is_empty(),
1709 "at least one PositionUpdate event must be emitted; frames delivered={frames}"
1710 );
1711 assert!(
1712 position_updates.len() <= frames,
1713 "PositionUpdate count ({}) must not exceed frame count ({frames})",
1714 position_updates.len()
1715 );
1716 }
1717
1718 #[test]
1721 fn hardware_accel_variants_should_be_accessible_on_player_runner() {
1722 let _ = HardwareAccel::Auto;
1724 let _ = HardwareAccel::None;
1725 let _ = HardwareAccel::Nvdec;
1726 let _ = HardwareAccel::Qsv;
1727 let _ = HardwareAccel::Amf;
1728 let _ = HardwareAccel::VideoToolbox;
1729 let _ = HardwareAccel::Vaapi;
1730 }
1731
1732 #[test]
1733 fn set_hardware_accel_none_should_complete_without_error_on_audio_only_file() {
1734 let path = test_audio_path();
1738 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1739 Ok(p) => p.split(),
1740 Err(e) => {
1741 println!("skipping: audio file not available: {e}");
1742 return;
1743 }
1744 };
1745
1746 runner.set_hardware_accel(HardwareAccel::None);
1747 assert_eq!(runner.hw_accel, HardwareAccel::None);
1748
1749 let handle_stop = handle.clone();
1750 thread::spawn(move || {
1751 thread::sleep(Duration::from_millis(150));
1752 handle_stop.stop();
1753 });
1754
1755 let result = runner.run();
1756 assert!(
1757 result.is_ok(),
1758 "run() with HardwareAccel::None must return Ok; got {result:?}"
1759 );
1760 }
1761
1762 #[test]
1763 #[ignore = "requires assets/video/gameplay.mp4 and hardware decoder; run with -- --include-ignored"]
1764 fn hardware_accel_auto_should_deliver_frames_on_video_file() {
1765 let path = test_video_path();
1766 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1767 Ok(p) => p.split(),
1768 Err(e) => {
1769 println!("skipping: video file not available: {e}");
1770 return;
1771 }
1772 };
1773
1774 runner.set_hardware_accel(HardwareAccel::Auto);
1775
1776 struct CountSink {
1777 count: usize,
1778 max: usize,
1779 handle: PlayerHandle,
1780 }
1781 impl FrameSink for CountSink {
1782 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1783 self.count += 1;
1784 if self.count >= self.max {
1785 self.handle.stop();
1786 }
1787 }
1788 }
1789 runner.set_sink(Box::new(CountSink {
1790 count: 0,
1791 max: 5,
1792 handle: handle.clone(),
1793 }));
1794
1795 let result = runner.run();
1796 assert!(
1797 result.is_ok(),
1798 "run() with HardwareAccel::Auto must return Ok; got {result:?}"
1799 );
1800 assert!(
1801 handle.current_pts() > Duration::ZERO,
1802 "at least one frame must have been presented"
1803 );
1804 }
1805}