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;
39
40pub enum PlayerCommand {
45 Play,
47 Pause,
49 Stop,
52 Seek(Duration),
55 SetRate(f64),
57 SetAvOffset(i64),
59}
60
61#[derive(Clone)]
73pub struct PlayerHandle {
74 cmd_tx: mpsc::SyncSender<PlayerCommand>,
75 event_rx: Arc<Mutex<mpsc::Receiver<PlayerEvent>>>,
76 current_pts: Arc<AtomicU64>,
78 audio_buf: Option<Arc<Mutex<VecDeque<f32>>>>,
79 samples_consumed: Option<Arc<AtomicU64>>,
81 paused: Arc<AtomicBool>,
83 stopped: Arc<AtomicBool>,
85 duration_millis: u64,
86 audio_mixer: Option<Arc<Mutex<AudioMixer>>>,
88}
89
90impl PlayerHandle {
91 pub fn play(&self) {
93 self.stopped.store(false, Ordering::Release);
94 self.paused.store(false, Ordering::Release);
95 let _ = self.cmd_tx.try_send(PlayerCommand::Play);
96 }
97
98 pub fn pause(&self) {
100 self.paused.store(true, Ordering::Release);
101 let _ = self.cmd_tx.try_send(PlayerCommand::Pause);
102 }
103
104 pub fn stop(&self) {
106 self.stopped.store(true, Ordering::Release);
107 let _ = self.cmd_tx.try_send(PlayerCommand::Stop);
108 }
109
110 pub fn seek(&self, pts: Duration) {
115 let _ = self.cmd_tx.try_send(PlayerCommand::Seek(pts));
116 }
117
118 pub fn set_rate(&self, rate: f64) {
122 let _ = self.cmd_tx.try_send(PlayerCommand::SetRate(rate));
123 }
124
125 pub fn set_av_offset(&self, ms: i64) {
130 let _ = self.cmd_tx.try_send(PlayerCommand::SetAvOffset(ms));
131 }
132
133 #[must_use]
137 pub fn current_pts(&self) -> Duration {
138 Duration::from_micros(self.current_pts.load(Ordering::Relaxed))
139 }
140
141 #[must_use]
143 pub fn duration(&self) -> Option<Duration> {
144 if self.duration_millis == u64::MAX {
145 None
146 } else {
147 Some(Duration::from_millis(self.duration_millis))
148 }
149 }
150
151 #[allow(clippy::cast_precision_loss)]
161 pub fn pop_audio_samples(&self, n: usize) -> Vec<f32> {
162 if self.paused.load(Ordering::Relaxed) || self.stopped.load(Ordering::Relaxed) {
163 return Vec::new();
164 }
165 if n == 0 {
166 return Vec::new();
167 }
168 if let Some(mixer) = &self.audio_mixer {
171 return mixer
172 .lock()
173 .unwrap_or_else(std::sync::PoisonError::into_inner)
174 .mix(n);
175 }
176 let Some(buf) = &self.audio_buf else {
178 return Vec::new();
179 };
180 let mut guard = buf
181 .lock()
182 .unwrap_or_else(std::sync::PoisonError::into_inner);
183 let take = n.min(guard.len());
184 if take == 0 {
185 return Vec::new();
186 }
187 let samples: Vec<f32> = guard.drain(..take).collect();
188 if let Some(sc) = &self.samples_consumed {
189 sc.fetch_add((take / 2) as u64, Ordering::Relaxed);
190 }
191 samples
192 }
193
194 #[must_use]
198 pub fn poll_event(&self) -> Option<PlayerEvent> {
199 self.event_rx.lock().ok()?.try_recv().ok()
200 }
201
202 #[must_use]
207 pub fn recv_event(&self) -> Option<PlayerEvent> {
208 self.event_rx.lock().ok()?.recv().ok()
209 }
210
211 #[cfg(feature = "timeline")]
216 pub(crate) fn for_timeline(
217 cmd_tx: mpsc::SyncSender<PlayerCommand>,
218 event_rx: Arc<Mutex<mpsc::Receiver<PlayerEvent>>>,
219 current_pts: Arc<AtomicU64>,
220 paused: Arc<AtomicBool>,
221 stopped: Arc<AtomicBool>,
222 duration_millis: u64,
223 audio_mixer: Option<Arc<Mutex<AudioMixer>>>,
224 ) -> Self {
225 Self {
226 cmd_tx,
227 event_rx,
228 current_pts,
229 audio_buf: None,
230 samples_consumed: None,
231 audio_mixer,
232 paused,
233 stopped,
234 duration_millis,
235 }
236 }
237}
238
239pub struct PlayerRunner {
248 path: PathBuf,
249 cmd_rx: mpsc::Receiver<PlayerCommand>,
250 event_tx: mpsc::SyncSender<PlayerEvent>,
251 decode_buf: Option<DecodeBuffer>,
252 fps: f64,
253 sink: Option<Box<dyn FrameSink>>,
254 clock: MasterClock,
255 audio_buf: Option<Arc<Mutex<VecDeque<f32>>>>,
256 audio_cancel: Option<Arc<AtomicBool>>,
257 audio_handle: Option<JoinHandle<()>>,
258 sws: super::playback_inner::SwsRgbaConverter,
259 rgba_buf: Vec<u8>,
260 active_path: PathBuf,
261 current_pts: Arc<AtomicU64>,
262 paused: Arc<AtomicBool>,
263 stopped: Arc<AtomicBool>,
264 av_offset_ms: i64,
265 rate: f64,
266 duration_millis: u64,
267 frame_cache: Option<FrameCache>,
268 hw_accel: HardwareAccel,
269}
270
271impl PlayerRunner {
272 pub fn set_sink(&mut self, sink: Box<dyn FrameSink>) {
274 self.sink = Some(sink);
275 }
276
277 pub fn set_hardware_accel(&mut self, accel: HardwareAccel) -> &mut Self {
283 self.hw_accel = accel;
284 self
285 }
286
287 #[must_use]
289 pub fn active_source(&self) -> &Path {
290 &self.active_path
291 }
292
293 #[must_use]
302 pub fn with_frame_cache_budget(mut self, bytes: usize) -> Self {
303 self.frame_cache = Some(FrameCache::new(bytes));
304 self
305 }
306
307 #[must_use]
309 pub fn duration(&self) -> Option<Duration> {
310 if self.duration_millis == u64::MAX {
311 None
312 } else {
313 Some(Duration::from_millis(self.duration_millis))
314 }
315 }
316
317 pub fn use_proxy_if_available(&mut self, proxy_dir: &Path) -> bool {
324 let stem = self
325 .path
326 .file_stem()
327 .and_then(|s| s.to_str())
328 .unwrap_or("output")
329 .to_owned();
330
331 for suffix in ["half", "quarter", "eighth"] {
332 let candidate = proxy_dir.join(format!("{stem}_proxy_{suffix}.mp4"));
333 if candidate.exists() {
334 match self.activate_proxy(&candidate) {
335 Ok(()) => {
336 log::debug!("proxy activated path={}", candidate.display());
337 return true;
338 }
339 Err(e) => {
340 log::warn!(
341 "proxy activation failed path={} error={e}",
342 candidate.display()
343 );
344 }
345 }
346 }
347 }
348 false
349 }
350
351 #[allow(clippy::too_many_lines)]
369 pub fn run(mut self) -> Result<(), PreviewError> {
370 let fps = self.fps.max(1.0);
371 let frame_period = Duration::from_secs_f64(1.0 / fps);
372
373 if self.hw_accel != HardwareAccel::Auto && self.decode_buf.is_some() {
378 match DecodeBuffer::open(&self.active_path)
379 .hardware_accel(self.hw_accel)
380 .build()
381 {
382 Ok(buf) => {
383 self.decode_buf = Some(buf);
384 }
385 Err(e) => {
386 log::warn!(
387 "hwaccel decode buffer rebuild failed accel={} error={e}",
388 self.hw_accel.name()
389 );
390 }
391 }
392 }
393
394 self.clock.reset(Duration::ZERO);
395
396 loop {
397 let mut pending_seek: Option<Duration> = None;
399 while let Ok(cmd) = self.cmd_rx.try_recv() {
400 match cmd {
401 PlayerCommand::Seek(pts) => pending_seek = Some(pts),
402 PlayerCommand::Play => {
403 self.stopped.store(false, Ordering::Release);
404 self.paused.store(false, Ordering::Release);
405 }
406 PlayerCommand::Pause => {
407 self.paused.store(true, Ordering::Release);
408 }
409 PlayerCommand::Stop => {
410 self.stopped.store(true, Ordering::Release);
411 }
412 PlayerCommand::SetRate(r) => {
413 if r > 0.0 {
414 self.rate = r;
415 }
416 }
417 PlayerCommand::SetAvOffset(ms) => {
418 const MAX_OFFSET_MS: i64 = 5_000;
419 self.av_offset_ms = ms.clamp(-MAX_OFFSET_MS, MAX_OFFSET_MS);
420 }
421 }
422 }
423
424 if let Some(pts) = pending_seek {
426 if let Some(cache) = &mut self.frame_cache {
428 let in_range = cache
429 .pts_range()
430 .is_some_and(|(lo, hi)| pts >= lo && pts <= hi);
431 if !in_range {
432 cache.invalidate();
433 }
434 }
435 if let Some(buf) = self.decode_buf.as_mut() {
436 buf.seek(pts)?;
437 }
438 self.clock.reset(pts);
439 self.restart_audio_from(pts);
440 let _ = self.event_tx.try_send(PlayerEvent::SeekCompleted(pts));
441 }
442
443 if let Some(buf) = self.decode_buf.as_ref() {
445 while let Ok(msg) = buf.error_events().try_recv() {
446 let _ = self.event_tx.try_send(PlayerEvent::Error(msg));
447 }
448 }
449
450 if self.stopped.load(Ordering::Acquire) {
451 break;
452 }
453 if self.paused.load(Ordering::Acquire) {
454 thread::sleep(Duration::from_millis(5));
455 continue;
456 }
457
458 if self.decode_buf.is_none() {
460 thread::sleep(Duration::from_millis(10));
461 if let Some(audio_buf) = &self.audio_buf {
462 let empty = audio_buf
463 .lock()
464 .unwrap_or_else(std::sync::PoisonError::into_inner)
465 .is_empty();
466 if empty
467 && self
468 .audio_handle
469 .as_ref()
470 .is_none_or(JoinHandle::is_finished)
471 {
472 break;
473 }
474 } else {
475 break;
476 }
477 continue;
478 }
479
480 let current = self.clock.current_pts();
482 let cache_hit = self
483 .frame_cache
484 .as_ref()
485 .and_then(|c| c.get(current))
486 .map(|f| (f.rgba.clone(), f.width, f.height));
487 if let Some((rgba, width, height)) = cache_hit {
488 if let Some(sink) = self.sink.as_mut() {
489 sink.push_frame(&rgba, width, height, current);
490 }
491 self.current_pts.store(
492 u64::try_from(current.as_micros()).unwrap_or(u64::MAX),
493 Ordering::Relaxed,
494 );
495 let _ = self.event_tx.try_send(PlayerEvent::PositionUpdate(current));
496 continue;
497 }
498
499 let pop_result = if let Some(buf) = self.decode_buf.as_mut() {
501 buf.pop_frame()
502 } else {
503 FrameResult::Eof
504 };
505
506 match pop_result {
507 FrameResult::Eof => break,
508 FrameResult::Seeking(last) => {
509 if let Some(ref f) = last {
510 self.present_frame(f);
511 }
512 }
513 FrameResult::Frame(frame) => {
514 if self.clock.should_sync() {
515 let video_pts = if frame.timestamp().is_valid() {
516 frame.timestamp().as_duration()
517 } else {
518 Duration::ZERO
519 };
520
521 let offset_ms = self.av_offset_ms;
522 let offset = Duration::from_millis(offset_ms.unsigned_abs());
523 let adjusted_video_pts = if offset_ms >= 0 {
524 video_pts.saturating_sub(offset)
525 } else {
526 video_pts + offset
527 };
528
529 let clock_pts = self.clock.current_pts();
530 let diff = adjusted_video_pts.as_secs_f64() - clock_pts.as_secs_f64();
531 let fp = frame_period.as_secs_f64();
532
533 if diff > fp {
534 let sleep_secs =
535 (diff - fp / 2.0).max(0.0) / self.rate.max(f64::MIN_POSITIVE);
536 thread::sleep(Duration::from_secs_f64(sleep_secs));
537 } else if diff < -fp {
538 log::debug!(
539 "dropped late frame video_pts={video_pts:?} \
540 clock_pts={clock_pts:?}"
541 );
542 continue;
543 }
544 }
545
546 self.present_frame(&frame);
547 let pts = frame.timestamp().as_duration();
548 let _ = self.event_tx.try_send(PlayerEvent::PositionUpdate(pts));
549
550 self.clock.activate_fallback_if_no_audio(pts);
555
556 if let Some(cache) = &mut self.frame_cache
558 && !self.rgba_buf.is_empty()
559 {
560 cache.insert(pts, self.rgba_buf.clone(), frame.width(), frame.height());
561 }
562 }
563 }
564 }
565
566 let _ = self.event_tx.try_send(PlayerEvent::Eof);
567 if let Some(sink) = self.sink.as_mut() {
568 sink.flush();
569 }
570 Ok(())
571 }
572
573 fn present_frame(&mut self, frame: &ff_format::VideoFrame) {
574 let pts = frame.timestamp().as_duration();
575 self.current_pts.store(
576 u64::try_from(pts.as_micros()).unwrap_or(u64::MAX),
577 Ordering::Relaxed,
578 );
579 let Some(sink) = self.sink.as_mut() else {
580 return;
581 };
582 let width = frame.width();
583 let height = frame.height();
584 if self.sws.convert(frame, &mut self.rgba_buf) {
585 sink.push_frame(&self.rgba_buf, width, height, pts);
586 }
587 }
588
589 fn restart_audio_from(&mut self, pts: Duration) {
590 if let Some(buf) = &self.audio_buf {
591 buf.lock()
592 .unwrap_or_else(std::sync::PoisonError::into_inner)
593 .clear();
594 }
595 if let Some(cancel) = &self.audio_cancel {
596 cancel.store(true, Ordering::Release);
597 }
598 drop(self.audio_handle.take());
599 if let Some(buf) = &self.audio_buf {
600 let new_cancel = Arc::new(AtomicBool::new(false));
601 let handle = spawn_audio_thread(
602 self.active_path.clone(),
603 pts,
604 Arc::clone(buf),
605 Arc::clone(&new_cancel),
606 );
607 self.audio_cancel = Some(new_cancel);
608 self.audio_handle = Some(handle);
609 }
610 }
611
612 fn activate_proxy(&mut self, proxy_path: &Path) -> Result<(), PreviewError> {
613 let info = ff_probe::open(proxy_path)?;
614 let fps = info.frame_rate().unwrap_or(30.0).max(1.0);
615 let decode_buf = DecodeBuffer::open(proxy_path)
616 .hardware_accel(self.hw_accel)
617 .build()?;
618
619 if let Some(cancel) = &self.audio_cancel {
620 cancel.store(true, Ordering::Release);
621 }
622 if let Some(buf) = &self.audio_buf {
623 buf.lock()
624 .unwrap_or_else(std::sync::PoisonError::into_inner)
625 .clear();
626 }
627 drop(self.audio_handle.take());
628
629 let (clock, audio_buf, audio_cancel, audio_handle) = if info.has_audio() {
630 let sample_rate = info.sample_rate().unwrap_or(48_000);
631 let buf = Arc::new(Mutex::new(VecDeque::<f32>::new()));
632 let cancel = Arc::new(AtomicBool::new(false));
633 let handle = spawn_audio_thread(
634 proxy_path.to_path_buf(),
635 Duration::ZERO,
636 Arc::clone(&buf),
637 Arc::clone(&cancel),
638 );
639 let clock = MasterClock::Audio {
640 samples_consumed: Arc::new(AtomicU64::new(0)),
641 sample_rate,
642 fallback: None,
643 };
644 (clock, Some(buf), Some(cancel), Some(handle))
645 } else {
646 log::debug!(
647 "proxy has no audio, using system clock path={}",
648 proxy_path.display()
649 );
650 let clock = MasterClock::System {
651 started_at: Instant::now(),
652 base_pts: Duration::ZERO,
653 };
654 (clock, None, None, None)
655 };
656
657 self.active_path = proxy_path.to_path_buf();
658 self.fps = fps;
659 self.decode_buf = Some(decode_buf);
660 self.clock = clock;
661 self.audio_buf = audio_buf;
662 self.audio_cancel = audio_cancel;
663 self.audio_handle = audio_handle;
664 Ok(())
665 }
666}
667
668impl Drop for PlayerRunner {
669 fn drop(&mut self) {
670 if let Some(cancel) = &self.audio_cancel {
671 cancel.store(true, Ordering::Release);
672 }
673 if let Some(h) = self.audio_handle.take() {
674 let _ = h.join();
675 }
676 }
677}
678
679pub struct PreviewPlayer {
704 path: PathBuf,
705 decode_buf: Option<DecodeBuffer>,
707 fps: f64,
708 clock: Option<MasterClock>,
710 audio_buf: Option<Arc<Mutex<VecDeque<f32>>>>,
711 audio_cancel: Option<Arc<AtomicBool>>,
712 audio_handle: Option<JoinHandle<()>>,
713 duration_millis: u64,
714 active_path: PathBuf,
715}
716
717impl PreviewPlayer {
718 pub fn open(path: impl AsRef<Path>) -> Result<Self, PreviewError> {
729 let path = path.as_ref();
730 let info = ff_probe::open(path)?;
731
732 if !info.has_video() && !info.has_audio() {
733 return Err(PreviewError::Ffmpeg {
734 code: -1,
735 message: "file has neither a video nor an audio stream".into(),
736 });
737 }
738
739 let fps = info.frame_rate().unwrap_or(30.0).max(1.0);
740
741 let d = info.duration();
742 let duration_millis = if d.is_zero() {
743 u64::MAX
744 } else {
745 u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
746 };
747
748 let clock = if info.has_audio() {
749 let sample_rate = info.sample_rate().unwrap_or(48_000);
750 MasterClock::Audio {
751 samples_consumed: Arc::new(AtomicU64::new(0)),
752 sample_rate,
753 fallback: None,
754 }
755 } else {
756 log::debug!(
757 "using system clock fallback path={} no_audio=true",
758 path.display()
759 );
760 MasterClock::System {
761 started_at: Instant::now(),
762 base_pts: Duration::ZERO,
763 }
764 };
765
766 let decode_buf = if info.has_video() {
767 Some(DecodeBuffer::open(path).build()?)
768 } else {
769 log::debug!(
770 "audio-only file; skipping video decode buffer path={}",
771 path.display()
772 );
773 None
774 };
775
776 let (audio_buf, audio_cancel, audio_handle) = if let MasterClock::Audio { .. } = &clock {
777 let buf = Arc::new(Mutex::new(VecDeque::<f32>::new()));
778 let cancel = Arc::new(AtomicBool::new(false));
779 let handle = spawn_audio_thread(
780 path.to_path_buf(),
781 Duration::ZERO,
782 Arc::clone(&buf),
783 Arc::clone(&cancel),
784 );
785 (Some(buf), Some(cancel), Some(handle))
786 } else {
787 (None, None, None)
788 };
789
790 Ok(PreviewPlayer {
791 path: path.to_path_buf(),
792 decode_buf,
793 fps,
794 clock: Some(clock),
795 audio_buf,
796 audio_cancel,
797 audio_handle,
798 duration_millis,
799 active_path: path.to_path_buf(),
800 })
801 }
802
803 #[must_use]
815 #[allow(clippy::expect_used)]
816 pub fn split(mut self) -> (PlayerRunner, PlayerHandle) {
817 let current_pts = Arc::new(AtomicU64::new(0));
818 let paused = Arc::new(AtomicBool::new(false));
819 let stopped = Arc::new(AtomicBool::new(false));
820 let (cmd_tx, cmd_rx) = mpsc::sync_channel(CHANNEL_CAP);
821 let (event_tx, event_rx) = mpsc::sync_channel(CHANNEL_CAP);
822
823 let clock = self.clock.take().expect("clock consumed before split");
824 let samples_consumed = match &clock {
825 MasterClock::Audio {
826 samples_consumed, ..
827 } => Some(Arc::clone(samples_consumed)),
828 MasterClock::System { .. } => None,
829 };
830
831 let audio_buf_for_handle = self.audio_buf.clone();
832 let duration_millis = self.duration_millis;
833
834 let runner = PlayerRunner {
835 path: self.path.clone(),
836 cmd_rx,
837 event_tx,
838 decode_buf: self.decode_buf.take(),
839 fps: self.fps,
840 sink: None,
841 clock,
842 audio_buf: self.audio_buf.take(),
843 audio_cancel: self.audio_cancel.take(),
844 audio_handle: self.audio_handle.take(),
845 sws: super::playback_inner::SwsRgbaConverter::new(),
846 rgba_buf: Vec::new(),
847 active_path: self.active_path.clone(),
848 current_pts: Arc::clone(¤t_pts),
849 paused: Arc::clone(&paused),
850 stopped: Arc::clone(&stopped),
851 av_offset_ms: 0,
852 rate: 1.0,
853 duration_millis,
854 frame_cache: None,
855 hw_accel: HardwareAccel::Auto,
856 };
857
858 let handle = PlayerHandle {
859 cmd_tx,
860 event_rx: Arc::new(Mutex::new(event_rx)),
861 current_pts,
862 audio_buf: audio_buf_for_handle,
863 samples_consumed,
864 audio_mixer: None,
865 paused,
866 stopped,
867 duration_millis,
868 };
869
870 (runner, handle)
871 }
872}
873
874impl Drop for PreviewPlayer {
875 fn drop(&mut self) {
876 if let Some(cancel) = &self.audio_cancel {
877 cancel.store(true, Ordering::Release);
878 }
879 if let Some(h) = self.audio_handle.take() {
880 let _ = h.join();
881 }
882 }
883}
884
885fn spawn_audio_thread(
888 path: PathBuf,
889 start_pts: Duration,
890 buf: Arc<Mutex<VecDeque<f32>>>,
891 cancel: Arc<AtomicBool>,
892) -> JoinHandle<()> {
893 thread::spawn(move || {
894 let mut decoder = match AudioDecoder::open(&path)
895 .output_format(SampleFormat::F32)
896 .output_sample_rate(48_000)
897 .output_channels(2)
898 .build()
899 {
900 Ok(d) => d,
901 Err(e) => {
902 log::warn!("audio decode thread open failed error={e}");
903 return;
904 }
905 };
906
907 if start_pts != Duration::ZERO
908 && let Err(e) = decoder.seek(start_pts, SeekMode::Backward)
909 {
910 log::warn!("audio seek failed pts={start_pts:?} error={e}");
911 }
912
913 loop {
914 if cancel.load(Ordering::Acquire) {
915 break;
916 }
917
918 let buf_len = buf
919 .lock()
920 .unwrap_or_else(std::sync::PoisonError::into_inner)
921 .len();
922 if buf_len >= AUDIO_MAX_BUF {
923 thread::sleep(Duration::from_millis(1));
924 continue;
925 }
926
927 match decoder.decode_one() {
928 Ok(Some(frame)) => {
929 let samples = super::playback_inner::audio_frame_to_f32(&frame);
930 if !samples.is_empty() {
931 let mut guard = buf
932 .lock()
933 .unwrap_or_else(std::sync::PoisonError::into_inner);
934 let space = AUDIO_MAX_BUF.saturating_sub(guard.len());
935 guard.extend(samples.into_iter().take(space));
936 }
937 }
938 Ok(None) => break,
939 Err(e) => {
940 log::warn!("audio decode error error={e}");
941 break;
942 }
943 }
944 }
945 })
946}
947
948#[cfg(test)]
951mod tests {
952 use super::*;
953
954 fn test_video_path() -> PathBuf {
955 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/video/gameplay.mp4")
956 }
957
958 fn test_audio_path() -> PathBuf {
959 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/audio/konekonoosanpo.mp3")
960 }
961
962 #[test]
965 fn preview_player_open_should_fail_for_nonexistent_file() {
966 let result = PreviewPlayer::open("nonexistent_preview.mp4");
967 assert!(
968 result.is_err(),
969 "open() must return Err for a non-existent file"
970 );
971 }
972
973 #[test]
976 fn player_handle_play_pause_should_update_paused_flag_immediately() {
977 let path = test_video_path();
978 let (_runner, handle) = match PreviewPlayer::open(&path) {
979 Ok(p) => p.split(),
980 Err(e) => {
981 println!("skipping: video file not available: {e}");
982 return;
983 }
984 };
985
986 assert!(!handle.paused.load(Ordering::Relaxed));
987 assert!(!handle.stopped.load(Ordering::Relaxed));
988
989 handle.pause();
990 assert!(handle.paused.load(Ordering::Relaxed));
991
992 handle.play();
993 assert!(!handle.paused.load(Ordering::Relaxed));
994 assert!(!handle.stopped.load(Ordering::Relaxed));
995
996 handle.stop();
997 assert!(handle.stopped.load(Ordering::Relaxed));
998 }
999
1000 #[test]
1003 fn player_runner_run_should_deliver_frames_to_sink() {
1004 struct CountSink(Arc<Mutex<usize>>);
1005 impl FrameSink for CountSink {
1006 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1007 *self
1008 .0
1009 .lock()
1010 .unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
1011 }
1012 }
1013
1014 let path = test_video_path();
1015 let (mut runner, _handle) = match PreviewPlayer::open(&path) {
1016 Ok(p) => p.split(),
1017 Err(e) => {
1018 println!("skipping: video file not available: {e}");
1019 return;
1020 }
1021 };
1022
1023 let count = Arc::new(Mutex::new(0usize));
1024 runner.set_sink(Box::new(CountSink(Arc::clone(&count))));
1025
1026 match runner.run() {
1027 Ok(()) => {}
1028 Err(e) => {
1029 println!("skipping: run() error: {e}");
1030 return;
1031 }
1032 }
1033
1034 let frames = *count
1035 .lock()
1036 .unwrap_or_else(std::sync::PoisonError::into_inner);
1037 assert!(
1038 frames > 0,
1039 "run() must deliver at least one frame to the sink"
1040 );
1041 }
1042
1043 #[test]
1046 fn pop_audio_samples_should_return_empty_when_paused() {
1047 let path = test_video_path();
1048 let (_runner, handle) = match PreviewPlayer::open(&path) {
1049 Ok(p) => p.split(),
1050 Err(e) => {
1051 println!("skipping: video file not available: {e}");
1052 return;
1053 }
1054 };
1055 handle.pause();
1056 let samples = handle.pop_audio_samples(1024);
1057 assert!(
1058 samples.is_empty(),
1059 "pop_audio_samples() must return empty while paused"
1060 );
1061 }
1062
1063 #[test]
1064 fn pop_audio_samples_should_return_empty_when_stopped() {
1065 let path = test_video_path();
1066 let (_runner, handle) = match PreviewPlayer::open(&path) {
1067 Ok(p) => p.split(),
1068 Err(e) => {
1069 println!("skipping: video file not available: {e}");
1070 return;
1071 }
1072 };
1073 handle.stop();
1074 let samples = handle.pop_audio_samples(1024);
1075 assert!(
1076 samples.is_empty(),
1077 "pop_audio_samples() must return empty while stopped"
1078 );
1079 }
1080
1081 #[test]
1082 fn pop_audio_samples_should_return_empty_for_zero_n_samples() {
1083 let path = test_video_path();
1084 let (_runner, handle) = match PreviewPlayer::open(&path) {
1085 Ok(p) => p.split(),
1086 Err(e) => {
1087 println!("skipping: video file not available: {e}");
1088 return;
1089 }
1090 };
1091 handle.play();
1092 let samples = handle.pop_audio_samples(0);
1093 assert!(
1094 samples.is_empty(),
1095 "pop_audio_samples(0) must always return empty"
1096 );
1097 }
1098
1099 #[test]
1100 fn pop_audio_samples_should_be_callable_via_cloned_handle() {
1101 let path = test_video_path();
1102 let (_runner, handle) = match PreviewPlayer::open(&path) {
1103 Ok(p) => p.split(),
1104 Err(e) => {
1105 println!("skipping: video file not available: {e}");
1106 return;
1107 }
1108 };
1109 let shared = handle.clone();
1110 let _samples = shared.pop_audio_samples(0);
1111 }
1112
1113 #[test]
1114 fn pop_audio_samples_clock_increment_should_equal_half_sample_count() {
1115 let stereo_samples: usize = 9_600;
1116 let expected_frames: u64 = (stereo_samples / 2) as u64;
1117 assert_eq!(
1118 expected_frames, 4_800,
1119 "9600 stereo samples must yield 4800 clock frames"
1120 );
1121 let pts = Duration::from_secs_f64(f64::from(48_000u32).recip() * expected_frames as f64);
1122 assert!(
1123 (pts.as_secs_f64() - 0.1).abs() < 1e-6,
1124 "4800 frames at 48 kHz must equal 100 ms; got {pts:?}"
1125 );
1126 }
1127
1128 #[test]
1131 fn current_pts_should_return_zero_before_first_frame() {
1132 let path = test_video_path();
1133 let (_runner, handle) = match PreviewPlayer::open(&path) {
1134 Ok(p) => p.split(),
1135 Err(e) => {
1136 println!("skipping: video file not available: {e}");
1137 return;
1138 }
1139 };
1140 assert_eq!(
1141 handle.current_pts(),
1142 Duration::ZERO,
1143 "current_pts() must be ZERO before any frame is presented"
1144 );
1145 }
1146
1147 #[test]
1148 fn duration_should_return_some_for_file_with_known_duration() {
1149 let path = test_video_path();
1150 let (_runner, handle) = match PreviewPlayer::open(&path) {
1151 Ok(p) => p.split(),
1152 Err(e) => {
1153 println!("skipping: video file not available: {e}");
1154 return;
1155 }
1156 };
1157 assert!(
1158 handle.duration().is_some(),
1159 "duration() must return Some for a file with a known container duration"
1160 );
1161 let d = handle.duration().unwrap();
1162 assert!(
1163 d > Duration::ZERO,
1164 "duration() must be positive for a valid media file; got {d:?}"
1165 );
1166 }
1167
1168 #[test]
1169 fn duration_should_return_none_when_duration_millis_is_sentinel() {
1170 let sentinel = u64::MAX;
1171 let result: Option<Duration> = if sentinel == u64::MAX {
1172 None
1173 } else {
1174 Some(Duration::from_millis(sentinel))
1175 };
1176 assert!(result.is_none(), "sentinel u64::MAX must map to None");
1177
1178 let valid = 5_000u64;
1179 let result: Option<Duration> = if valid == u64::MAX {
1180 None
1181 } else {
1182 Some(Duration::from_millis(valid))
1183 };
1184 assert_eq!(result, Some(Duration::from_secs(5)));
1185 }
1186
1187 #[test]
1188 fn current_pts_should_advance_after_frames_are_presented() {
1189 struct PtsSink(Arc<Mutex<Option<Duration>>>);
1190 impl FrameSink for PtsSink {
1191 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, pts: Duration) {
1192 *self
1193 .0
1194 .lock()
1195 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(pts);
1196 }
1197 }
1198
1199 let path = test_video_path();
1200 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1201 Ok(p) => p.split(),
1202 Err(e) => {
1203 println!("skipping: video file not available: {e}");
1204 return;
1205 }
1206 };
1207
1208 let last_pts = Arc::new(Mutex::new(None::<Duration>));
1209 runner.set_sink(Box::new(PtsSink(Arc::clone(&last_pts))));
1210 let _ = runner.run();
1211
1212 let sink_pts = last_pts
1213 .lock()
1214 .unwrap_or_else(std::sync::PoisonError::into_inner)
1215 .unwrap_or(Duration::ZERO);
1216 let player_pts = handle.current_pts();
1217 let diff = sink_pts.abs_diff(player_pts);
1218 assert!(
1219 diff <= Duration::from_millis(1),
1220 "current_pts() must be within 1 ms of the last sink PTS; \
1221 player_pts={player_pts:?} sink_pts={sink_pts:?} diff={diff:?}"
1222 );
1223 }
1224
1225 #[test]
1228 fn seek_coarse_should_delegate_to_decode_buffer() {
1229 let path = test_video_path();
1230 let (runner, handle) = match PreviewPlayer::open(&path) {
1231 Ok(p) => p.split(),
1232 Err(e) => {
1233 println!("skipping: video file not available: {e}");
1234 return;
1235 }
1236 };
1237
1238 let target = Duration::from_secs(1);
1239 handle.seek(target);
1240
1241 let handle_thread = handle.clone();
1243 thread::spawn(move || {
1244 thread::sleep(Duration::from_millis(500));
1245 handle_thread.stop();
1246 });
1247
1248 match runner.run() {
1249 Ok(()) => {}
1250 Err(e) => {
1251 println!("skipping: run() error: {e}");
1252 }
1253 }
1254 }
1255
1256 #[test]
1259 fn use_proxy_if_available_should_return_false_when_no_proxy_in_dir() {
1260 let path = test_video_path();
1261 let (mut runner, _handle) = match PreviewPlayer::open(&path) {
1262 Ok(p) => p.split(),
1263 Err(e) => {
1264 println!("skipping: video file not available: {e}");
1265 return;
1266 }
1267 };
1268 let tmp = std::env::temp_dir().join("ff_preview_no_proxy_dir_test");
1269 let _ = std::fs::create_dir_all(&tmp);
1270 let found = runner.use_proxy_if_available(&tmp);
1271 assert!(
1272 !found,
1273 "must return false when no proxy files exist in the directory"
1274 );
1275 }
1276
1277 #[test]
1278 fn active_source_should_return_original_path_before_proxy_activation() {
1279 let path = test_video_path();
1280 let (runner, _handle) = match PreviewPlayer::open(&path) {
1281 Ok(p) => p.split(),
1282 Err(e) => {
1283 println!("skipping: video file not available: {e}");
1284 return;
1285 }
1286 };
1287 assert_eq!(
1288 runner.active_source(),
1289 path.as_path(),
1290 "active_source() must equal the original path before any proxy activation"
1291 );
1292 }
1293
1294 #[test]
1297 fn set_rate_should_accept_positive_value() {
1298 let path = test_video_path();
1299 let (_runner, handle) = match PreviewPlayer::open(&path) {
1300 Ok(p) => p.split(),
1301 Err(e) => {
1302 println!("skipping: video file not available: {e}");
1303 return;
1304 }
1305 };
1306 handle.set_rate(2.0);
1308 handle.set_rate(0.5);
1309 }
1310
1311 #[test]
1312 fn set_av_offset_default_should_be_zero() {
1313 use std::sync::atomic::{AtomicI64, Ordering};
1314 let offset = AtomicI64::new(0);
1315 assert_eq!(offset.load(Ordering::Relaxed), 0);
1316 }
1317
1318 #[test]
1319 fn positive_av_offset_should_reduce_adjusted_video_pts() {
1320 let video_pts = Duration::from_millis(1_000);
1321 let offset_ms: i64 = 200;
1322 let adjusted = if offset_ms >= 0 {
1323 let offset = Duration::from_millis(offset_ms as u64);
1324 video_pts.saturating_sub(offset)
1325 } else {
1326 let offset = Duration::from_millis(offset_ms.unsigned_abs());
1327 video_pts + offset
1328 };
1329 assert_eq!(
1330 adjusted,
1331 Duration::from_millis(800),
1332 "positive offset must reduce adjusted_video_pts by offset amount"
1333 );
1334 }
1335
1336 #[test]
1337 fn negative_av_offset_should_increase_adjusted_video_pts() {
1338 let video_pts = Duration::from_millis(1_000);
1339 let offset_ms: i64 = -200;
1340 let adjusted = if offset_ms >= 0 {
1341 let offset = Duration::from_millis(offset_ms as u64);
1342 video_pts.saturating_sub(offset)
1343 } else {
1344 let offset = Duration::from_millis(offset_ms.unsigned_abs());
1345 video_pts + offset
1346 };
1347 assert_eq!(
1348 adjusted,
1349 Duration::from_millis(1_200),
1350 "negative offset must increase adjusted_video_pts by offset amount"
1351 );
1352 }
1353
1354 #[test]
1355 fn positive_av_offset_at_zero_pts_should_saturate_to_zero() {
1356 let video_pts = Duration::ZERO;
1357 let offset_ms: i64 = 100;
1358 let adjusted = video_pts.saturating_sub(Duration::from_millis(offset_ms as u64));
1359 assert_eq!(
1360 adjusted,
1361 Duration::ZERO,
1362 "saturating_sub on zero pts must clamp to zero not underflow"
1363 );
1364 }
1365
1366 #[test]
1369 fn audio_only_open_should_succeed() {
1370 let path = test_audio_path();
1371 match PreviewPlayer::open(&path) {
1372 Ok(player) => {
1373 let (runner, handle) = player.split();
1374 assert!(
1376 runner.decode_buf.is_none(),
1377 "audio-only runner must have no video decode buffer"
1378 );
1379 assert!(
1381 handle.audio_buf.is_some(),
1382 "audio-only handle must have an audio ring buffer"
1383 );
1384 }
1385 Err(e) => {
1386 println!("skipping: audio file not available: {e}");
1387 }
1388 }
1389 }
1390
1391 #[test]
1392 fn audio_only_run_should_return_ok_without_video_frames() {
1393 let path = test_audio_path();
1394 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1395 Ok(p) => p.split(),
1396 Err(e) => {
1397 println!("skipping: audio file not available: {e}");
1398 return;
1399 }
1400 };
1401
1402 struct CountingSink(usize);
1403 impl FrameSink for CountingSink {
1404 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1405 self.0 += 1;
1406 }
1407 }
1408 runner.set_sink(Box::new(CountingSink(0)));
1409
1410 let handle_thread = handle.clone();
1411 thread::spawn(move || {
1412 thread::sleep(Duration::from_millis(150));
1413 handle_thread.stop();
1414 });
1415
1416 let result = runner.run();
1417 assert!(
1418 result.is_ok(),
1419 "run() on an audio-only player must return Ok; got {result:?}"
1420 );
1421 assert_eq!(
1422 handle.current_pts(),
1423 Duration::ZERO,
1424 "current_pts() must remain ZERO for audio-only playback (no video frames)"
1425 );
1426 }
1427
1428 #[test]
1429 fn audio_only_seek_should_not_fail_for_valid_target() {
1430 let path = test_audio_path();
1431 let (_runner, handle) = match PreviewPlayer::open(&path) {
1432 Ok(p) => p.split(),
1433 Err(e) => {
1434 println!("skipping: audio file not available: {e}");
1435 return;
1436 }
1437 };
1438 handle.seek(Duration::from_secs(1));
1440 }
1441
1442 #[test]
1445 #[ignore = "requires assets/video/gameplay.mp4; run with -- --include-ignored"]
1446 fn seek_should_deliver_seek_completed_event_via_poll_event() {
1447 let path = test_video_path();
1448 if !path.exists() {
1449 println!("skipping: video file not found at {}", path.display());
1450 return;
1451 }
1452
1453 let (runner, handle) = match PreviewPlayer::open(&path) {
1454 Ok(p) => p.split(),
1455 Err(e) => {
1456 println!("skipping: open failed: {e}");
1457 return;
1458 }
1459 };
1460
1461 let handle_bg = handle.clone();
1462 let bg = thread::spawn(move || {
1463 let _ = runner.run();
1464 });
1465
1466 thread::sleep(Duration::from_millis(50));
1468 let target = Duration::from_secs(1);
1469 handle.seek(target);
1470
1471 let deadline = Instant::now() + Duration::from_secs(2);
1473 let event = loop {
1474 if let Some(e) = handle.poll_event() {
1475 break Some(e);
1476 }
1477 if Instant::now() > deadline {
1478 break None;
1479 }
1480 thread::sleep(Duration::from_millis(10));
1481 };
1482
1483 handle_bg.stop();
1484 let _ = bg.join();
1485
1486 match event {
1487 Some(PlayerEvent::SeekCompleted(pts)) => {
1488 assert!(
1489 pts >= target.saturating_sub(Duration::from_millis(100)),
1490 "SeekCompleted pts must be near the requested target; \
1491 target={target:?} pts={pts:?}"
1492 );
1493 }
1494 Some(PlayerEvent::Eof) => {
1495 panic!("received Eof before SeekCompleted — file may be too short");
1496 }
1497 Some(PlayerEvent::PositionUpdate(_) | PlayerEvent::Error(_)) | None => {
1498 panic!("no PlayerEvent::SeekCompleted received within 2 seconds");
1499 }
1500 }
1501 }
1502
1503 #[test]
1506 fn position_update_and_error_event_variants_should_be_accessible() {
1507 let _ = PlayerEvent::PositionUpdate(Duration::ZERO);
1508 let _ = PlayerEvent::Error("test error".to_string());
1509 }
1510
1511 #[test]
1512 fn eof_event_should_be_delivered_after_run_completes() {
1513 let path = test_audio_path();
1514 let (runner, handle) = match PreviewPlayer::open(&path) {
1515 Ok(p) => p.split(),
1516 Err(e) => {
1517 println!("skipping: {e}");
1518 return;
1519 }
1520 };
1521
1522 let handle_stop = handle.clone();
1524 thread::spawn(move || {
1525 thread::sleep(Duration::from_millis(150));
1526 handle_stop.stop();
1527 });
1528
1529 let _ = runner.run();
1530 let events: Vec<_> = std::iter::from_fn(|| handle.poll_event()).collect();
1531 assert!(
1532 events.iter().any(|e| matches!(e, PlayerEvent::Eof)),
1533 "Eof event must be delivered after run() returns; collected {} events",
1534 events.len()
1535 );
1536 }
1537
1538 #[test]
1539 #[ignore = "requires assets/video/gameplay.mp4; run with -- --include-ignored"]
1540 fn position_update_should_be_emitted_for_each_video_frame() {
1541 let path =
1542 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/video/gameplay.mp4");
1543 if !path.exists() {
1544 println!("skipping: video asset not found");
1545 return;
1546 }
1547
1548 use std::sync::{Arc, Mutex};
1549 struct CountSink {
1550 count: Arc<Mutex<usize>>,
1551 max: usize,
1552 handle: PlayerHandle,
1553 }
1554 impl FrameSink for CountSink {
1555 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1556 let mut g = self
1557 .count
1558 .lock()
1559 .unwrap_or_else(std::sync::PoisonError::into_inner);
1560 *g += 1;
1561 if *g >= self.max {
1562 self.handle.stop();
1563 }
1564 }
1565 }
1566
1567 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1568 Ok(p) => p.split(),
1569 Err(e) => {
1570 println!("skipping: {e}");
1571 return;
1572 }
1573 };
1574
1575 let count = Arc::new(Mutex::new(0usize));
1576 runner.set_sink(Box::new(CountSink {
1577 count: Arc::clone(&count),
1578 max: 20,
1579 handle: handle.clone(),
1580 }));
1581 let _ = runner.run();
1582
1583 let frames = *count
1584 .lock()
1585 .unwrap_or_else(std::sync::PoisonError::into_inner);
1586 let position_updates: Vec<_> = std::iter::from_fn(|| handle.poll_event())
1587 .filter(|e| matches!(e, PlayerEvent::PositionUpdate(_)))
1588 .collect();
1589
1590 assert!(
1591 !position_updates.is_empty(),
1592 "at least one PositionUpdate event must be emitted; frames delivered={frames}"
1593 );
1594 assert!(
1595 position_updates.len() <= frames,
1596 "PositionUpdate count ({}) must not exceed frame count ({frames})",
1597 position_updates.len()
1598 );
1599 }
1600
1601 #[test]
1604 fn hardware_accel_variants_should_be_accessible_on_player_runner() {
1605 let _ = HardwareAccel::Auto;
1607 let _ = HardwareAccel::None;
1608 let _ = HardwareAccel::Nvdec;
1609 let _ = HardwareAccel::Qsv;
1610 let _ = HardwareAccel::Amf;
1611 let _ = HardwareAccel::VideoToolbox;
1612 let _ = HardwareAccel::Vaapi;
1613 }
1614
1615 #[test]
1616 fn set_hardware_accel_none_should_complete_without_error_on_audio_only_file() {
1617 let path = test_audio_path();
1621 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1622 Ok(p) => p.split(),
1623 Err(e) => {
1624 println!("skipping: audio file not available: {e}");
1625 return;
1626 }
1627 };
1628
1629 runner.set_hardware_accel(HardwareAccel::None);
1630 assert_eq!(runner.hw_accel, HardwareAccel::None);
1631
1632 let handle_stop = handle.clone();
1633 thread::spawn(move || {
1634 thread::sleep(Duration::from_millis(150));
1635 handle_stop.stop();
1636 });
1637
1638 let result = runner.run();
1639 assert!(
1640 result.is_ok(),
1641 "run() with HardwareAccel::None must return Ok; got {result:?}"
1642 );
1643 }
1644
1645 #[test]
1646 #[ignore = "requires assets/video/gameplay.mp4 and hardware decoder; run with -- --include-ignored"]
1647 fn hardware_accel_auto_should_deliver_frames_on_video_file() {
1648 let path = test_video_path();
1649 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1650 Ok(p) => p.split(),
1651 Err(e) => {
1652 println!("skipping: video file not available: {e}");
1653 return;
1654 }
1655 };
1656
1657 runner.set_hardware_accel(HardwareAccel::Auto);
1658
1659 struct CountSink {
1660 count: usize,
1661 max: usize,
1662 handle: PlayerHandle,
1663 }
1664 impl FrameSink for CountSink {
1665 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1666 self.count += 1;
1667 if self.count >= self.max {
1668 self.handle.stop();
1669 }
1670 }
1671 }
1672 runner.set_sink(Box::new(CountSink {
1673 count: 0,
1674 max: 5,
1675 handle: handle.clone(),
1676 }));
1677
1678 let result = runner.run();
1679 assert!(
1680 result.is_ok(),
1681 "run() with HardwareAccel::Auto must return Ok; got {result:?}"
1682 );
1683 assert!(
1684 handle.current_pts() > Duration::ZERO,
1685 "at least one frame must have been presented"
1686 );
1687 }
1688}