1use std::collections::VecDeque;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::sync::Mutex;
10use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
11use std::thread::{self, JoinHandle};
12use std::time::{Duration, Instant};
13
14use ff_decode::{AudioDecoder, SeekMode};
15use ff_format::SampleFormat;
16
17use super::clock::MasterClock;
18use super::decode_buffer::{DecodeBuffer, FrameResult, SeekEvent};
19use super::sink::FrameSink;
20use crate::error::PreviewError;
21
22const AUDIO_MAX_BUF: usize = 96_000;
27
28pub struct PreviewPlayer {
45 path: PathBuf,
48 decode_buf: Option<DecodeBuffer>,
51 fps: f64,
53 sink: Option<Box<dyn FrameSink>>,
56 paused: Arc<AtomicBool>,
58 stopped: Arc<AtomicBool>,
60 clock: MasterClock,
62 av_offset_ms: Arc<AtomicI64>,
67 audio_buf: Option<Arc<Mutex<VecDeque<f32>>>>,
70 audio_cancel: Option<Arc<AtomicBool>>,
73 audio_handle: Option<JoinHandle<()>>,
75 sws: super::playback_inner::SwsRgbaConverter,
78 rgba_buf: Vec<u8>,
80 active_path: PathBuf,
83 started: AtomicBool,
86 current_pts_millis: AtomicU64,
89 duration_millis: u64,
92 rate_bits: Arc<AtomicU64>,
96}
97
98impl PreviewPlayer {
99 pub fn open(path: &Path) -> Result<Self, PreviewError> {
115 let info = ff_probe::open(path)?;
116
117 if !info.has_video() && !info.has_audio() {
118 return Err(PreviewError::Ffmpeg {
119 code: -1,
120 message: "file has neither a video nor an audio stream".into(),
121 });
122 }
123
124 let fps = info.frame_rate().unwrap_or(30.0).max(1.0);
125
126 let d = info.duration();
128 let duration_millis = if d.is_zero() {
129 u64::MAX
130 } else {
131 u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
132 };
133
134 let clock = if info.has_audio() {
135 let sample_rate = info.sample_rate().unwrap_or(48_000);
136 MasterClock::Audio {
137 samples_consumed: Arc::new(AtomicU64::new(0)),
138 sample_rate,
139 }
140 } else {
141 log::debug!(
142 "using system clock fallback path={} no_audio=true",
143 path.display()
144 );
145 MasterClock::System {
146 started_at: Instant::now(),
147 base_pts: Duration::ZERO,
148 }
149 };
150
151 let decode_buf = if info.has_video() {
154 Some(DecodeBuffer::open(path).build()?)
155 } else {
156 log::debug!(
157 "audio-only file; skipping video decode buffer path={}",
158 path.display()
159 );
160 None
161 };
162
163 let (audio_buf, audio_cancel, audio_handle) = if let MasterClock::Audio { .. } = &clock {
165 let buf = Arc::new(Mutex::new(VecDeque::<f32>::new()));
166 let cancel = Arc::new(AtomicBool::new(false));
167 let handle = spawn_audio_thread(
168 path.to_path_buf(),
169 Duration::ZERO,
170 Arc::clone(&buf),
171 Arc::clone(&cancel),
172 );
173 (Some(buf), Some(cancel), Some(handle))
174 } else {
175 (None, None, None)
176 };
177
178 Ok(PreviewPlayer {
179 path: path.to_path_buf(),
180 decode_buf,
181 fps,
182 sink: None,
183 paused: Arc::new(AtomicBool::new(false)),
184 stopped: Arc::new(AtomicBool::new(false)),
185 clock,
186 av_offset_ms: Arc::new(AtomicI64::new(0)),
187 audio_buf,
188 audio_cancel,
189 audio_handle,
190 sws: super::playback_inner::SwsRgbaConverter::new(),
191 rgba_buf: Vec::new(),
192 active_path: path.to_path_buf(),
193 started: AtomicBool::new(false),
194 current_pts_millis: AtomicU64::new(0),
195 duration_millis,
196 rate_bits: Arc::new(AtomicU64::new(1.0_f64.to_bits())),
197 })
198 }
199
200 pub fn set_sink(&mut self, sink: Box<dyn FrameSink>) {
202 self.sink = Some(sink);
203 }
204
205 pub fn play(&self) {
210 self.started.store(true, Ordering::Release);
211 self.paused.store(false, Ordering::Release);
212 self.stopped.store(false, Ordering::Release);
213 }
214
215 pub fn pause(&self) {
218 self.paused.store(true, Ordering::Release);
219 }
220
221 pub fn stop(&mut self) {
225 self.stopped.store(true, Ordering::Release);
226 }
227
228 pub fn stop_handle(&self) -> Arc<AtomicBool> {
243 Arc::clone(&self.stopped)
244 }
245
246 pub fn pause_handle(&self) -> Arc<AtomicBool> {
265 Arc::clone(&self.paused)
266 }
267
268 pub fn pop_frame(&mut self) -> FrameResult {
273 match self.decode_buf.as_mut() {
274 Some(buf) => buf.pop_frame(),
275 None => FrameResult::Eof,
276 }
277 }
278
279 pub fn seek(&mut self, target_pts: Duration) -> Result<(), PreviewError> {
288 match self.decode_buf.as_mut() {
289 Some(buf) => buf.seek(target_pts),
290 None => Ok(()),
291 }
292 }
293
294 pub fn seek_coarse(&mut self, target_pts: Duration) -> Result<(), PreviewError> {
317 match self.decode_buf.as_mut() {
318 Some(buf) => buf.seek_coarse(target_pts),
319 None => Ok(()),
320 }
321 }
322
323 pub fn use_proxy_if_available(&mut self, proxy_dir: &Path) -> bool {
336 if self.started.load(Ordering::Acquire) {
337 log::warn!("use_proxy_if_available called after play; ignored");
338 return false;
339 }
340 let stem = self
341 .path
342 .file_stem()
343 .and_then(|s| s.to_str())
344 .unwrap_or("output")
345 .to_owned();
346
347 for suffix in ["half", "quarter", "eighth"] {
348 let candidate = proxy_dir.join(format!("{stem}_proxy_{suffix}.mp4"));
349 if candidate.exists() {
350 match self.activate_proxy(&candidate) {
351 Ok(()) => {
352 log::debug!("proxy activated path={}", candidate.display());
353 return true;
354 }
355 Err(e) => {
356 log::warn!(
357 "proxy activation failed path={} error={e}",
358 candidate.display()
359 );
360 }
361 }
362 }
363 }
364 false
365 }
366
367 pub fn active_source(&self) -> &Path {
370 &self.active_path
371 }
372
373 fn activate_proxy(&mut self, proxy_path: &Path) -> Result<(), PreviewError> {
376 let info = ff_probe::open(proxy_path)?;
377 let fps = info.frame_rate().unwrap_or(30.0).max(1.0);
378 let decode_buf = DecodeBuffer::open(proxy_path).build()?;
379
380 if let Some(cancel) = &self.audio_cancel {
382 cancel.store(true, Ordering::Release);
383 }
384 if let Some(buf) = &self.audio_buf {
385 buf.lock()
386 .unwrap_or_else(std::sync::PoisonError::into_inner)
387 .clear();
388 }
389 drop(self.audio_handle.take());
391
392 let (clock, audio_buf, audio_cancel, audio_handle) = if info.has_audio() {
393 let sample_rate = info.sample_rate().unwrap_or(48_000);
394 let buf = Arc::new(Mutex::new(VecDeque::<f32>::new()));
395 let cancel = Arc::new(AtomicBool::new(false));
396 let handle = spawn_audio_thread(
397 proxy_path.to_path_buf(),
398 Duration::ZERO,
399 Arc::clone(&buf),
400 Arc::clone(&cancel),
401 );
402 let clock = MasterClock::Audio {
403 samples_consumed: Arc::new(AtomicU64::new(0)),
404 sample_rate,
405 };
406 (clock, Some(buf), Some(cancel), Some(handle))
407 } else {
408 log::debug!(
409 "proxy has no audio, using system clock path={}",
410 proxy_path.display()
411 );
412 let clock = MasterClock::System {
413 started_at: Instant::now(),
414 base_pts: Duration::ZERO,
415 };
416 (clock, None, None, None)
417 };
418
419 self.active_path = proxy_path.to_path_buf();
420 self.fps = fps;
421 self.decode_buf = Some(decode_buf);
422 self.clock = clock;
423 self.audio_buf = audio_buf;
424 self.audio_cancel = audio_cancel;
425 self.audio_handle = audio_handle;
426 Ok(())
427 }
428
429 pub fn set_av_offset(&self, ms: i64) {
439 const MAX_OFFSET_MS: i64 = 5_000;
440 let clamped = if ms.abs() > MAX_OFFSET_MS {
441 log::warn!("av_offset clamped value={ms}");
442 ms.clamp(-MAX_OFFSET_MS, MAX_OFFSET_MS)
443 } else {
444 ms
445 };
446 self.av_offset_ms.store(clamped, Ordering::Relaxed);
447 }
448
449 pub fn av_offset(&self) -> i64 {
453 self.av_offset_ms.load(Ordering::Relaxed)
454 }
455
456 pub fn av_offset_handle(&self) -> Arc<AtomicI64> {
480 Arc::clone(&self.av_offset_ms)
481 }
482
483 pub fn set_rate(&self, rate: f64) {
495 if rate > 0.0 {
496 self.rate_bits.store(rate.to_bits(), Ordering::Relaxed);
497 }
498 }
499
500 pub fn rate_handle(&self) -> Arc<AtomicU64> {
523 Arc::clone(&self.rate_bits)
524 }
525
526 pub fn current_pts(&self) -> Duration {
550 Duration::from_millis(self.current_pts_millis.load(Ordering::Relaxed))
551 }
552
553 pub fn duration(&self) -> Option<Duration> {
565 if self.duration_millis == u64::MAX {
566 None
567 } else {
568 Some(Duration::from_millis(self.duration_millis))
569 }
570 }
571
572 pub fn pop_audio_samples(&self, n_samples: usize) -> Vec<f32> {
590 if self.paused.load(Ordering::Relaxed) || self.stopped.load(Ordering::Relaxed) {
591 return Vec::new();
592 }
593 let MasterClock::Audio {
594 samples_consumed, ..
595 } = &self.clock
596 else {
597 return Vec::new();
598 };
599 if n_samples == 0 {
600 return Vec::new();
601 }
602 let Some(buf) = &self.audio_buf else {
603 return Vec::new();
604 };
605 let mut guard = buf
606 .lock()
607 .unwrap_or_else(std::sync::PoisonError::into_inner);
608 let take = n_samples.min(guard.len());
609 if take == 0 {
610 return Vec::new();
611 }
612 let samples: Vec<f32> = guard.drain(..take).collect();
613 samples_consumed.fetch_add((take / 2) as u64, Ordering::Relaxed);
616 samples
617 }
618
619 pub fn run(&mut self) -> Result<(), PreviewError> {
637 let fps = self.fps.max(1.0);
638 let frame_period = Duration::from_secs_f64(1.0 / fps);
639
640 self.clock.reset(Duration::ZERO);
643
644 loop {
645 if self.stopped.load(Ordering::Acquire) {
646 break;
647 }
648 if self.paused.load(Ordering::Acquire) {
649 thread::sleep(Duration::from_millis(5));
650 continue;
651 }
652
653 if self.decode_buf.is_none() {
658 thread::sleep(Duration::from_millis(10));
659 if let Some(audio_buf) = &self.audio_buf {
660 let empty = audio_buf
661 .lock()
662 .unwrap_or_else(std::sync::PoisonError::into_inner)
663 .is_empty();
664 if empty
665 && self
666 .audio_handle
667 .as_ref()
668 .is_none_or(JoinHandle::is_finished)
669 {
670 break;
671 }
672 } else {
673 break;
675 }
676 continue;
677 }
678
679 let pop_result = if let Some(buf) = self.decode_buf.as_mut() {
682 buf.pop_frame()
683 } else {
684 FrameResult::Eof };
686 match pop_result {
687 FrameResult::Eof => break,
688 FrameResult::Seeking(last) => {
689 if let Some(ref f) = last {
690 self.present_frame(f);
691 }
692 }
694 FrameResult::Frame(frame) => {
695 let seek_pts: Vec<Duration> = match self.decode_buf.as_ref() {
699 Some(buf) => {
700 let mut v = Vec::new();
701 while let Ok(SeekEvent::Completed { pts }) =
702 buf.seek_events().try_recv()
703 {
704 v.push(pts);
705 }
706 v
707 }
708 None => Vec::new(),
709 };
710 for pts in seek_pts {
711 self.clock.reset(pts);
712 self.restart_audio_from(pts);
715 }
716
717 if self.clock.should_sync() {
718 let video_pts = if frame.timestamp().is_valid() {
719 frame.timestamp().as_duration()
720 } else {
721 Duration::ZERO
722 };
723
724 let offset_ms = self.av_offset_ms.load(Ordering::Relaxed);
726 let offset = Duration::from_millis(offset_ms.unsigned_abs());
727 let adjusted_video_pts = if offset_ms >= 0 {
728 video_pts.saturating_sub(offset)
731 } else {
732 video_pts + offset
735 };
736
737 let clock_pts = self.clock.current_pts();
738 let diff = adjusted_video_pts.as_secs_f64() - clock_pts.as_secs_f64();
739 let fp = frame_period.as_secs_f64();
740
741 if diff > fp {
742 let rate = f64::from_bits(self.rate_bits.load(Ordering::Relaxed));
745 let sleep_secs =
746 (diff - fp / 2.0).max(0.0) / rate.max(f64::MIN_POSITIVE);
747 thread::sleep(Duration::from_secs_f64(sleep_secs));
748 } else if diff < -fp {
749 log::debug!(
751 "dropped late frame video_pts={video_pts:?} \
752 clock_pts={clock_pts:?}"
753 );
754 continue;
755 }
756 }
757
758 self.present_frame(&frame);
759 }
760 }
761 }
762 if let Some(sink) = self.sink.as_mut() {
763 sink.flush();
764 }
765 Ok(())
766 }
767
768 fn present_frame(&mut self, frame: &ff_format::VideoFrame) {
770 let Some(sink) = self.sink.as_mut() else {
771 return;
772 };
773 let width = frame.width();
774 let height = frame.height();
775 let pts = frame.timestamp().as_duration();
776 self.current_pts_millis.store(
779 u64::try_from(pts.as_millis()).unwrap_or(u64::MAX),
780 Ordering::Relaxed,
781 );
782 if self.sws.convert(frame, &mut self.rgba_buf) {
783 sink.push_frame(&self.rgba_buf, width, height, pts);
784 }
785 }
786
787 fn restart_audio_from(&mut self, pts: Duration) {
794 if let Some(buf) = &self.audio_buf {
796 buf.lock()
797 .unwrap_or_else(std::sync::PoisonError::into_inner)
798 .clear();
799 }
800 if let Some(cancel) = &self.audio_cancel {
802 cancel.store(true, Ordering::Release);
803 }
804 drop(self.audio_handle.take());
806 if let Some(buf) = &self.audio_buf {
808 let new_cancel = Arc::new(AtomicBool::new(false));
809 let handle = spawn_audio_thread(
810 self.active_path.clone(),
811 pts,
812 Arc::clone(buf),
813 Arc::clone(&new_cancel),
814 );
815 self.audio_cancel = Some(new_cancel);
816 self.audio_handle = Some(handle);
817 }
818 }
819}
820
821impl Drop for PreviewPlayer {
822 fn drop(&mut self) {
823 if let Some(cancel) = &self.audio_cancel {
827 cancel.store(true, Ordering::Release);
828 }
829 if let Some(h) = self.audio_handle.take() {
830 let _ = h.join();
831 }
832 }
833}
834
835fn spawn_audio_thread(
844 path: PathBuf,
845 start_pts: Duration,
846 buf: Arc<Mutex<VecDeque<f32>>>,
847 cancel: Arc<AtomicBool>,
848) -> JoinHandle<()> {
849 thread::spawn(move || {
850 let mut decoder = match AudioDecoder::open(&path)
851 .output_format(SampleFormat::F32)
852 .output_sample_rate(48_000)
853 .output_channels(2)
854 .build()
855 {
856 Ok(d) => d,
857 Err(e) => {
858 log::warn!("audio decode thread open failed error={e}");
859 return;
860 }
861 };
862
863 if start_pts != Duration::ZERO
864 && let Err(e) = decoder.seek(start_pts, SeekMode::Backward)
865 {
866 log::warn!("audio seek failed pts={start_pts:?} error={e}");
867 }
868
869 loop {
870 if cancel.load(Ordering::Acquire) {
871 break;
872 }
873
874 let buf_len = buf
875 .lock()
876 .unwrap_or_else(std::sync::PoisonError::into_inner)
877 .len();
878 if buf_len >= AUDIO_MAX_BUF {
879 thread::sleep(Duration::from_millis(1));
880 continue;
881 }
882
883 match decoder.decode_one() {
884 Ok(Some(frame)) => {
885 let samples = super::playback_inner::audio_frame_to_f32(&frame);
886 if !samples.is_empty() {
887 let mut guard = buf
888 .lock()
889 .unwrap_or_else(std::sync::PoisonError::into_inner);
890 let space = AUDIO_MAX_BUF.saturating_sub(guard.len());
891 guard.extend(samples.into_iter().take(space));
892 }
893 }
894 Ok(None) => break, Err(e) => {
896 log::warn!("audio decode error error={e}");
897 break;
898 }
899 }
900 }
901 })
902}
903
904#[cfg(test)]
907mod tests {
908 use super::*;
909 use std::path::Path;
910
911 fn test_video_path() -> std::path::PathBuf {
912 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/video/gameplay.mp4")
913 }
914
915 fn test_audio_path() -> std::path::PathBuf {
916 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
917 .join("../../assets/audio/konekonoosanpo.mp3")
918 }
919
920 #[test]
923 fn preview_player_open_should_fail_for_nonexistent_file() {
924 let result = PreviewPlayer::open(Path::new("nonexistent_preview.mp4"));
925 assert!(
926 result.is_err(),
927 "open() must return Err for a non-existent file"
928 );
929 }
930
931 #[test]
932 fn preview_player_play_pause_stop_should_update_state() {
933 let path = test_video_path();
934 let mut player = match PreviewPlayer::open(&path) {
935 Ok(p) => p,
936 Err(e) => {
937 println!("skipping: video file not available: {e}");
938 return;
939 }
940 };
941
942 assert!(!player.paused.load(Ordering::Relaxed));
944 assert!(!player.stopped.load(Ordering::Relaxed));
945
946 player.pause();
947 assert!(player.paused.load(Ordering::Relaxed));
948
949 player.play();
950 assert!(!player.paused.load(Ordering::Relaxed));
951 assert!(!player.stopped.load(Ordering::Relaxed));
952
953 player.stop();
954 assert!(player.stopped.load(Ordering::Relaxed));
955 }
956
957 #[test]
958 fn preview_player_run_should_deliver_frames_to_sink() {
959 use std::sync::{Arc, Mutex};
960
961 struct CountingSink(Arc<Mutex<usize>>);
962 impl FrameSink for CountingSink {
963 fn push_frame(&mut self, _rgba: &[u8], _width: u32, _height: u32, _pts: Duration) {
964 *self
965 .0
966 .lock()
967 .unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
968 }
969 }
970
971 let path = test_video_path();
972 let mut player = match PreviewPlayer::open(&path) {
973 Ok(p) => p,
974 Err(e) => {
975 println!("skipping: video file not available: {e}");
976 return;
977 }
978 };
979
980 let count = Arc::new(Mutex::new(0usize));
981 player.set_sink(Box::new(CountingSink(Arc::clone(&count))));
982 player.play();
983
984 match player.run() {
986 Ok(()) => {}
987 Err(e) => {
988 println!("skipping: run() error: {e}");
989 return;
990 }
991 }
992
993 let frames = *count
994 .lock()
995 .unwrap_or_else(std::sync::PoisonError::into_inner);
996 assert!(
997 frames > 0,
998 "run() must deliver at least one frame to the sink"
999 );
1000 }
1001
1002 #[test]
1005 fn pop_audio_samples_should_return_empty_when_paused() {
1006 let path = test_video_path();
1007 let player = match PreviewPlayer::open(&path) {
1008 Ok(p) => p,
1009 Err(e) => {
1010 println!("skipping: video file not available: {e}");
1011 return;
1012 }
1013 };
1014 player.pause();
1015 let samples = player.pop_audio_samples(1024);
1016 assert!(
1017 samples.is_empty(),
1018 "pop_audio_samples() must return empty while paused"
1019 );
1020 }
1021
1022 #[test]
1023 fn pop_audio_samples_should_return_empty_when_stopped() {
1024 let path = test_video_path();
1025 let mut player = match PreviewPlayer::open(&path) {
1026 Ok(p) => p,
1027 Err(e) => {
1028 println!("skipping: video file not available: {e}");
1029 return;
1030 }
1031 };
1032 player.stop();
1033 let samples = player.pop_audio_samples(1024);
1034 assert!(
1035 samples.is_empty(),
1036 "pop_audio_samples() must return empty while stopped"
1037 );
1038 }
1039
1040 #[test]
1041 fn pop_audio_samples_should_return_empty_for_zero_n_samples() {
1042 let path = test_video_path();
1043 let player = match PreviewPlayer::open(&path) {
1044 Ok(p) => p,
1045 Err(e) => {
1046 println!("skipping: video file not available: {e}");
1047 return;
1048 }
1049 };
1050 player.play();
1051 let samples = player.pop_audio_samples(0);
1052 assert!(
1053 samples.is_empty(),
1054 "pop_audio_samples(0) must always return empty"
1055 );
1056 }
1057
1058 #[test]
1059 fn pause_handle_should_control_paused_flag_from_shared_reference() {
1060 let path = test_video_path();
1061 let player = match PreviewPlayer::open(&path) {
1062 Ok(p) => p,
1063 Err(e) => {
1064 println!("skipping: video file not available: {e}");
1065 return;
1066 }
1067 };
1068 let handle = player.pause_handle();
1069
1070 handle.store(true, Ordering::Release);
1071 assert!(
1072 player.paused.load(Ordering::Acquire),
1073 "handle must set paused flag"
1074 );
1075
1076 handle.store(false, Ordering::Release);
1077 assert!(
1078 !player.paused.load(Ordering::Acquire),
1079 "handle must clear paused flag"
1080 );
1081
1082 let cloned = Arc::clone(&handle);
1084 cloned.store(true, Ordering::Release);
1085 assert!(
1086 player.paused.load(Ordering::Acquire),
1087 "cloned handle must set paused flag"
1088 );
1089 }
1090
1091 #[test]
1092 fn play_and_pause_should_be_callable_via_shared_reference() {
1093 let path = test_video_path();
1095 let player = match PreviewPlayer::open(&path) {
1096 Ok(p) => p,
1097 Err(e) => {
1098 println!("skipping: video file not available: {e}");
1099 return;
1100 }
1101 };
1102 player.pause();
1103 assert!(
1104 player.paused.load(Ordering::Relaxed),
1105 "pause() via &self must set paused flag"
1106 );
1107 player.play();
1108 assert!(
1109 !player.paused.load(Ordering::Relaxed),
1110 "play() via &self must clear paused flag"
1111 );
1112 }
1113
1114 #[test]
1115 fn pop_audio_samples_should_be_callable_via_shared_reference() {
1116 let path = test_video_path();
1119 let player = match PreviewPlayer::open(&path) {
1120 Ok(p) => p,
1121 Err(e) => {
1122 println!("skipping: video file not available: {e}");
1123 return;
1124 }
1125 };
1126 let samples = player.pop_audio_samples(0);
1128 assert!(samples.is_empty(), "pop_audio_samples(0) must return empty");
1129
1130 let shared = std::sync::Arc::new(player);
1132 let _samples = shared.pop_audio_samples(0);
1133 }
1134
1135 #[test]
1136 fn pop_audio_samples_clock_increment_should_equal_half_sample_count() {
1137 let stereo_samples: usize = 9_600;
1140 let expected_frames: u64 = (stereo_samples / 2) as u64;
1141 assert_eq!(
1142 expected_frames, 4_800,
1143 "9600 stereo samples must yield 4800 clock frames"
1144 );
1145 let pts = Duration::from_secs_f64(f64::from(48_000u32).recip() * expected_frames as f64);
1147 assert!(
1148 (pts.as_secs_f64() - 0.1).abs() < 1e-6,
1149 "4800 frames at 48 kHz must equal 100 ms; got {pts:?}"
1150 );
1151 }
1152
1153 #[test]
1156 fn current_pts_should_return_zero_before_first_frame() {
1157 let path = test_video_path();
1158 let player = match PreviewPlayer::open(&path) {
1159 Ok(p) => p,
1160 Err(e) => {
1161 println!("skipping: video file not available: {e}");
1162 return;
1163 }
1164 };
1165 assert_eq!(
1166 player.current_pts(),
1167 Duration::ZERO,
1168 "current_pts() must be ZERO before any frame is presented"
1169 );
1170 }
1171
1172 #[test]
1173 fn duration_should_return_some_for_file_with_known_duration() {
1174 let path = test_video_path();
1175 let player = match PreviewPlayer::open(&path) {
1176 Ok(p) => p,
1177 Err(e) => {
1178 println!("skipping: video file not available: {e}");
1179 return;
1180 }
1181 };
1182 assert!(
1183 player.duration().is_some(),
1184 "duration() must return Some for a file with a known container duration"
1185 );
1186 let d = player.duration().unwrap();
1187 assert!(
1188 d > Duration::ZERO,
1189 "duration() must be positive for a valid media file; got {d:?}"
1190 );
1191 }
1192
1193 #[test]
1194 fn duration_should_return_none_when_duration_millis_is_sentinel() {
1195 let sentinel = u64::MAX;
1199 let result: Option<Duration> = if sentinel == u64::MAX {
1200 None
1201 } else {
1202 Some(Duration::from_millis(sentinel))
1203 };
1204 assert!(result.is_none(), "sentinel u64::MAX must map to None");
1205
1206 let valid = 5_000u64; let result: Option<Duration> = if valid == u64::MAX {
1209 None
1210 } else {
1211 Some(Duration::from_millis(valid))
1212 };
1213 assert_eq!(result, Some(Duration::from_secs(5)));
1214 }
1215
1216 #[test]
1217 fn current_pts_should_advance_after_frames_are_presented() {
1218 use std::sync::{Arc, Mutex};
1219
1220 struct PtsSink(Arc<Mutex<Option<Duration>>>);
1221 impl FrameSink for PtsSink {
1222 fn push_frame(&mut self, _rgba: &[u8], _width: u32, _height: u32, pts: Duration) {
1223 let mut g = self
1224 .0
1225 .lock()
1226 .unwrap_or_else(std::sync::PoisonError::into_inner);
1227 *g = Some(pts);
1228 }
1229 }
1230
1231 let path = test_video_path();
1232 let mut player = match PreviewPlayer::open(&path) {
1233 Ok(p) => p,
1234 Err(e) => {
1235 println!("skipping: video file not available: {e}");
1236 return;
1237 }
1238 };
1239
1240 let last_pts = Arc::new(Mutex::new(None::<Duration>));
1241 player.set_sink(Box::new(PtsSink(Arc::clone(&last_pts))));
1242 player.play();
1243 let _ = player.run();
1244
1245 let sink_pts = last_pts
1249 .lock()
1250 .unwrap_or_else(std::sync::PoisonError::into_inner)
1251 .unwrap_or(Duration::ZERO);
1252 let player_pts = player.current_pts();
1253 let diff = if player_pts >= sink_pts {
1254 player_pts - sink_pts
1255 } else {
1256 sink_pts - player_pts
1257 };
1258 assert!(
1259 diff <= Duration::from_millis(1),
1260 "current_pts() must be within 1 ms of the last sink PTS; \
1261 player_pts={player_pts:?} sink_pts={sink_pts:?} diff={diff:?}"
1262 );
1263 }
1264
1265 #[test]
1268 fn seek_coarse_should_delegate_to_decode_buffer() {
1269 let path = test_video_path();
1270 let mut player = match PreviewPlayer::open(&path) {
1271 Ok(p) => p,
1272 Err(e) => {
1273 println!("skipping: video file not available: {e}");
1274 return;
1275 }
1276 };
1277 for _ in 0..3 {
1279 if matches!(player.pop_frame(), FrameResult::Eof) {
1280 println!("skipping: EOF before seek target");
1281 return;
1282 }
1283 }
1284 let target = Duration::from_secs(1);
1285 match player.seek_coarse(target) {
1286 Ok(()) => {}
1287 Err(e) => {
1288 println!("skipping: seek_coarse not supported or failed: {e}");
1289 return;
1290 }
1291 }
1292 match player.pop_frame() {
1294 FrameResult::Frame(_) | FrameResult::Seeking(_) => {}
1295 FrameResult::Eof => panic!("pop_frame() returned Eof immediately after seek_coarse"),
1296 }
1297 }
1298
1299 #[test]
1300 fn seek_coarse_should_be_faster_than_seek_for_same_target() {
1301 let path = test_video_path();
1304 let mut player_exact = match PreviewPlayer::open(&path) {
1305 Ok(p) => p,
1306 Err(e) => {
1307 println!("skipping: video file not available: {e}");
1308 return;
1309 }
1310 };
1311 let mut player_coarse = match PreviewPlayer::open(&path) {
1312 Ok(p) => p,
1313 Err(e) => {
1314 println!("skipping: video file not available: {e}");
1315 return;
1316 }
1317 };
1318
1319 let target = Duration::from_secs(1);
1320 let exact_ok = player_exact.seek(target).is_ok();
1321 let coarse_ok = player_coarse.seek_coarse(target).is_ok();
1322
1323 assert_eq!(
1325 exact_ok, coarse_ok,
1326 "seek() and seek_coarse() must both succeed or both fail for the same file"
1327 );
1328 }
1329
1330 #[test]
1333 fn av_offset_handle_should_control_offset_from_shared_reference() {
1334 let path = test_video_path();
1335 let player = match PreviewPlayer::open(&path) {
1336 Ok(p) => p,
1337 Err(e) => {
1338 println!("skipping: video file not available: {e}");
1339 return;
1340 }
1341 };
1342 let handle = player.av_offset_handle();
1343
1344 handle.store(300, Ordering::Relaxed);
1345 assert_eq!(
1346 player.av_offset(),
1347 300,
1348 "handle must update av_offset visible through av_offset()"
1349 );
1350
1351 handle.store(-150, Ordering::Relaxed);
1352 assert_eq!(player.av_offset(), -150);
1353
1354 let cloned = Arc::clone(&handle);
1356 cloned.store(500, Ordering::Relaxed);
1357 assert_eq!(
1358 player.av_offset(),
1359 500,
1360 "cloned handle must update av_offset"
1361 );
1362 }
1363
1364 #[test]
1365 fn av_offset_handle_should_have_same_signature_as_stop_handle() {
1366 let path = test_video_path();
1369 let player = match PreviewPlayer::open(&path) {
1370 Ok(p) => p,
1371 Err(e) => {
1372 println!("skipping: video file not available: {e}");
1373 return;
1374 }
1375 };
1376 let _av: Arc<AtomicI64> = player.av_offset_handle();
1377 let _stop: Arc<AtomicBool> = player.stop_handle();
1378 }
1379
1380 #[test]
1383 fn av_offset_default_should_be_zero() {
1384 use std::sync::atomic::{AtomicI64, Ordering};
1385 let offset = AtomicI64::new(0);
1387 assert_eq!(offset.load(Ordering::Relaxed), 0);
1388 }
1389
1390 #[test]
1391 fn set_av_offset_should_clamp_large_positive_value() {
1392 let path = test_video_path();
1393 let player = match PreviewPlayer::open(&path) {
1394 Ok(p) => p,
1395 Err(e) => {
1396 println!("skipping: video file not available: {e}");
1397 return;
1398 }
1399 };
1400 player.set_av_offset(10_000);
1401 assert_eq!(player.av_offset(), 5_000, "offset must be clamped to +5000");
1402 }
1403
1404 #[test]
1405 fn set_av_offset_should_clamp_large_negative_value() {
1406 let path = test_video_path();
1407 let player = match PreviewPlayer::open(&path) {
1408 Ok(p) => p,
1409 Err(e) => {
1410 println!("skipping: video file not available: {e}");
1411 return;
1412 }
1413 };
1414 player.set_av_offset(-10_000);
1415 assert_eq!(
1416 player.av_offset(),
1417 -5_000,
1418 "offset must be clamped to -5000"
1419 );
1420 }
1421
1422 #[test]
1423 fn positive_av_offset_should_reduce_adjusted_video_pts() {
1424 let video_pts = Duration::from_millis(1_000);
1426 let offset_ms: i64 = 200;
1427 let adjusted = if offset_ms >= 0 {
1428 let offset = Duration::from_millis(offset_ms as u64);
1429 video_pts.saturating_sub(offset)
1430 } else {
1431 let offset = Duration::from_millis(offset_ms.unsigned_abs());
1432 video_pts + offset
1433 };
1434 assert_eq!(
1435 adjusted,
1436 Duration::from_millis(800),
1437 "positive offset must reduce adjusted_video_pts by offset amount"
1438 );
1439 }
1440
1441 #[test]
1442 fn negative_av_offset_should_increase_adjusted_video_pts() {
1443 let video_pts = Duration::from_millis(1_000);
1444 let offset_ms: i64 = -200;
1445 let adjusted = if offset_ms >= 0 {
1446 let offset = Duration::from_millis(offset_ms as u64);
1447 video_pts.saturating_sub(offset)
1448 } else {
1449 let offset = Duration::from_millis(offset_ms.unsigned_abs());
1450 video_pts + offset
1451 };
1452 assert_eq!(
1453 adjusted,
1454 Duration::from_millis(1_200),
1455 "negative offset must increase adjusted_video_pts by offset amount"
1456 );
1457 }
1458
1459 #[test]
1460 fn positive_av_offset_at_zero_pts_should_saturate_to_zero() {
1461 let video_pts = Duration::ZERO;
1462 let offset_ms: i64 = 100;
1463 let adjusted = video_pts.saturating_sub(Duration::from_millis(offset_ms as u64));
1464 assert_eq!(
1465 adjusted,
1466 Duration::ZERO,
1467 "saturating_sub on zero pts must clamp to zero not underflow"
1468 );
1469 }
1470
1471 #[test]
1474 fn use_proxy_if_available_should_return_false_when_no_proxy_in_dir() {
1475 let path = test_video_path();
1476 let mut player = match PreviewPlayer::open(&path) {
1477 Ok(p) => p,
1478 Err(e) => {
1479 println!("skipping: video file not available: {e}");
1480 return;
1481 }
1482 };
1483 let tmp = std::env::temp_dir().join("ff_preview_no_proxy_dir_test");
1484 let _ = std::fs::create_dir_all(&tmp);
1485 let found = player.use_proxy_if_available(&tmp);
1486 assert!(
1487 !found,
1488 "must return false when no proxy files exist in the directory"
1489 );
1490 }
1491
1492 #[test]
1493 fn use_proxy_if_available_should_return_false_after_play() {
1494 let path = test_video_path();
1495 let mut player = match PreviewPlayer::open(&path) {
1496 Ok(p) => p,
1497 Err(e) => {
1498 println!("skipping: video file not available: {e}");
1499 return;
1500 }
1501 };
1502 player.play();
1503 let found = player.use_proxy_if_available(Path::new("."));
1504 assert!(!found, "must return false when called after play()");
1505 }
1506
1507 #[test]
1508 fn active_source_should_return_original_path_before_proxy_activation() {
1509 let path = test_video_path();
1510 let player = match PreviewPlayer::open(&path) {
1511 Ok(p) => p,
1512 Err(e) => {
1513 println!("skipping: video file not available: {e}");
1514 return;
1515 }
1516 };
1517 assert_eq!(
1518 player.active_source(),
1519 path.as_path(),
1520 "active_source() must equal the original path before any proxy activation"
1521 );
1522 }
1523
1524 #[test]
1527 fn set_rate_should_update_rate_bits() {
1528 let path = test_video_path();
1529 let player = match PreviewPlayer::open(&path) {
1530 Ok(p) => p,
1531 Err(e) => {
1532 println!("skipping: video file not available: {e}");
1533 return;
1534 }
1535 };
1536 let default_rate = f64::from_bits(player.rate_bits.load(Ordering::Relaxed));
1538 assert!(
1539 (default_rate - 1.0).abs() < f64::EPSILON,
1540 "default rate must be 1.0; got {default_rate}"
1541 );
1542
1543 player.set_rate(2.0);
1544 let rate = f64::from_bits(player.rate_bits.load(Ordering::Relaxed));
1545 assert!(
1546 (rate - 2.0).abs() < f64::EPSILON,
1547 "set_rate(2.0) must store 2.0; got {rate}"
1548 );
1549
1550 player.set_rate(0.5);
1551 let rate = f64::from_bits(player.rate_bits.load(Ordering::Relaxed));
1552 assert!(
1553 (rate - 0.5).abs() < f64::EPSILON,
1554 "set_rate(0.5) must store 0.5; got {rate}"
1555 );
1556 }
1557
1558 #[test]
1559 fn set_rate_should_ignore_non_positive_values() {
1560 let path = test_video_path();
1561 let player = match PreviewPlayer::open(&path) {
1562 Ok(p) => p,
1563 Err(e) => {
1564 println!("skipping: video file not available: {e}");
1565 return;
1566 }
1567 };
1568 player.set_rate(2.0);
1569
1570 player.set_rate(0.0);
1572 let rate = f64::from_bits(player.rate_bits.load(Ordering::Relaxed));
1573 assert!(
1574 (rate - 2.0).abs() < f64::EPSILON,
1575 "set_rate(0.0) must be a no-op; rate must remain 2.0, got {rate}"
1576 );
1577
1578 player.set_rate(-1.0);
1580 let rate = f64::from_bits(player.rate_bits.load(Ordering::Relaxed));
1581 assert!(
1582 (rate - 2.0).abs() < f64::EPSILON,
1583 "set_rate(-1.0) must be a no-op; rate must remain 2.0, got {rate}"
1584 );
1585 }
1586
1587 #[test]
1588 fn rate_handle_should_return_shared_reference_to_rate_bits() {
1589 let path = test_video_path();
1590 let player = match PreviewPlayer::open(&path) {
1591 Ok(p) => p,
1592 Err(e) => {
1593 println!("skipping: video file not available: {e}");
1594 return;
1595 }
1596 };
1597 let handle = player.rate_handle();
1598
1599 handle.store(3.0_f64.to_bits(), Ordering::Relaxed);
1600 let rate = f64::from_bits(player.rate_bits.load(Ordering::Relaxed));
1601 assert!(
1602 (rate - 3.0).abs() < f64::EPSILON,
1603 "rate_handle() write must be visible through rate_bits; got {rate}"
1604 );
1605
1606 let cloned = Arc::clone(&handle);
1608 cloned.store(0.25_f64.to_bits(), Ordering::Relaxed);
1609 let rate = f64::from_bits(player.rate_bits.load(Ordering::Relaxed));
1610 assert!(
1611 (rate - 0.25).abs() < f64::EPSILON,
1612 "cloned rate_handle write must be visible; got {rate}"
1613 );
1614 }
1615
1616 #[test]
1617 fn set_rate_should_be_callable_via_shared_reference() {
1618 let path = test_video_path();
1620 let player = match PreviewPlayer::open(&path) {
1621 Ok(p) => p,
1622 Err(e) => {
1623 println!("skipping: video file not available: {e}");
1624 return;
1625 }
1626 };
1627 player.set_rate(2.0);
1628 let rate_handle: Arc<AtomicU64> = player.rate_handle();
1629 let _ = rate_handle;
1630 }
1631
1632 #[test]
1635 fn audio_only_open_should_succeed() {
1636 let path = test_audio_path();
1637 match PreviewPlayer::open(&path) {
1638 Ok(player) => {
1639 assert!(
1641 player.decode_buf.is_none(),
1642 "audio-only player must have no video decode buffer"
1643 );
1644 assert!(
1646 player.audio_buf.is_some(),
1647 "audio-only player must have an audio ring buffer"
1648 );
1649 }
1650 Err(e) => {
1651 println!("skipping: audio file not available: {e}");
1652 }
1653 }
1654 }
1655
1656 #[test]
1657 fn audio_only_pop_frame_should_return_eof() {
1658 let path = test_audio_path();
1659 let mut player = match PreviewPlayer::open(&path) {
1660 Ok(p) => p,
1661 Err(e) => {
1662 println!("skipping: audio file not available: {e}");
1663 return;
1664 }
1665 };
1666 assert!(
1668 matches!(player.pop_frame(), FrameResult::Eof),
1669 "pop_frame() on an audio-only player must return Eof"
1670 );
1671 }
1672
1673 #[test]
1674 fn audio_only_run_should_return_ok_without_video_frames() {
1675 let path = test_audio_path();
1676 let mut player = match PreviewPlayer::open(&path) {
1677 Ok(p) => p,
1678 Err(e) => {
1679 println!("skipping: audio file not available: {e}");
1680 return;
1681 }
1682 };
1683
1684 struct CountingSink(usize);
1686 impl FrameSink for CountingSink {
1687 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1688 self.0 += 1;
1689 }
1690 }
1691 player.set_sink(Box::new(CountingSink(0)));
1692
1693 let stop = player.stop_handle();
1695 let _ = thread::spawn(move || {
1696 thread::sleep(Duration::from_millis(150));
1697 stop.store(true, Ordering::Release);
1698 });
1699
1700 player.play();
1701 let result = player.run();
1702 assert!(
1703 result.is_ok(),
1704 "run() on an audio-only player must return Ok; got {result:?}"
1705 );
1706 if let Some(sink) = player.sink.as_ref() {
1708 let _ = sink;
1711 }
1712 assert_eq!(
1713 player.current_pts(),
1714 Duration::ZERO,
1715 "current_pts() must remain ZERO for audio-only playback (no video frames)"
1716 );
1717 }
1718
1719 #[test]
1720 fn audio_only_seek_should_return_ok() {
1721 let path = test_audio_path();
1722 let mut player = match PreviewPlayer::open(&path) {
1723 Ok(p) => p,
1724 Err(e) => {
1725 println!("skipping: audio file not available: {e}");
1726 return;
1727 }
1728 };
1729 assert!(
1731 player.seek(Duration::from_secs(1)).is_ok(),
1732 "seek() on audio-only player must return Ok"
1733 );
1734 assert!(
1735 player.seek_coarse(Duration::from_secs(1)).is_ok(),
1736 "seek_coarse() on audio-only player must return Ok"
1737 );
1738 }
1739}