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#[cfg(feature = "timeline")]
27use ff_pipeline::timeline::Timeline;
28
29use super::clock::MasterClock;
30use super::decode_buffer::{DecodeBuffer, FrameResult};
31use super::sink::FrameSink;
32use crate::audio::AudioMixer;
33use crate::cache::FrameCache;
34use crate::error::PreviewError;
35use crate::event::PlayerEvent;
36
37const AUDIO_MAX_BUF: usize = 96_000;
40const CHANNEL_CAP: usize = 64;
41const AUDIO_STALL_FRAMES: u32 = 5;
46const DECODED_SAMPLE_RATE: u32 = 48_000;
54
55pub enum PlayerCommand {
60 Play,
62 Pause,
64 Stop,
67 Seek(Duration),
70 SetRate(f64),
72 SetAvOffset(i64),
74 #[cfg(feature = "timeline")]
81 UpdateLayout(Box<Timeline>),
82}
83
84#[derive(Clone)]
96pub struct PlayerHandle {
97 cmd_tx: mpsc::SyncSender<PlayerCommand>,
98 event_rx: Arc<Mutex<mpsc::Receiver<PlayerEvent>>>,
99 current_pts: Arc<AtomicU64>,
101 audio_buf: Option<Arc<Mutex<VecDeque<f32>>>>,
102 samples_consumed: Option<Arc<AtomicU64>>,
104 paused: Arc<AtomicBool>,
106 stopped: Arc<AtomicBool>,
108 duration_millis: u64,
109 audio_mixer: Option<Arc<Mutex<AudioMixer>>>,
111}
112
113impl PlayerHandle {
114 pub fn play(&self) {
116 self.stopped.store(false, Ordering::Release);
117 self.paused.store(false, Ordering::Release);
118 let _ = self.cmd_tx.try_send(PlayerCommand::Play);
119 }
120
121 pub fn pause(&self) {
123 self.paused.store(true, Ordering::Release);
124 let _ = self.cmd_tx.try_send(PlayerCommand::Pause);
125 }
126
127 pub fn stop(&self) {
129 self.stopped.store(true, Ordering::Release);
130 let _ = self.cmd_tx.try_send(PlayerCommand::Stop);
131 }
132
133 pub fn seek(&self, pts: Duration) {
138 let _ = self.cmd_tx.try_send(PlayerCommand::Seek(pts));
139 }
140
141 pub fn set_rate(&self, rate: f64) {
149 let _ = self.cmd_tx.try_send(PlayerCommand::SetRate(rate));
150 }
151
152 pub fn set_av_offset(&self, ms: i64) {
157 let _ = self.cmd_tx.try_send(PlayerCommand::SetAvOffset(ms));
158 }
159
160 #[cfg(feature = "timeline")]
174 pub fn update_timeline(&self, timeline: Timeline) {
175 let _ = self
176 .cmd_tx
177 .try_send(PlayerCommand::UpdateLayout(Box::new(timeline)));
178 }
179
180 #[must_use]
184 pub fn current_pts(&self) -> Duration {
185 Duration::from_micros(self.current_pts.load(Ordering::Relaxed))
186 }
187
188 #[must_use]
190 pub fn duration(&self) -> Option<Duration> {
191 if self.duration_millis == u64::MAX {
192 None
193 } else {
194 Some(Duration::from_millis(self.duration_millis))
195 }
196 }
197
198 #[must_use]
215 pub fn audio_sample_rate(&self) -> Option<u32> {
216 self.audio_buf.as_ref().map(|_| DECODED_SAMPLE_RATE)
217 }
218
219 #[allow(clippy::cast_precision_loss)]
229 pub fn pop_audio_samples(&self, n: usize) -> Vec<f32> {
230 if self.paused.load(Ordering::Relaxed) || self.stopped.load(Ordering::Relaxed) {
231 return Vec::new();
232 }
233 if n == 0 {
234 return Vec::new();
235 }
236 if let Some(mixer) = &self.audio_mixer {
239 return mixer
240 .lock()
241 .unwrap_or_else(std::sync::PoisonError::into_inner)
242 .mix(n);
243 }
244 let Some(buf) = &self.audio_buf else {
246 return Vec::new();
247 };
248 let mut guard = buf
249 .lock()
250 .unwrap_or_else(std::sync::PoisonError::into_inner);
251 let take = n.min(guard.len());
252 if take == 0 {
253 return Vec::new();
254 }
255 let samples: Vec<f32> = guard.drain(..take).collect();
256 if let Some(sc) = &self.samples_consumed {
257 sc.fetch_add((take / 2) as u64, Ordering::Relaxed);
258 }
259 samples
260 }
261
262 #[allow(clippy::cast_precision_loss)]
280 pub fn pop_audio_samples_for_rate(&self, pop_n: usize, clock_stereo_pairs: u64) -> Vec<f32> {
281 if self.paused.load(Ordering::Relaxed) || self.stopped.load(Ordering::Relaxed) {
282 if let Some(sc) = &self.samples_consumed {
284 sc.fetch_add(clock_stereo_pairs, Ordering::Relaxed);
285 }
286 return Vec::new();
287 }
288 if pop_n == 0 {
289 if let Some(sc) = &self.samples_consumed {
290 sc.fetch_add(clock_stereo_pairs, Ordering::Relaxed);
291 }
292 return Vec::new();
293 }
294 if let Some(mixer) = &self.audio_mixer {
296 return mixer
297 .lock()
298 .unwrap_or_else(std::sync::PoisonError::into_inner)
299 .mix(pop_n);
300 }
301 let Some(buf) = &self.audio_buf else {
303 if let Some(sc) = &self.samples_consumed {
304 sc.fetch_add(clock_stereo_pairs, Ordering::Relaxed);
305 }
306 return Vec::new();
307 };
308 let mut guard = buf
309 .lock()
310 .unwrap_or_else(std::sync::PoisonError::into_inner);
311 let take = pop_n.min(guard.len());
312 let samples: Vec<f32> = if take > 0 {
313 guard.drain(..take).collect()
314 } else {
315 Vec::new()
316 };
317 drop(guard);
318 if let Some(sc) = &self.samples_consumed {
320 sc.fetch_add(clock_stereo_pairs, Ordering::Relaxed);
321 }
322 samples
323 }
324
325 #[must_use]
329 pub fn poll_event(&self) -> Option<PlayerEvent> {
330 self.event_rx.lock().ok()?.try_recv().ok()
331 }
332
333 #[must_use]
338 pub fn recv_event(&self) -> Option<PlayerEvent> {
339 self.event_rx.lock().ok()?.recv().ok()
340 }
341
342 #[cfg(feature = "timeline")]
347 pub(crate) fn for_timeline(
348 cmd_tx: mpsc::SyncSender<PlayerCommand>,
349 event_rx: Arc<Mutex<mpsc::Receiver<PlayerEvent>>>,
350 current_pts: Arc<AtomicU64>,
351 paused: Arc<AtomicBool>,
352 stopped: Arc<AtomicBool>,
353 duration_millis: u64,
354 audio_mixer: Option<Arc<Mutex<AudioMixer>>>,
355 ) -> Self {
356 Self {
357 cmd_tx,
358 event_rx,
359 current_pts,
360 audio_buf: None,
361 samples_consumed: None,
362 audio_mixer,
363 paused,
364 stopped,
365 duration_millis,
366 }
367 }
368}
369
370pub struct PlayerRunner {
379 path: PathBuf,
380 cmd_rx: mpsc::Receiver<PlayerCommand>,
381 event_tx: mpsc::SyncSender<PlayerEvent>,
382 decode_buf: Option<DecodeBuffer>,
383 fps: f64,
384 sink: Option<Box<dyn FrameSink>>,
385 clock: MasterClock,
386 audio_buf: Option<Arc<Mutex<VecDeque<f32>>>>,
387 audio_cancel: Option<Arc<AtomicBool>>,
388 audio_handle: Option<JoinHandle<()>>,
389 sws: super::playback_inner::SwsRgbaConverter,
390 rgba_buf: Vec<u8>,
391 active_path: PathBuf,
392 current_pts: Arc<AtomicU64>,
393 paused: Arc<AtomicBool>,
394 stopped: Arc<AtomicBool>,
395 av_offset_ms: i64,
396 rate: f64,
397 duration_millis: u64,
398 frame_cache: Option<FrameCache>,
399 hw_accel: HardwareAccel,
400}
401
402impl PlayerRunner {
403 pub fn set_sink(&mut self, sink: Box<dyn FrameSink>) {
405 self.sink = Some(sink);
406 }
407
408 pub fn set_hardware_accel(&mut self, accel: HardwareAccel) -> &mut Self {
414 self.hw_accel = accel;
415 self
416 }
417
418 #[must_use]
420 pub fn active_source(&self) -> &Path {
421 &self.active_path
422 }
423
424 #[must_use]
433 pub fn with_frame_cache_budget(mut self, bytes: usize) -> Self {
434 self.frame_cache = Some(FrameCache::new(bytes));
435 self
436 }
437
438 #[must_use]
440 pub fn duration(&self) -> Option<Duration> {
441 if self.duration_millis == u64::MAX {
442 None
443 } else {
444 Some(Duration::from_millis(self.duration_millis))
445 }
446 }
447
448 pub fn use_proxy_if_available(&mut self, proxy_dir: &Path) -> bool {
455 let stem = self
456 .path
457 .file_stem()
458 .and_then(|s| s.to_str())
459 .unwrap_or("output")
460 .to_owned();
461
462 for suffix in ["half", "quarter", "eighth"] {
463 let candidate = proxy_dir.join(format!("{stem}_proxy_{suffix}.mp4"));
464 if candidate.exists() {
465 match self.activate_proxy(&candidate) {
466 Ok(()) => {
467 log::debug!("proxy activated path={}", candidate.display());
468 return true;
469 }
470 Err(e) => {
471 log::warn!(
472 "proxy activation failed path={} error={e}",
473 candidate.display()
474 );
475 }
476 }
477 }
478 }
479 false
480 }
481
482 #[allow(clippy::too_many_lines)]
500 pub fn run(mut self) -> Result<(), PreviewError> {
501 let fps = self.fps.max(1.0);
502 let frame_period = Duration::from_secs_f64(1.0 / fps);
503
504 if self.hw_accel != HardwareAccel::Auto && self.decode_buf.is_some() {
509 match DecodeBuffer::open(&self.active_path)
510 .hardware_accel(self.hw_accel)
511 .build()
512 {
513 Ok(buf) => {
514 self.decode_buf = Some(buf);
515 }
516 Err(e) => {
517 log::warn!(
518 "hwaccel decode buffer rebuild failed accel={} error={e}",
519 self.hw_accel.name()
520 );
521 }
522 }
523 }
524
525 self.clock.reset(Duration::ZERO);
526
527 let mut prev_audio_samples: u64 = 0;
532 let mut audio_stall_frames: u32 = 0;
533
534 loop {
535 let mut pending_seek: Option<Duration> = None;
537 while let Ok(cmd) = self.cmd_rx.try_recv() {
538 match cmd {
539 PlayerCommand::Seek(pts) => pending_seek = Some(pts),
540 PlayerCommand::Play => {
541 self.stopped.store(false, Ordering::Release);
542 self.paused.store(false, Ordering::Release);
543 if self.rate > 0.0 {
548 let pts =
549 Duration::from_micros(self.current_pts.load(Ordering::Relaxed));
550 if self.clock.current_pts().saturating_sub(pts)
551 > Duration::from_millis(100)
552 {
553 self.clock.reset(pts);
554 self.restart_audio_from(pts);
555 }
556 }
557 }
558 PlayerCommand::Pause => {
559 self.paused.store(true, Ordering::Release);
560 }
561 PlayerCommand::Stop => {
562 self.stopped.store(true, Ordering::Release);
563 }
564 PlayerCommand::SetRate(r) => {
565 if r != 0.0 {
566 let was_negative = self.rate < 0.0;
567 self.rate = r;
568 if r > 0.0 {
569 self.clock.set_rate(r);
570 if was_negative {
576 let pts = Duration::from_micros(
577 self.current_pts.load(Ordering::Relaxed),
578 );
579 self.clock.reset(pts);
580 if let Some(buf) = self.decode_buf.as_mut()
584 && let Err(e) = buf.seek_coarse(pts)
585 {
586 log::warn!(
587 "reverse→forward seek failed pts={pts:?} \
588 error={e}"
589 );
590 }
591 self.restart_audio_from(pts);
592 }
593 } else {
594 if let Some(cancel) = &self.audio_cancel {
597 cancel.store(true, Ordering::Release);
598 }
599 if let Some(buf) = &self.audio_buf {
600 buf.lock()
601 .unwrap_or_else(std::sync::PoisonError::into_inner)
602 .clear();
603 }
604 }
605 }
606 }
607 PlayerCommand::SetAvOffset(ms) => {
608 const MAX_OFFSET_MS: i64 = 5_000;
609 self.av_offset_ms = ms.clamp(-MAX_OFFSET_MS, MAX_OFFSET_MS);
610 }
611 #[cfg(feature = "timeline")]
612 PlayerCommand::UpdateLayout(_) => {}
613 }
614 }
615
616 let had_seek = pending_seek.is_some();
618 if let Some(pts) = pending_seek {
619 if let Some(cache) = &mut self.frame_cache {
621 let in_range = cache
622 .pts_range()
623 .is_some_and(|(lo, hi)| pts >= lo && pts <= hi);
624 if !in_range {
625 cache.invalidate();
626 }
627 }
628 if let Some(buf) = self.decode_buf.as_mut() {
629 buf.seek(pts)?;
630 }
631 self.clock.reset(pts);
632 self.restart_audio_from(pts);
633 let _ = self.event_tx.try_send(PlayerEvent::SeekCompleted(pts));
634 }
635
636 if had_seek
639 && self.paused.load(Ordering::Acquire)
640 && let Some(buf) = self.decode_buf.as_mut()
641 {
642 let deadline = std::time::Instant::now() + Duration::from_millis(300);
643 loop {
644 match buf.pop_frame() {
645 FrameResult::Frame(f) => {
646 self.present_frame(&f);
647 let pts = f.timestamp().as_duration();
648 let _ = self.event_tx.try_send(PlayerEvent::PositionUpdate(pts));
649 break;
650 }
651 FrameResult::Seeking(_) => {
652 if std::time::Instant::now() > deadline {
653 break;
654 }
655 thread::sleep(Duration::from_millis(2));
656 }
657 FrameResult::Eof => break,
658 }
659 }
660 }
661
662 if let Some(buf) = self.decode_buf.as_ref() {
664 while let Ok(msg) = buf.error_events().try_recv() {
665 let _ = self.event_tx.try_send(PlayerEvent::Error(msg));
666 }
667 }
668
669 if self.stopped.load(Ordering::Acquire) {
670 break;
671 }
672 if self.paused.load(Ordering::Acquire) {
673 thread::sleep(Duration::from_millis(5));
674 continue;
675 }
676
677 if self.rate < 0.0 {
679 if let Some(buf) = self.decode_buf.as_mut() {
680 let current = Duration::from_micros(self.current_pts.load(Ordering::Relaxed));
681 let step =
683 Duration::from_secs_f64(self.rate.abs() / fps.max(f64::MIN_POSITIVE));
684 let target = current.saturating_sub(step);
685
686 if buf.seek_coarse(target).is_err() {
687 break;
688 }
689
690 let deadline = std::time::Instant::now() + Duration::from_millis(300);
692 let frame = loop {
693 match buf.pop_frame() {
694 FrameResult::Frame(f) => break Some(f),
695 FrameResult::Seeking(_) => {
696 if std::time::Instant::now() > deadline {
697 break None;
698 }
699 thread::sleep(Duration::from_millis(2));
700 }
701 FrameResult::Eof => break None,
702 }
703 };
704
705 if let Some(f) = frame {
706 self.present_frame(&f);
707 let pts = f.timestamp().as_duration();
708 let _ = self.event_tx.try_send(PlayerEvent::PositionUpdate(pts));
709 }
710
711 if target == Duration::ZERO {
712 self.paused.store(true, Ordering::Release);
714 }
715 }
716 thread::sleep(frame_period);
717 continue;
718 }
719
720 if self.decode_buf.is_none() {
722 let poll_secs =
723 (10.0_f64 / self.rate.max(f64::MIN_POSITIVE)).clamp(1.0, 50.0) / 1_000.0;
724 thread::sleep(Duration::from_secs_f64(poll_secs));
725 if let Some(audio_buf) = &self.audio_buf {
726 let empty = audio_buf
727 .lock()
728 .unwrap_or_else(std::sync::PoisonError::into_inner)
729 .is_empty();
730 if empty
731 && self
732 .audio_handle
733 .as_ref()
734 .is_none_or(JoinHandle::is_finished)
735 {
736 break;
737 }
738 } else {
739 break;
740 }
741 continue;
742 }
743
744 let current = self.clock.current_pts();
746 let cache_hit = self
747 .frame_cache
748 .as_ref()
749 .and_then(|c| c.get(current))
750 .map(|f| (f.rgba.clone(), f.width, f.height));
751 if let Some((rgba, width, height)) = cache_hit {
752 if let Some(sink) = self.sink.as_mut() {
753 sink.push_frame(&rgba, width, height, current);
754 }
755 self.current_pts.store(
756 u64::try_from(current.as_micros()).unwrap_or(u64::MAX),
757 Ordering::Relaxed,
758 );
759 let _ = self.event_tx.try_send(PlayerEvent::PositionUpdate(current));
760 continue;
761 }
762
763 let pop_result = if let Some(buf) = self.decode_buf.as_mut() {
765 buf.pop_frame()
766 } else {
767 FrameResult::Eof
768 };
769
770 match pop_result {
771 FrameResult::Eof => break,
772 FrameResult::Seeking(last) => {
773 if let Some(ref f) = last {
774 self.present_frame(f);
775 }
776 }
777 FrameResult::Frame(frame) => {
778 if self.clock.should_sync() {
779 let video_pts = if frame.timestamp().is_valid() {
780 frame.timestamp().as_duration()
781 } else {
782 Duration::ZERO
783 };
784
785 let offset_ms = self.av_offset_ms;
786 let offset = Duration::from_millis(offset_ms.unsigned_abs());
787 let adjusted_video_pts = if offset_ms >= 0 {
788 video_pts.saturating_sub(offset)
789 } else {
790 video_pts + offset
791 };
792
793 let clock_pts = self.clock.current_pts();
794 let diff = adjusted_video_pts.as_secs_f64() - clock_pts.as_secs_f64();
795 let fp = frame_period.as_secs_f64();
796
797 if diff > fp {
798 let sleep_secs =
799 (diff - fp / 2.0).max(0.0) / self.rate.max(f64::MIN_POSITIVE);
800 let max_sleep = fp / self.rate.max(f64::MIN_POSITIVE);
805 thread::sleep(Duration::from_secs_f64(sleep_secs.min(max_sleep)));
806 } else if diff < -fp {
807 log::debug!(
808 "dropped late frame video_pts={video_pts:?} \
809 clock_pts={clock_pts:?}"
810 );
811 continue;
812 }
813 }
814
815 self.present_frame(&frame);
816 let pts = frame.timestamp().as_duration();
817 let _ = self.event_tx.try_send(PlayerEvent::PositionUpdate(pts));
818
819 self.clock.activate_fallback_if_no_audio(pts);
824
825 let cur_audio = self.clock.audio_samples_snapshot();
830 if cur_audio > 0 && cur_audio == prev_audio_samples {
831 audio_stall_frames = audio_stall_frames.saturating_add(1);
832 if audio_stall_frames == AUDIO_STALL_FRAMES {
833 self.clock.rearm_fallback_at(pts);
834 }
835 } else {
836 prev_audio_samples = cur_audio;
837 audio_stall_frames = 0;
838 }
839
840 if let Some(cache) = &mut self.frame_cache
842 && !self.rgba_buf.is_empty()
843 {
844 cache.insert(pts, self.rgba_buf.clone(), frame.width(), frame.height());
845 }
846 }
847 }
848 }
849
850 let _ = self.event_tx.try_send(PlayerEvent::Eof);
851 if let Some(sink) = self.sink.as_mut() {
852 sink.flush();
853 }
854 Ok(())
855 }
856
857 fn present_frame(&mut self, frame: &ff_format::VideoFrame) {
858 let pts = frame.timestamp().as_duration();
859 self.current_pts.store(
860 u64::try_from(pts.as_micros()).unwrap_or(u64::MAX),
861 Ordering::Relaxed,
862 );
863 let Some(sink) = self.sink.as_mut() else {
864 return;
865 };
866 let width = frame.width();
867 let height = frame.height();
868 if self.sws.convert(frame, &mut self.rgba_buf) {
869 sink.push_frame(&self.rgba_buf, width, height, pts);
870 }
871 }
872
873 fn restart_audio_from(&mut self, pts: Duration) {
874 if let Some(buf) = &self.audio_buf {
875 buf.lock()
876 .unwrap_or_else(std::sync::PoisonError::into_inner)
877 .clear();
878 }
879 if let Some(cancel) = &self.audio_cancel {
880 cancel.store(true, Ordering::Release);
881 }
882 drop(self.audio_handle.take());
883 if let Some(buf) = &self.audio_buf {
884 let new_cancel = Arc::new(AtomicBool::new(false));
885 let handle = spawn_audio_thread(
886 self.active_path.clone(),
887 pts,
888 Arc::clone(buf),
889 Arc::clone(&new_cancel),
890 );
891 self.audio_cancel = Some(new_cancel);
892 self.audio_handle = Some(handle);
893 }
894 }
895
896 fn activate_proxy(&mut self, proxy_path: &Path) -> Result<(), PreviewError> {
897 let info = ff_probe::open(proxy_path)?;
898 let fps = info.frame_rate().unwrap_or(30.0).max(1.0);
899 let decode_buf = DecodeBuffer::open(proxy_path)
900 .hardware_accel(self.hw_accel)
901 .build()?;
902
903 if let Some(cancel) = &self.audio_cancel {
904 cancel.store(true, Ordering::Release);
905 }
906 if let Some(buf) = &self.audio_buf {
907 buf.lock()
908 .unwrap_or_else(std::sync::PoisonError::into_inner)
909 .clear();
910 }
911 drop(self.audio_handle.take());
912
913 let (clock, audio_buf, audio_cancel, audio_handle) = if info.has_audio() {
914 let buf = Arc::new(Mutex::new(VecDeque::<f32>::new()));
915 let cancel = Arc::new(AtomicBool::new(false));
916 let handle = spawn_audio_thread(
917 proxy_path.to_path_buf(),
918 Duration::ZERO,
919 Arc::clone(&buf),
920 Arc::clone(&cancel),
921 );
922 let clock = MasterClock::Audio {
923 samples_consumed: Arc::new(AtomicU64::new(0)),
924 sample_rate: DECODED_SAMPLE_RATE,
925 rate: 1.0,
926 samples_base: 0,
927 pts_base: Duration::ZERO,
928 fallback: None,
929 };
930 (clock, Some(buf), Some(cancel), Some(handle))
931 } else {
932 log::debug!(
933 "proxy has no audio, using system clock path={}",
934 proxy_path.display()
935 );
936 let clock = MasterClock::System {
937 started_at: Instant::now(),
938 base_pts: Duration::ZERO,
939 rate: 1.0,
940 };
941 (clock, None, None, None)
942 };
943
944 self.active_path = proxy_path.to_path_buf();
945 self.fps = fps;
946 self.decode_buf = Some(decode_buf);
947 self.clock = clock;
948 self.audio_buf = audio_buf;
949 self.audio_cancel = audio_cancel;
950 self.audio_handle = audio_handle;
951 Ok(())
952 }
953}
954
955impl Drop for PlayerRunner {
956 fn drop(&mut self) {
957 if let Some(cancel) = &self.audio_cancel {
958 cancel.store(true, Ordering::Release);
959 }
960 if let Some(h) = self.audio_handle.take() {
961 let _ = h.join();
962 }
963 }
964}
965
966pub struct PreviewPlayer {
991 path: PathBuf,
992 decode_buf: Option<DecodeBuffer>,
994 fps: f64,
995 clock: Option<MasterClock>,
997 audio_buf: Option<Arc<Mutex<VecDeque<f32>>>>,
998 audio_cancel: Option<Arc<AtomicBool>>,
999 audio_handle: Option<JoinHandle<()>>,
1000 duration_millis: u64,
1001 active_path: PathBuf,
1002}
1003
1004impl PreviewPlayer {
1005 pub fn open(path: impl AsRef<Path>) -> Result<Self, PreviewError> {
1016 let path = path.as_ref();
1017 let info = ff_probe::open(path)?;
1018
1019 if !info.has_video() && !info.has_audio() {
1020 return Err(PreviewError::Ffmpeg {
1021 code: -1,
1022 message: "file has neither a video nor an audio stream".into(),
1023 });
1024 }
1025
1026 let fps = info.frame_rate().unwrap_or(30.0).max(1.0);
1027
1028 let d = info.duration();
1029 let duration_millis = if d.is_zero() {
1030 u64::MAX
1031 } else {
1032 u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
1033 };
1034
1035 let clock = if info.has_audio() {
1036 MasterClock::Audio {
1037 samples_consumed: Arc::new(AtomicU64::new(0)),
1038 sample_rate: DECODED_SAMPLE_RATE,
1039 rate: 1.0,
1040 samples_base: 0,
1041 pts_base: Duration::ZERO,
1042 fallback: None,
1043 }
1044 } else {
1045 log::debug!(
1046 "using system clock fallback path={} no_audio=true",
1047 path.display()
1048 );
1049 MasterClock::System {
1050 started_at: Instant::now(),
1051 base_pts: Duration::ZERO,
1052 rate: 1.0,
1053 }
1054 };
1055
1056 let decode_buf = if info.has_video() {
1057 Some(DecodeBuffer::open(path).build()?)
1058 } else {
1059 log::debug!(
1060 "audio-only file; skipping video decode buffer path={}",
1061 path.display()
1062 );
1063 None
1064 };
1065
1066 let (audio_buf, audio_cancel, audio_handle) = if let MasterClock::Audio { .. } = &clock {
1067 let buf = Arc::new(Mutex::new(VecDeque::<f32>::new()));
1068 let cancel = Arc::new(AtomicBool::new(false));
1069 let handle = spawn_audio_thread(
1070 path.to_path_buf(),
1071 Duration::ZERO,
1072 Arc::clone(&buf),
1073 Arc::clone(&cancel),
1074 );
1075 (Some(buf), Some(cancel), Some(handle))
1076 } else {
1077 (None, None, None)
1078 };
1079
1080 Ok(PreviewPlayer {
1081 path: path.to_path_buf(),
1082 decode_buf,
1083 fps,
1084 clock: Some(clock),
1085 audio_buf,
1086 audio_cancel,
1087 audio_handle,
1088 duration_millis,
1089 active_path: path.to_path_buf(),
1090 })
1091 }
1092
1093 #[must_use]
1105 #[allow(clippy::expect_used)]
1106 pub fn split(mut self) -> (PlayerRunner, PlayerHandle) {
1107 let current_pts = Arc::new(AtomicU64::new(0));
1108 let paused = Arc::new(AtomicBool::new(false));
1109 let stopped = Arc::new(AtomicBool::new(false));
1110 let (cmd_tx, cmd_rx) = mpsc::sync_channel(CHANNEL_CAP);
1111 let (event_tx, event_rx) = mpsc::sync_channel(CHANNEL_CAP);
1112
1113 let clock = self.clock.take().expect("clock consumed before split");
1114 let samples_consumed = match &clock {
1115 MasterClock::Audio {
1116 samples_consumed, ..
1117 } => Some(Arc::clone(samples_consumed)),
1118 MasterClock::System { .. } => None,
1119 };
1120
1121 let audio_buf_for_handle = self.audio_buf.clone();
1122 let duration_millis = self.duration_millis;
1123
1124 let runner = PlayerRunner {
1125 path: self.path.clone(),
1126 cmd_rx,
1127 event_tx,
1128 decode_buf: self.decode_buf.take(),
1129 fps: self.fps,
1130 sink: None,
1131 clock,
1132 audio_buf: self.audio_buf.take(),
1133 audio_cancel: self.audio_cancel.take(),
1134 audio_handle: self.audio_handle.take(),
1135 sws: super::playback_inner::SwsRgbaConverter::new(),
1136 rgba_buf: Vec::new(),
1137 active_path: self.active_path.clone(),
1138 current_pts: Arc::clone(¤t_pts),
1139 paused: Arc::clone(&paused),
1140 stopped: Arc::clone(&stopped),
1141 av_offset_ms: 0,
1142 rate: 1.0,
1143 duration_millis,
1144 frame_cache: None,
1145 hw_accel: HardwareAccel::Auto,
1146 };
1147
1148 let handle = PlayerHandle {
1149 cmd_tx,
1150 event_rx: Arc::new(Mutex::new(event_rx)),
1151 current_pts,
1152 audio_buf: audio_buf_for_handle,
1153 samples_consumed,
1154 audio_mixer: None,
1155 paused,
1156 stopped,
1157 duration_millis,
1158 };
1159
1160 (runner, handle)
1161 }
1162}
1163
1164impl Drop for PreviewPlayer {
1165 fn drop(&mut self) {
1166 if let Some(cancel) = &self.audio_cancel {
1167 cancel.store(true, Ordering::Release);
1168 }
1169 if let Some(h) = self.audio_handle.take() {
1170 let _ = h.join();
1171 }
1172 }
1173}
1174
1175fn spawn_audio_thread(
1178 path: PathBuf,
1179 start_pts: Duration,
1180 buf: Arc<Mutex<VecDeque<f32>>>,
1181 cancel: Arc<AtomicBool>,
1182) -> JoinHandle<()> {
1183 thread::spawn(move || {
1184 let mut decoder = match AudioDecoder::open(&path)
1185 .output_format(SampleFormat::F32)
1186 .output_sample_rate(DECODED_SAMPLE_RATE)
1187 .output_channels(2)
1188 .build()
1189 {
1190 Ok(d) => d,
1191 Err(e) => {
1192 log::warn!("audio decode thread open failed error={e}");
1193 return;
1194 }
1195 };
1196
1197 if start_pts != Duration::ZERO
1198 && let Err(e) = decoder.seek(start_pts, SeekMode::Backward)
1199 {
1200 log::warn!("audio seek failed pts={start_pts:?} error={e}");
1201 }
1202
1203 loop {
1204 if cancel.load(Ordering::Acquire) {
1205 break;
1206 }
1207
1208 match decoder.decode_one() {
1209 Ok(Some(frame)) => {
1210 let samples = super::playback_inner::audio_frame_to_f32(&frame);
1211 let mut offset = 0;
1217 while offset < samples.len() {
1218 if cancel.load(Ordering::Acquire) {
1219 return;
1220 }
1221 let mut guard = buf
1222 .lock()
1223 .unwrap_or_else(std::sync::PoisonError::into_inner);
1224 let space = AUDIO_MAX_BUF.saturating_sub(guard.len());
1225 if space == 0 {
1226 drop(guard);
1227 thread::sleep(Duration::from_millis(1));
1228 continue;
1229 }
1230 let take = space.min(samples.len() - offset);
1231 guard.extend(samples[offset..offset + take].iter().copied());
1232 offset += take;
1233 }
1234 }
1235 Ok(None) => break,
1236 Err(e) => {
1237 log::warn!("audio decode error error={e}");
1238 break;
1239 }
1240 }
1241 }
1242 })
1243}
1244
1245#[cfg(test)]
1248mod tests {
1249 use super::*;
1250
1251 fn test_video_path() -> PathBuf {
1252 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/video/gameplay.mp4")
1253 }
1254
1255 fn test_audio_path() -> PathBuf {
1256 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/audio/konekonoosanpo.mp3")
1257 }
1258
1259 #[test]
1262 fn preview_player_open_should_fail_for_nonexistent_file() {
1263 let result = PreviewPlayer::open("nonexistent_preview.mp4");
1264 assert!(
1265 result.is_err(),
1266 "open() must return Err for a non-existent file"
1267 );
1268 }
1269
1270 #[test]
1273 fn player_handle_play_pause_should_update_paused_flag_immediately() {
1274 let path = test_video_path();
1275 let (_runner, handle) = match PreviewPlayer::open(&path) {
1276 Ok(p) => p.split(),
1277 Err(e) => {
1278 println!("skipping: video file not available: {e}");
1279 return;
1280 }
1281 };
1282
1283 assert!(!handle.paused.load(Ordering::Relaxed));
1284 assert!(!handle.stopped.load(Ordering::Relaxed));
1285
1286 handle.pause();
1287 assert!(handle.paused.load(Ordering::Relaxed));
1288
1289 handle.play();
1290 assert!(!handle.paused.load(Ordering::Relaxed));
1291 assert!(!handle.stopped.load(Ordering::Relaxed));
1292
1293 handle.stop();
1294 assert!(handle.stopped.load(Ordering::Relaxed));
1295 }
1296
1297 #[test]
1300 fn player_runner_run_should_deliver_frames_to_sink() {
1301 struct CountSink(Arc<Mutex<usize>>);
1302 impl FrameSink for CountSink {
1303 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1304 *self
1305 .0
1306 .lock()
1307 .unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
1308 }
1309 }
1310
1311 let path = test_video_path();
1312 let (mut runner, _handle) = match PreviewPlayer::open(&path) {
1313 Ok(p) => p.split(),
1314 Err(e) => {
1315 println!("skipping: video file not available: {e}");
1316 return;
1317 }
1318 };
1319
1320 let count = Arc::new(Mutex::new(0usize));
1321 runner.set_sink(Box::new(CountSink(Arc::clone(&count))));
1322
1323 match runner.run() {
1324 Ok(()) => {}
1325 Err(e) => {
1326 println!("skipping: run() error: {e}");
1327 return;
1328 }
1329 }
1330
1331 let frames = *count
1332 .lock()
1333 .unwrap_or_else(std::sync::PoisonError::into_inner);
1334 assert!(
1335 frames > 0,
1336 "run() must deliver at least one frame to the sink"
1337 );
1338 }
1339
1340 #[test]
1343 fn pop_audio_samples_should_return_empty_when_paused() {
1344 let path = test_video_path();
1345 let (_runner, handle) = match PreviewPlayer::open(&path) {
1346 Ok(p) => p.split(),
1347 Err(e) => {
1348 println!("skipping: video file not available: {e}");
1349 return;
1350 }
1351 };
1352 handle.pause();
1353 let samples = handle.pop_audio_samples(1024);
1354 assert!(
1355 samples.is_empty(),
1356 "pop_audio_samples() must return empty while paused"
1357 );
1358 }
1359
1360 #[test]
1361 fn pop_audio_samples_should_return_empty_when_stopped() {
1362 let path = test_video_path();
1363 let (_runner, handle) = match PreviewPlayer::open(&path) {
1364 Ok(p) => p.split(),
1365 Err(e) => {
1366 println!("skipping: video file not available: {e}");
1367 return;
1368 }
1369 };
1370 handle.stop();
1371 let samples = handle.pop_audio_samples(1024);
1372 assert!(
1373 samples.is_empty(),
1374 "pop_audio_samples() must return empty while stopped"
1375 );
1376 }
1377
1378 #[test]
1379 fn pop_audio_samples_should_return_empty_for_zero_n_samples() {
1380 let path = test_video_path();
1381 let (_runner, handle) = match PreviewPlayer::open(&path) {
1382 Ok(p) => p.split(),
1383 Err(e) => {
1384 println!("skipping: video file not available: {e}");
1385 return;
1386 }
1387 };
1388 handle.play();
1389 let samples = handle.pop_audio_samples(0);
1390 assert!(
1391 samples.is_empty(),
1392 "pop_audio_samples(0) must always return empty"
1393 );
1394 }
1395
1396 #[test]
1397 fn pop_audio_samples_should_be_callable_via_cloned_handle() {
1398 let path = test_video_path();
1399 let (_runner, handle) = match PreviewPlayer::open(&path) {
1400 Ok(p) => p.split(),
1401 Err(e) => {
1402 println!("skipping: video file not available: {e}");
1403 return;
1404 }
1405 };
1406 let shared = handle.clone();
1407 let _samples = shared.pop_audio_samples(0);
1408 }
1409
1410 #[test]
1411 fn pop_audio_samples_clock_increment_should_equal_half_sample_count() {
1412 let stereo_samples: usize = 9_600;
1413 let expected_frames: u64 = (stereo_samples / 2) as u64;
1414 assert_eq!(
1415 expected_frames, 4_800,
1416 "9600 stereo samples must yield 4800 clock frames"
1417 );
1418 let pts = Duration::from_secs_f64(f64::from(48_000u32).recip() * expected_frames as f64);
1419 assert!(
1420 (pts.as_secs_f64() - 0.1).abs() < 1e-6,
1421 "4800 frames at 48 kHz must equal 100 ms; got {pts:?}"
1422 );
1423 }
1424
1425 #[test]
1428 fn current_pts_should_return_zero_before_first_frame() {
1429 let path = test_video_path();
1430 let (_runner, handle) = match PreviewPlayer::open(&path) {
1431 Ok(p) => p.split(),
1432 Err(e) => {
1433 println!("skipping: video file not available: {e}");
1434 return;
1435 }
1436 };
1437 assert_eq!(
1438 handle.current_pts(),
1439 Duration::ZERO,
1440 "current_pts() must be ZERO before any frame is presented"
1441 );
1442 }
1443
1444 #[test]
1445 fn duration_should_return_some_for_file_with_known_duration() {
1446 let path = test_video_path();
1447 let (_runner, handle) = match PreviewPlayer::open(&path) {
1448 Ok(p) => p.split(),
1449 Err(e) => {
1450 println!("skipping: video file not available: {e}");
1451 return;
1452 }
1453 };
1454 assert!(
1455 handle.duration().is_some(),
1456 "duration() must return Some for a file with a known container duration"
1457 );
1458 let d = handle.duration().unwrap();
1459 assert!(
1460 d > Duration::ZERO,
1461 "duration() must be positive for a valid media file; got {d:?}"
1462 );
1463 }
1464
1465 #[test]
1466 fn duration_should_return_none_when_duration_millis_is_sentinel() {
1467 let sentinel = u64::MAX;
1468 let result: Option<Duration> = if sentinel == u64::MAX {
1469 None
1470 } else {
1471 Some(Duration::from_millis(sentinel))
1472 };
1473 assert!(result.is_none(), "sentinel u64::MAX must map to None");
1474
1475 let valid = 5_000u64;
1476 let result: Option<Duration> = if valid == u64::MAX {
1477 None
1478 } else {
1479 Some(Duration::from_millis(valid))
1480 };
1481 assert_eq!(result, Some(Duration::from_secs(5)));
1482 }
1483
1484 #[test]
1485 fn current_pts_should_advance_after_frames_are_presented() {
1486 struct PtsSink(Arc<Mutex<Option<Duration>>>);
1487 impl FrameSink for PtsSink {
1488 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, pts: Duration) {
1489 *self
1490 .0
1491 .lock()
1492 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(pts);
1493 }
1494 }
1495
1496 let path = test_video_path();
1497 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1498 Ok(p) => p.split(),
1499 Err(e) => {
1500 println!("skipping: video file not available: {e}");
1501 return;
1502 }
1503 };
1504
1505 let last_pts = Arc::new(Mutex::new(None::<Duration>));
1506 runner.set_sink(Box::new(PtsSink(Arc::clone(&last_pts))));
1507 let _ = runner.run();
1508
1509 let sink_pts = last_pts
1510 .lock()
1511 .unwrap_or_else(std::sync::PoisonError::into_inner)
1512 .unwrap_or(Duration::ZERO);
1513 let player_pts = handle.current_pts();
1514 let diff = sink_pts.abs_diff(player_pts);
1515 assert!(
1516 diff <= Duration::from_millis(1),
1517 "current_pts() must be within 1 ms of the last sink PTS; \
1518 player_pts={player_pts:?} sink_pts={sink_pts:?} diff={diff:?}"
1519 );
1520 }
1521
1522 #[test]
1525 fn seek_coarse_should_delegate_to_decode_buffer() {
1526 let path = test_video_path();
1527 let (runner, handle) = match PreviewPlayer::open(&path) {
1528 Ok(p) => p.split(),
1529 Err(e) => {
1530 println!("skipping: video file not available: {e}");
1531 return;
1532 }
1533 };
1534
1535 let target = Duration::from_secs(1);
1536 handle.seek(target);
1537
1538 let handle_thread = handle.clone();
1540 thread::spawn(move || {
1541 thread::sleep(Duration::from_millis(500));
1542 handle_thread.stop();
1543 });
1544
1545 match runner.run() {
1546 Ok(()) => {}
1547 Err(e) => {
1548 println!("skipping: run() error: {e}");
1549 }
1550 }
1551 }
1552
1553 #[test]
1556 fn use_proxy_if_available_should_return_false_when_no_proxy_in_dir() {
1557 let path = test_video_path();
1558 let (mut runner, _handle) = match PreviewPlayer::open(&path) {
1559 Ok(p) => p.split(),
1560 Err(e) => {
1561 println!("skipping: video file not available: {e}");
1562 return;
1563 }
1564 };
1565 let tmp = std::env::temp_dir().join("ff_preview_no_proxy_dir_test");
1566 let _ = std::fs::create_dir_all(&tmp);
1567 let found = runner.use_proxy_if_available(&tmp);
1568 assert!(
1569 !found,
1570 "must return false when no proxy files exist in the directory"
1571 );
1572 }
1573
1574 #[test]
1575 fn active_source_should_return_original_path_before_proxy_activation() {
1576 let path = test_video_path();
1577 let (runner, _handle) = match PreviewPlayer::open(&path) {
1578 Ok(p) => p.split(),
1579 Err(e) => {
1580 println!("skipping: video file not available: {e}");
1581 return;
1582 }
1583 };
1584 assert_eq!(
1585 runner.active_source(),
1586 path.as_path(),
1587 "active_source() must equal the original path before any proxy activation"
1588 );
1589 }
1590
1591 #[test]
1594 fn set_rate_should_accept_positive_value() {
1595 let path = test_video_path();
1596 let (_runner, handle) = match PreviewPlayer::open(&path) {
1597 Ok(p) => p.split(),
1598 Err(e) => {
1599 println!("skipping: video file not available: {e}");
1600 return;
1601 }
1602 };
1603 handle.set_rate(2.0);
1605 handle.set_rate(0.5);
1606 }
1607
1608 #[test]
1609 fn set_av_offset_default_should_be_zero() {
1610 use std::sync::atomic::{AtomicI64, Ordering};
1611 let offset = AtomicI64::new(0);
1612 assert_eq!(offset.load(Ordering::Relaxed), 0);
1613 }
1614
1615 #[test]
1616 fn positive_av_offset_should_reduce_adjusted_video_pts() {
1617 let video_pts = Duration::from_millis(1_000);
1618 let offset_ms: i64 = 200;
1619 let adjusted = if offset_ms >= 0 {
1620 let offset = Duration::from_millis(offset_ms as u64);
1621 video_pts.saturating_sub(offset)
1622 } else {
1623 let offset = Duration::from_millis(offset_ms.unsigned_abs());
1624 video_pts + offset
1625 };
1626 assert_eq!(
1627 adjusted,
1628 Duration::from_millis(800),
1629 "positive offset must reduce adjusted_video_pts by offset amount"
1630 );
1631 }
1632
1633 #[test]
1634 fn negative_av_offset_should_increase_adjusted_video_pts() {
1635 let video_pts = Duration::from_millis(1_000);
1636 let offset_ms: i64 = -200;
1637 let adjusted = if offset_ms >= 0 {
1638 let offset = Duration::from_millis(offset_ms as u64);
1639 video_pts.saturating_sub(offset)
1640 } else {
1641 let offset = Duration::from_millis(offset_ms.unsigned_abs());
1642 video_pts + offset
1643 };
1644 assert_eq!(
1645 adjusted,
1646 Duration::from_millis(1_200),
1647 "negative offset must increase adjusted_video_pts by offset amount"
1648 );
1649 }
1650
1651 #[test]
1652 fn positive_av_offset_at_zero_pts_should_saturate_to_zero() {
1653 let video_pts = Duration::ZERO;
1654 let offset_ms: i64 = 100;
1655 let adjusted = video_pts.saturating_sub(Duration::from_millis(offset_ms as u64));
1656 assert_eq!(
1657 adjusted,
1658 Duration::ZERO,
1659 "saturating_sub on zero pts must clamp to zero not underflow"
1660 );
1661 }
1662
1663 #[test]
1666 fn audio_sample_rate_should_return_some_48_khz_for_audio_only_file() {
1667 let path = test_audio_path();
1668 let (_runner, handle) = match PreviewPlayer::open(&path) {
1669 Ok(p) => p.split(),
1670 Err(e) => {
1671 println!("skipping: audio file not available: {e}");
1672 return;
1673 }
1674 };
1675 assert_eq!(
1676 handle.audio_sample_rate(),
1677 Some(DECODED_SAMPLE_RATE),
1678 "audio_sample_rate() must return Some(48_000) for a file with an audio stream"
1679 );
1680 }
1681
1682 #[test]
1683 fn audio_sample_rate_should_return_some_48_khz_regardless_of_source_native_rate() {
1684 let path = test_audio_path();
1689 let (_runner, handle) = match PreviewPlayer::open(&path) {
1690 Ok(p) => p.split(),
1691 Err(e) => {
1692 println!("skipping: audio file not available: {e}");
1693 return;
1694 }
1695 };
1696 if let Some(rate) = handle.audio_sample_rate() {
1697 assert_eq!(
1698 rate, DECODED_SAMPLE_RATE,
1699 "audio_sample_rate() must equal DECODED_SAMPLE_RATE=48 000 regardless of source"
1700 );
1701 }
1702 }
1703
1704 #[test]
1705 fn audio_sample_rate_should_return_none_when_no_audio_buf_present() {
1706 let buf: Option<std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<f32>>>> = None;
1710 let rate: Option<u32> = buf.as_ref().map(|_| DECODED_SAMPLE_RATE);
1711 assert_eq!(
1712 rate, None,
1713 "audio_sample_rate() must return None when no audio ring buffer is present"
1714 );
1715 }
1716
1717 #[test]
1720 fn audio_only_open_should_succeed() {
1721 let path = test_audio_path();
1722 match PreviewPlayer::open(&path) {
1723 Ok(player) => {
1724 let (runner, handle) = player.split();
1725 assert!(
1727 runner.decode_buf.is_none(),
1728 "audio-only runner must have no video decode buffer"
1729 );
1730 assert!(
1732 handle.audio_buf.is_some(),
1733 "audio-only handle must have an audio ring buffer"
1734 );
1735 }
1736 Err(e) => {
1737 println!("skipping: audio file not available: {e}");
1738 }
1739 }
1740 }
1741
1742 #[test]
1743 fn audio_only_run_should_return_ok_without_video_frames() {
1744 let path = test_audio_path();
1745 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1746 Ok(p) => p.split(),
1747 Err(e) => {
1748 println!("skipping: audio file not available: {e}");
1749 return;
1750 }
1751 };
1752
1753 struct CountingSink(usize);
1754 impl FrameSink for CountingSink {
1755 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1756 self.0 += 1;
1757 }
1758 }
1759 runner.set_sink(Box::new(CountingSink(0)));
1760
1761 let handle_thread = handle.clone();
1762 thread::spawn(move || {
1763 thread::sleep(Duration::from_millis(150));
1764 handle_thread.stop();
1765 });
1766
1767 let result = runner.run();
1768 assert!(
1769 result.is_ok(),
1770 "run() on an audio-only player must return Ok; got {result:?}"
1771 );
1772 assert_eq!(
1773 handle.current_pts(),
1774 Duration::ZERO,
1775 "current_pts() must remain ZERO for audio-only playback (no video frames)"
1776 );
1777 }
1778
1779 #[test]
1780 fn audio_only_seek_should_not_fail_for_valid_target() {
1781 let path = test_audio_path();
1782 let (_runner, handle) = match PreviewPlayer::open(&path) {
1783 Ok(p) => p.split(),
1784 Err(e) => {
1785 println!("skipping: audio file not available: {e}");
1786 return;
1787 }
1788 };
1789 handle.seek(Duration::from_secs(1));
1791 }
1792
1793 #[test]
1796 #[ignore = "requires assets/video/gameplay.mp4; run with -- --include-ignored"]
1797 fn seek_should_deliver_seek_completed_event_via_poll_event() {
1798 let path = test_video_path();
1799 if !path.exists() {
1800 println!("skipping: video file not found at {}", path.display());
1801 return;
1802 }
1803
1804 let (runner, handle) = match PreviewPlayer::open(&path) {
1805 Ok(p) => p.split(),
1806 Err(e) => {
1807 println!("skipping: open failed: {e}");
1808 return;
1809 }
1810 };
1811
1812 let handle_bg = handle.clone();
1813 let bg = thread::spawn(move || {
1814 let _ = runner.run();
1815 });
1816
1817 thread::sleep(Duration::from_millis(50));
1819 let target = Duration::from_secs(1);
1820 handle.seek(target);
1821
1822 let deadline = Instant::now() + Duration::from_secs(2);
1825 let seek_result = loop {
1826 match handle.poll_event() {
1827 Some(PlayerEvent::SeekCompleted(pts)) => break Ok(pts),
1828 Some(PlayerEvent::Eof) => break Err("Eof"),
1829 Some(PlayerEvent::Error(_)) => break Err("Error"),
1830 Some(PlayerEvent::PositionUpdate(_)) => {} None => {}
1832 }
1833 if Instant::now() > deadline {
1834 break Err("timeout");
1835 }
1836 thread::sleep(Duration::from_millis(10));
1837 };
1838
1839 handle_bg.stop();
1840 let _ = bg.join();
1841
1842 match seek_result {
1843 Ok(pts) => {
1844 assert!(
1845 pts >= target.saturating_sub(Duration::from_millis(100)),
1846 "SeekCompleted pts must be near the requested target; \
1847 target={target:?} pts={pts:?}"
1848 );
1849 }
1850 Err(reason) => {
1851 panic!("SeekCompleted not received within 2 seconds: {reason}");
1852 }
1853 }
1854 }
1855
1856 #[test]
1859 fn position_update_and_error_event_variants_should_be_accessible() {
1860 let _ = PlayerEvent::PositionUpdate(Duration::ZERO);
1861 let _ = PlayerEvent::Error("test error".to_string());
1862 }
1863
1864 #[test]
1865 fn eof_event_should_be_delivered_after_run_completes() {
1866 let path = test_audio_path();
1867 let (runner, handle) = match PreviewPlayer::open(&path) {
1868 Ok(p) => p.split(),
1869 Err(e) => {
1870 println!("skipping: {e}");
1871 return;
1872 }
1873 };
1874
1875 let handle_stop = handle.clone();
1877 thread::spawn(move || {
1878 thread::sleep(Duration::from_millis(150));
1879 handle_stop.stop();
1880 });
1881
1882 let _ = runner.run();
1883 let events: Vec<_> = std::iter::from_fn(|| handle.poll_event()).collect();
1884 assert!(
1885 events.iter().any(|e| matches!(e, PlayerEvent::Eof)),
1886 "Eof event must be delivered after run() returns; collected {} events",
1887 events.len()
1888 );
1889 }
1890
1891 #[test]
1892 #[ignore = "requires assets/video/gameplay.mp4; run with -- --include-ignored"]
1893 fn position_update_should_be_emitted_for_each_video_frame() {
1894 let path =
1895 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/video/gameplay.mp4");
1896 if !path.exists() {
1897 println!("skipping: video asset not found");
1898 return;
1899 }
1900
1901 use std::sync::{Arc, Mutex};
1902 struct CountSink {
1903 count: Arc<Mutex<usize>>,
1904 max: usize,
1905 handle: PlayerHandle,
1906 }
1907 impl FrameSink for CountSink {
1908 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1909 let mut g = self
1910 .count
1911 .lock()
1912 .unwrap_or_else(std::sync::PoisonError::into_inner);
1913 *g += 1;
1914 if *g >= self.max {
1915 self.handle.stop();
1916 }
1917 }
1918 }
1919
1920 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1921 Ok(p) => p.split(),
1922 Err(e) => {
1923 println!("skipping: {e}");
1924 return;
1925 }
1926 };
1927
1928 let count = Arc::new(Mutex::new(0usize));
1929 runner.set_sink(Box::new(CountSink {
1930 count: Arc::clone(&count),
1931 max: 20,
1932 handle: handle.clone(),
1933 }));
1934 let _ = runner.run();
1935
1936 let frames = *count
1937 .lock()
1938 .unwrap_or_else(std::sync::PoisonError::into_inner);
1939 let position_updates: Vec<_> = std::iter::from_fn(|| handle.poll_event())
1940 .filter(|e| matches!(e, PlayerEvent::PositionUpdate(_)))
1941 .collect();
1942
1943 assert!(
1944 !position_updates.is_empty(),
1945 "at least one PositionUpdate event must be emitted; frames delivered={frames}"
1946 );
1947 assert!(
1948 position_updates.len() <= frames,
1949 "PositionUpdate count ({}) must not exceed frame count ({frames})",
1950 position_updates.len()
1951 );
1952 }
1953
1954 #[test]
1957 fn hardware_accel_variants_should_be_accessible_on_player_runner() {
1958 let _ = HardwareAccel::Auto;
1960 let _ = HardwareAccel::None;
1961 let _ = HardwareAccel::Nvdec;
1962 let _ = HardwareAccel::Qsv;
1963 let _ = HardwareAccel::Amf;
1964 let _ = HardwareAccel::VideoToolbox;
1965 let _ = HardwareAccel::Vaapi;
1966 }
1967
1968 #[test]
1969 fn set_hardware_accel_none_should_complete_without_error_on_audio_only_file() {
1970 let path = test_audio_path();
1974 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1975 Ok(p) => p.split(),
1976 Err(e) => {
1977 println!("skipping: audio file not available: {e}");
1978 return;
1979 }
1980 };
1981
1982 runner.set_hardware_accel(HardwareAccel::None);
1983 assert_eq!(runner.hw_accel, HardwareAccel::None);
1984
1985 let handle_stop = handle.clone();
1986 thread::spawn(move || {
1987 thread::sleep(Duration::from_millis(150));
1988 handle_stop.stop();
1989 });
1990
1991 let result = runner.run();
1992 assert!(
1993 result.is_ok(),
1994 "run() with HardwareAccel::None must return Ok; got {result:?}"
1995 );
1996 }
1997
1998 #[test]
1999 #[ignore = "requires assets/video/gameplay.mp4 and hardware decoder; run with -- --include-ignored"]
2000 fn hardware_accel_auto_should_deliver_frames_on_video_file() {
2001 let path = test_video_path();
2002 let (mut runner, handle) = match PreviewPlayer::open(&path) {
2003 Ok(p) => p.split(),
2004 Err(e) => {
2005 println!("skipping: video file not available: {e}");
2006 return;
2007 }
2008 };
2009
2010 runner.set_hardware_accel(HardwareAccel::Auto);
2011
2012 struct CountSink {
2013 count: usize,
2014 max: usize,
2015 handle: PlayerHandle,
2016 }
2017 impl FrameSink for CountSink {
2018 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
2019 self.count += 1;
2020 if self.count >= self.max {
2021 self.handle.stop();
2022 }
2023 }
2024 }
2025 runner.set_sink(Box::new(CountSink {
2026 count: 0,
2027 max: 5,
2028 handle: handle.clone(),
2029 }));
2030
2031 let result = runner.run();
2032 assert!(
2033 result.is_ok(),
2034 "run() with HardwareAccel::Auto must return Ok; got {result:?}"
2035 );
2036 assert!(
2037 handle.current_pts() > Duration::ZERO,
2038 "at least one frame must have been presented"
2039 );
2040 }
2041}