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 if let Some(cache) = &mut self.frame_cache
552 && !self.rgba_buf.is_empty()
553 {
554 cache.insert(pts, self.rgba_buf.clone(), frame.width(), frame.height());
555 }
556 }
557 }
558 }
559
560 let _ = self.event_tx.try_send(PlayerEvent::Eof);
561 if let Some(sink) = self.sink.as_mut() {
562 sink.flush();
563 }
564 Ok(())
565 }
566
567 fn present_frame(&mut self, frame: &ff_format::VideoFrame) {
568 let pts = frame.timestamp().as_duration();
569 self.current_pts.store(
570 u64::try_from(pts.as_micros()).unwrap_or(u64::MAX),
571 Ordering::Relaxed,
572 );
573 let Some(sink) = self.sink.as_mut() else {
574 return;
575 };
576 let width = frame.width();
577 let height = frame.height();
578 if self.sws.convert(frame, &mut self.rgba_buf) {
579 sink.push_frame(&self.rgba_buf, width, height, pts);
580 }
581 }
582
583 fn restart_audio_from(&mut self, pts: Duration) {
584 if let Some(buf) = &self.audio_buf {
585 buf.lock()
586 .unwrap_or_else(std::sync::PoisonError::into_inner)
587 .clear();
588 }
589 if let Some(cancel) = &self.audio_cancel {
590 cancel.store(true, Ordering::Release);
591 }
592 drop(self.audio_handle.take());
593 if let Some(buf) = &self.audio_buf {
594 let new_cancel = Arc::new(AtomicBool::new(false));
595 let handle = spawn_audio_thread(
596 self.active_path.clone(),
597 pts,
598 Arc::clone(buf),
599 Arc::clone(&new_cancel),
600 );
601 self.audio_cancel = Some(new_cancel);
602 self.audio_handle = Some(handle);
603 }
604 }
605
606 fn activate_proxy(&mut self, proxy_path: &Path) -> Result<(), PreviewError> {
607 let info = ff_probe::open(proxy_path)?;
608 let fps = info.frame_rate().unwrap_or(30.0).max(1.0);
609 let decode_buf = DecodeBuffer::open(proxy_path)
610 .hardware_accel(self.hw_accel)
611 .build()?;
612
613 if let Some(cancel) = &self.audio_cancel {
614 cancel.store(true, Ordering::Release);
615 }
616 if let Some(buf) = &self.audio_buf {
617 buf.lock()
618 .unwrap_or_else(std::sync::PoisonError::into_inner)
619 .clear();
620 }
621 drop(self.audio_handle.take());
622
623 let (clock, audio_buf, audio_cancel, audio_handle) = if info.has_audio() {
624 let sample_rate = info.sample_rate().unwrap_or(48_000);
625 let buf = Arc::new(Mutex::new(VecDeque::<f32>::new()));
626 let cancel = Arc::new(AtomicBool::new(false));
627 let handle = spawn_audio_thread(
628 proxy_path.to_path_buf(),
629 Duration::ZERO,
630 Arc::clone(&buf),
631 Arc::clone(&cancel),
632 );
633 let clock = MasterClock::Audio {
634 samples_consumed: Arc::new(AtomicU64::new(0)),
635 sample_rate,
636 };
637 (clock, Some(buf), Some(cancel), Some(handle))
638 } else {
639 log::debug!(
640 "proxy has no audio, using system clock path={}",
641 proxy_path.display()
642 );
643 let clock = MasterClock::System {
644 started_at: Instant::now(),
645 base_pts: Duration::ZERO,
646 };
647 (clock, None, None, None)
648 };
649
650 self.active_path = proxy_path.to_path_buf();
651 self.fps = fps;
652 self.decode_buf = Some(decode_buf);
653 self.clock = clock;
654 self.audio_buf = audio_buf;
655 self.audio_cancel = audio_cancel;
656 self.audio_handle = audio_handle;
657 Ok(())
658 }
659}
660
661impl Drop for PlayerRunner {
662 fn drop(&mut self) {
663 if let Some(cancel) = &self.audio_cancel {
664 cancel.store(true, Ordering::Release);
665 }
666 if let Some(h) = self.audio_handle.take() {
667 let _ = h.join();
668 }
669 }
670}
671
672pub struct PreviewPlayer {
697 path: PathBuf,
698 decode_buf: Option<DecodeBuffer>,
700 fps: f64,
701 clock: Option<MasterClock>,
703 audio_buf: Option<Arc<Mutex<VecDeque<f32>>>>,
704 audio_cancel: Option<Arc<AtomicBool>>,
705 audio_handle: Option<JoinHandle<()>>,
706 duration_millis: u64,
707 active_path: PathBuf,
708}
709
710impl PreviewPlayer {
711 pub fn open(path: impl AsRef<Path>) -> Result<Self, PreviewError> {
722 let path = path.as_ref();
723 let info = ff_probe::open(path)?;
724
725 if !info.has_video() && !info.has_audio() {
726 return Err(PreviewError::Ffmpeg {
727 code: -1,
728 message: "file has neither a video nor an audio stream".into(),
729 });
730 }
731
732 let fps = info.frame_rate().unwrap_or(30.0).max(1.0);
733
734 let d = info.duration();
735 let duration_millis = if d.is_zero() {
736 u64::MAX
737 } else {
738 u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
739 };
740
741 let clock = if info.has_audio() {
742 let sample_rate = info.sample_rate().unwrap_or(48_000);
743 MasterClock::Audio {
744 samples_consumed: Arc::new(AtomicU64::new(0)),
745 sample_rate,
746 }
747 } else {
748 log::debug!(
749 "using system clock fallback path={} no_audio=true",
750 path.display()
751 );
752 MasterClock::System {
753 started_at: Instant::now(),
754 base_pts: Duration::ZERO,
755 }
756 };
757
758 let decode_buf = if info.has_video() {
759 Some(DecodeBuffer::open(path).build()?)
760 } else {
761 log::debug!(
762 "audio-only file; skipping video decode buffer path={}",
763 path.display()
764 );
765 None
766 };
767
768 let (audio_buf, audio_cancel, audio_handle) = if let MasterClock::Audio { .. } = &clock {
769 let buf = Arc::new(Mutex::new(VecDeque::<f32>::new()));
770 let cancel = Arc::new(AtomicBool::new(false));
771 let handle = spawn_audio_thread(
772 path.to_path_buf(),
773 Duration::ZERO,
774 Arc::clone(&buf),
775 Arc::clone(&cancel),
776 );
777 (Some(buf), Some(cancel), Some(handle))
778 } else {
779 (None, None, None)
780 };
781
782 Ok(PreviewPlayer {
783 path: path.to_path_buf(),
784 decode_buf,
785 fps,
786 clock: Some(clock),
787 audio_buf,
788 audio_cancel,
789 audio_handle,
790 duration_millis,
791 active_path: path.to_path_buf(),
792 })
793 }
794
795 #[must_use]
807 #[allow(clippy::expect_used)]
808 pub fn split(mut self) -> (PlayerRunner, PlayerHandle) {
809 let current_pts = Arc::new(AtomicU64::new(0));
810 let paused = Arc::new(AtomicBool::new(false));
811 let stopped = Arc::new(AtomicBool::new(false));
812 let (cmd_tx, cmd_rx) = mpsc::sync_channel(CHANNEL_CAP);
813 let (event_tx, event_rx) = mpsc::sync_channel(CHANNEL_CAP);
814
815 let clock = self.clock.take().expect("clock consumed before split");
816 let samples_consumed = match &clock {
817 MasterClock::Audio {
818 samples_consumed, ..
819 } => Some(Arc::clone(samples_consumed)),
820 MasterClock::System { .. } => None,
821 };
822
823 let audio_buf_for_handle = self.audio_buf.clone();
824 let duration_millis = self.duration_millis;
825
826 let runner = PlayerRunner {
827 path: self.path.clone(),
828 cmd_rx,
829 event_tx,
830 decode_buf: self.decode_buf.take(),
831 fps: self.fps,
832 sink: None,
833 clock,
834 audio_buf: self.audio_buf.take(),
835 audio_cancel: self.audio_cancel.take(),
836 audio_handle: self.audio_handle.take(),
837 sws: super::playback_inner::SwsRgbaConverter::new(),
838 rgba_buf: Vec::new(),
839 active_path: self.active_path.clone(),
840 current_pts: Arc::clone(¤t_pts),
841 paused: Arc::clone(&paused),
842 stopped: Arc::clone(&stopped),
843 av_offset_ms: 0,
844 rate: 1.0,
845 duration_millis,
846 frame_cache: None,
847 hw_accel: HardwareAccel::Auto,
848 };
849
850 let handle = PlayerHandle {
851 cmd_tx,
852 event_rx: Arc::new(Mutex::new(event_rx)),
853 current_pts,
854 audio_buf: audio_buf_for_handle,
855 samples_consumed,
856 audio_mixer: None,
857 paused,
858 stopped,
859 duration_millis,
860 };
861
862 (runner, handle)
863 }
864}
865
866impl Drop for PreviewPlayer {
867 fn drop(&mut self) {
868 if let Some(cancel) = &self.audio_cancel {
869 cancel.store(true, Ordering::Release);
870 }
871 if let Some(h) = self.audio_handle.take() {
872 let _ = h.join();
873 }
874 }
875}
876
877fn spawn_audio_thread(
880 path: PathBuf,
881 start_pts: Duration,
882 buf: Arc<Mutex<VecDeque<f32>>>,
883 cancel: Arc<AtomicBool>,
884) -> JoinHandle<()> {
885 thread::spawn(move || {
886 let mut decoder = match AudioDecoder::open(&path)
887 .output_format(SampleFormat::F32)
888 .output_sample_rate(48_000)
889 .output_channels(2)
890 .build()
891 {
892 Ok(d) => d,
893 Err(e) => {
894 log::warn!("audio decode thread open failed error={e}");
895 return;
896 }
897 };
898
899 if start_pts != Duration::ZERO
900 && let Err(e) = decoder.seek(start_pts, SeekMode::Backward)
901 {
902 log::warn!("audio seek failed pts={start_pts:?} error={e}");
903 }
904
905 loop {
906 if cancel.load(Ordering::Acquire) {
907 break;
908 }
909
910 let buf_len = buf
911 .lock()
912 .unwrap_or_else(std::sync::PoisonError::into_inner)
913 .len();
914 if buf_len >= AUDIO_MAX_BUF {
915 thread::sleep(Duration::from_millis(1));
916 continue;
917 }
918
919 match decoder.decode_one() {
920 Ok(Some(frame)) => {
921 let samples = super::playback_inner::audio_frame_to_f32(&frame);
922 if !samples.is_empty() {
923 let mut guard = buf
924 .lock()
925 .unwrap_or_else(std::sync::PoisonError::into_inner);
926 let space = AUDIO_MAX_BUF.saturating_sub(guard.len());
927 guard.extend(samples.into_iter().take(space));
928 }
929 }
930 Ok(None) => break,
931 Err(e) => {
932 log::warn!("audio decode error error={e}");
933 break;
934 }
935 }
936 }
937 })
938}
939
940#[cfg(test)]
943mod tests {
944 use super::*;
945
946 fn test_video_path() -> PathBuf {
947 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/video/gameplay.mp4")
948 }
949
950 fn test_audio_path() -> PathBuf {
951 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/audio/konekonoosanpo.mp3")
952 }
953
954 #[test]
957 fn preview_player_open_should_fail_for_nonexistent_file() {
958 let result = PreviewPlayer::open("nonexistent_preview.mp4");
959 assert!(
960 result.is_err(),
961 "open() must return Err for a non-existent file"
962 );
963 }
964
965 #[test]
968 fn player_handle_play_pause_should_update_paused_flag_immediately() {
969 let path = test_video_path();
970 let (_runner, handle) = match PreviewPlayer::open(&path) {
971 Ok(p) => p.split(),
972 Err(e) => {
973 println!("skipping: video file not available: {e}");
974 return;
975 }
976 };
977
978 assert!(!handle.paused.load(Ordering::Relaxed));
979 assert!(!handle.stopped.load(Ordering::Relaxed));
980
981 handle.pause();
982 assert!(handle.paused.load(Ordering::Relaxed));
983
984 handle.play();
985 assert!(!handle.paused.load(Ordering::Relaxed));
986 assert!(!handle.stopped.load(Ordering::Relaxed));
987
988 handle.stop();
989 assert!(handle.stopped.load(Ordering::Relaxed));
990 }
991
992 #[test]
995 fn player_runner_run_should_deliver_frames_to_sink() {
996 struct CountSink(Arc<Mutex<usize>>);
997 impl FrameSink for CountSink {
998 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
999 *self
1000 .0
1001 .lock()
1002 .unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
1003 }
1004 }
1005
1006 let path = test_video_path();
1007 let (mut runner, _handle) = match PreviewPlayer::open(&path) {
1008 Ok(p) => p.split(),
1009 Err(e) => {
1010 println!("skipping: video file not available: {e}");
1011 return;
1012 }
1013 };
1014
1015 let count = Arc::new(Mutex::new(0usize));
1016 runner.set_sink(Box::new(CountSink(Arc::clone(&count))));
1017
1018 match runner.run() {
1019 Ok(()) => {}
1020 Err(e) => {
1021 println!("skipping: run() error: {e}");
1022 return;
1023 }
1024 }
1025
1026 let frames = *count
1027 .lock()
1028 .unwrap_or_else(std::sync::PoisonError::into_inner);
1029 assert!(
1030 frames > 0,
1031 "run() must deliver at least one frame to the sink"
1032 );
1033 }
1034
1035 #[test]
1038 fn pop_audio_samples_should_return_empty_when_paused() {
1039 let path = test_video_path();
1040 let (_runner, handle) = match PreviewPlayer::open(&path) {
1041 Ok(p) => p.split(),
1042 Err(e) => {
1043 println!("skipping: video file not available: {e}");
1044 return;
1045 }
1046 };
1047 handle.pause();
1048 let samples = handle.pop_audio_samples(1024);
1049 assert!(
1050 samples.is_empty(),
1051 "pop_audio_samples() must return empty while paused"
1052 );
1053 }
1054
1055 #[test]
1056 fn pop_audio_samples_should_return_empty_when_stopped() {
1057 let path = test_video_path();
1058 let (_runner, handle) = match PreviewPlayer::open(&path) {
1059 Ok(p) => p.split(),
1060 Err(e) => {
1061 println!("skipping: video file not available: {e}");
1062 return;
1063 }
1064 };
1065 handle.stop();
1066 let samples = handle.pop_audio_samples(1024);
1067 assert!(
1068 samples.is_empty(),
1069 "pop_audio_samples() must return empty while stopped"
1070 );
1071 }
1072
1073 #[test]
1074 fn pop_audio_samples_should_return_empty_for_zero_n_samples() {
1075 let path = test_video_path();
1076 let (_runner, handle) = match PreviewPlayer::open(&path) {
1077 Ok(p) => p.split(),
1078 Err(e) => {
1079 println!("skipping: video file not available: {e}");
1080 return;
1081 }
1082 };
1083 handle.play();
1084 let samples = handle.pop_audio_samples(0);
1085 assert!(
1086 samples.is_empty(),
1087 "pop_audio_samples(0) must always return empty"
1088 );
1089 }
1090
1091 #[test]
1092 fn pop_audio_samples_should_be_callable_via_cloned_handle() {
1093 let path = test_video_path();
1094 let (_runner, handle) = match PreviewPlayer::open(&path) {
1095 Ok(p) => p.split(),
1096 Err(e) => {
1097 println!("skipping: video file not available: {e}");
1098 return;
1099 }
1100 };
1101 let shared = handle.clone();
1102 let _samples = shared.pop_audio_samples(0);
1103 }
1104
1105 #[test]
1106 fn pop_audio_samples_clock_increment_should_equal_half_sample_count() {
1107 let stereo_samples: usize = 9_600;
1108 let expected_frames: u64 = (stereo_samples / 2) as u64;
1109 assert_eq!(
1110 expected_frames, 4_800,
1111 "9600 stereo samples must yield 4800 clock frames"
1112 );
1113 let pts = Duration::from_secs_f64(f64::from(48_000u32).recip() * expected_frames as f64);
1114 assert!(
1115 (pts.as_secs_f64() - 0.1).abs() < 1e-6,
1116 "4800 frames at 48 kHz must equal 100 ms; got {pts:?}"
1117 );
1118 }
1119
1120 #[test]
1123 fn current_pts_should_return_zero_before_first_frame() {
1124 let path = test_video_path();
1125 let (_runner, handle) = match PreviewPlayer::open(&path) {
1126 Ok(p) => p.split(),
1127 Err(e) => {
1128 println!("skipping: video file not available: {e}");
1129 return;
1130 }
1131 };
1132 assert_eq!(
1133 handle.current_pts(),
1134 Duration::ZERO,
1135 "current_pts() must be ZERO before any frame is presented"
1136 );
1137 }
1138
1139 #[test]
1140 fn duration_should_return_some_for_file_with_known_duration() {
1141 let path = test_video_path();
1142 let (_runner, handle) = match PreviewPlayer::open(&path) {
1143 Ok(p) => p.split(),
1144 Err(e) => {
1145 println!("skipping: video file not available: {e}");
1146 return;
1147 }
1148 };
1149 assert!(
1150 handle.duration().is_some(),
1151 "duration() must return Some for a file with a known container duration"
1152 );
1153 let d = handle.duration().unwrap();
1154 assert!(
1155 d > Duration::ZERO,
1156 "duration() must be positive for a valid media file; got {d:?}"
1157 );
1158 }
1159
1160 #[test]
1161 fn duration_should_return_none_when_duration_millis_is_sentinel() {
1162 let sentinel = u64::MAX;
1163 let result: Option<Duration> = if sentinel == u64::MAX {
1164 None
1165 } else {
1166 Some(Duration::from_millis(sentinel))
1167 };
1168 assert!(result.is_none(), "sentinel u64::MAX must map to None");
1169
1170 let valid = 5_000u64;
1171 let result: Option<Duration> = if valid == u64::MAX {
1172 None
1173 } else {
1174 Some(Duration::from_millis(valid))
1175 };
1176 assert_eq!(result, Some(Duration::from_secs(5)));
1177 }
1178
1179 #[test]
1180 fn current_pts_should_advance_after_frames_are_presented() {
1181 struct PtsSink(Arc<Mutex<Option<Duration>>>);
1182 impl FrameSink for PtsSink {
1183 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, pts: Duration) {
1184 *self
1185 .0
1186 .lock()
1187 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(pts);
1188 }
1189 }
1190
1191 let path = test_video_path();
1192 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1193 Ok(p) => p.split(),
1194 Err(e) => {
1195 println!("skipping: video file not available: {e}");
1196 return;
1197 }
1198 };
1199
1200 let last_pts = Arc::new(Mutex::new(None::<Duration>));
1201 runner.set_sink(Box::new(PtsSink(Arc::clone(&last_pts))));
1202 let _ = runner.run();
1203
1204 let sink_pts = last_pts
1205 .lock()
1206 .unwrap_or_else(std::sync::PoisonError::into_inner)
1207 .unwrap_or(Duration::ZERO);
1208 let player_pts = handle.current_pts();
1209 let diff = sink_pts.abs_diff(player_pts);
1210 assert!(
1211 diff <= Duration::from_millis(1),
1212 "current_pts() must be within 1 ms of the last sink PTS; \
1213 player_pts={player_pts:?} sink_pts={sink_pts:?} diff={diff:?}"
1214 );
1215 }
1216
1217 #[test]
1220 fn seek_coarse_should_delegate_to_decode_buffer() {
1221 let path = test_video_path();
1222 let (runner, handle) = match PreviewPlayer::open(&path) {
1223 Ok(p) => p.split(),
1224 Err(e) => {
1225 println!("skipping: video file not available: {e}");
1226 return;
1227 }
1228 };
1229
1230 let target = Duration::from_secs(1);
1231 handle.seek(target);
1232
1233 let handle_thread = handle.clone();
1235 thread::spawn(move || {
1236 thread::sleep(Duration::from_millis(500));
1237 handle_thread.stop();
1238 });
1239
1240 match runner.run() {
1241 Ok(()) => {}
1242 Err(e) => {
1243 println!("skipping: run() error: {e}");
1244 }
1245 }
1246 }
1247
1248 #[test]
1251 fn use_proxy_if_available_should_return_false_when_no_proxy_in_dir() {
1252 let path = test_video_path();
1253 let (mut runner, _handle) = match PreviewPlayer::open(&path) {
1254 Ok(p) => p.split(),
1255 Err(e) => {
1256 println!("skipping: video file not available: {e}");
1257 return;
1258 }
1259 };
1260 let tmp = std::env::temp_dir().join("ff_preview_no_proxy_dir_test");
1261 let _ = std::fs::create_dir_all(&tmp);
1262 let found = runner.use_proxy_if_available(&tmp);
1263 assert!(
1264 !found,
1265 "must return false when no proxy files exist in the directory"
1266 );
1267 }
1268
1269 #[test]
1270 fn active_source_should_return_original_path_before_proxy_activation() {
1271 let path = test_video_path();
1272 let (runner, _handle) = match PreviewPlayer::open(&path) {
1273 Ok(p) => p.split(),
1274 Err(e) => {
1275 println!("skipping: video file not available: {e}");
1276 return;
1277 }
1278 };
1279 assert_eq!(
1280 runner.active_source(),
1281 path.as_path(),
1282 "active_source() must equal the original path before any proxy activation"
1283 );
1284 }
1285
1286 #[test]
1289 fn set_rate_should_accept_positive_value() {
1290 let path = test_video_path();
1291 let (_runner, handle) = match PreviewPlayer::open(&path) {
1292 Ok(p) => p.split(),
1293 Err(e) => {
1294 println!("skipping: video file not available: {e}");
1295 return;
1296 }
1297 };
1298 handle.set_rate(2.0);
1300 handle.set_rate(0.5);
1301 }
1302
1303 #[test]
1304 fn set_av_offset_default_should_be_zero() {
1305 use std::sync::atomic::{AtomicI64, Ordering};
1306 let offset = AtomicI64::new(0);
1307 assert_eq!(offset.load(Ordering::Relaxed), 0);
1308 }
1309
1310 #[test]
1311 fn positive_av_offset_should_reduce_adjusted_video_pts() {
1312 let video_pts = Duration::from_millis(1_000);
1313 let offset_ms: i64 = 200;
1314 let adjusted = if offset_ms >= 0 {
1315 let offset = Duration::from_millis(offset_ms as u64);
1316 video_pts.saturating_sub(offset)
1317 } else {
1318 let offset = Duration::from_millis(offset_ms.unsigned_abs());
1319 video_pts + offset
1320 };
1321 assert_eq!(
1322 adjusted,
1323 Duration::from_millis(800),
1324 "positive offset must reduce adjusted_video_pts by offset amount"
1325 );
1326 }
1327
1328 #[test]
1329 fn negative_av_offset_should_increase_adjusted_video_pts() {
1330 let video_pts = Duration::from_millis(1_000);
1331 let offset_ms: i64 = -200;
1332 let adjusted = if offset_ms >= 0 {
1333 let offset = Duration::from_millis(offset_ms as u64);
1334 video_pts.saturating_sub(offset)
1335 } else {
1336 let offset = Duration::from_millis(offset_ms.unsigned_abs());
1337 video_pts + offset
1338 };
1339 assert_eq!(
1340 adjusted,
1341 Duration::from_millis(1_200),
1342 "negative offset must increase adjusted_video_pts by offset amount"
1343 );
1344 }
1345
1346 #[test]
1347 fn positive_av_offset_at_zero_pts_should_saturate_to_zero() {
1348 let video_pts = Duration::ZERO;
1349 let offset_ms: i64 = 100;
1350 let adjusted = video_pts.saturating_sub(Duration::from_millis(offset_ms as u64));
1351 assert_eq!(
1352 adjusted,
1353 Duration::ZERO,
1354 "saturating_sub on zero pts must clamp to zero not underflow"
1355 );
1356 }
1357
1358 #[test]
1361 fn audio_only_open_should_succeed() {
1362 let path = test_audio_path();
1363 match PreviewPlayer::open(&path) {
1364 Ok(player) => {
1365 let (runner, handle) = player.split();
1366 assert!(
1368 runner.decode_buf.is_none(),
1369 "audio-only runner must have no video decode buffer"
1370 );
1371 assert!(
1373 handle.audio_buf.is_some(),
1374 "audio-only handle must have an audio ring buffer"
1375 );
1376 }
1377 Err(e) => {
1378 println!("skipping: audio file not available: {e}");
1379 }
1380 }
1381 }
1382
1383 #[test]
1384 fn audio_only_run_should_return_ok_without_video_frames() {
1385 let path = test_audio_path();
1386 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1387 Ok(p) => p.split(),
1388 Err(e) => {
1389 println!("skipping: audio file not available: {e}");
1390 return;
1391 }
1392 };
1393
1394 struct CountingSink(usize);
1395 impl FrameSink for CountingSink {
1396 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1397 self.0 += 1;
1398 }
1399 }
1400 runner.set_sink(Box::new(CountingSink(0)));
1401
1402 let handle_thread = handle.clone();
1403 thread::spawn(move || {
1404 thread::sleep(Duration::from_millis(150));
1405 handle_thread.stop();
1406 });
1407
1408 let result = runner.run();
1409 assert!(
1410 result.is_ok(),
1411 "run() on an audio-only player must return Ok; got {result:?}"
1412 );
1413 assert_eq!(
1414 handle.current_pts(),
1415 Duration::ZERO,
1416 "current_pts() must remain ZERO for audio-only playback (no video frames)"
1417 );
1418 }
1419
1420 #[test]
1421 fn audio_only_seek_should_not_fail_for_valid_target() {
1422 let path = test_audio_path();
1423 let (_runner, handle) = match PreviewPlayer::open(&path) {
1424 Ok(p) => p.split(),
1425 Err(e) => {
1426 println!("skipping: audio file not available: {e}");
1427 return;
1428 }
1429 };
1430 handle.seek(Duration::from_secs(1));
1432 }
1433
1434 #[test]
1437 #[ignore = "requires assets/video/gameplay.mp4; run with -- --include-ignored"]
1438 fn seek_should_deliver_seek_completed_event_via_poll_event() {
1439 let path = test_video_path();
1440 if !path.exists() {
1441 println!("skipping: video file not found at {}", path.display());
1442 return;
1443 }
1444
1445 let (runner, handle) = match PreviewPlayer::open(&path) {
1446 Ok(p) => p.split(),
1447 Err(e) => {
1448 println!("skipping: open failed: {e}");
1449 return;
1450 }
1451 };
1452
1453 let handle_bg = handle.clone();
1454 let bg = thread::spawn(move || {
1455 let _ = runner.run();
1456 });
1457
1458 thread::sleep(Duration::from_millis(50));
1460 let target = Duration::from_secs(1);
1461 handle.seek(target);
1462
1463 let deadline = Instant::now() + Duration::from_secs(2);
1465 let event = loop {
1466 if let Some(e) = handle.poll_event() {
1467 break Some(e);
1468 }
1469 if Instant::now() > deadline {
1470 break None;
1471 }
1472 thread::sleep(Duration::from_millis(10));
1473 };
1474
1475 handle_bg.stop();
1476 let _ = bg.join();
1477
1478 match event {
1479 Some(PlayerEvent::SeekCompleted(pts)) => {
1480 assert!(
1481 pts >= target.saturating_sub(Duration::from_millis(100)),
1482 "SeekCompleted pts must be near the requested target; \
1483 target={target:?} pts={pts:?}"
1484 );
1485 }
1486 Some(PlayerEvent::Eof) => {
1487 panic!("received Eof before SeekCompleted — file may be too short");
1488 }
1489 Some(PlayerEvent::PositionUpdate(_) | PlayerEvent::Error(_)) | None => {
1490 panic!("no PlayerEvent::SeekCompleted received within 2 seconds");
1491 }
1492 }
1493 }
1494
1495 #[test]
1498 fn position_update_and_error_event_variants_should_be_accessible() {
1499 let _ = PlayerEvent::PositionUpdate(Duration::ZERO);
1500 let _ = PlayerEvent::Error("test error".to_string());
1501 }
1502
1503 #[test]
1504 fn eof_event_should_be_delivered_after_run_completes() {
1505 let path = test_audio_path();
1506 let (runner, handle) = match PreviewPlayer::open(&path) {
1507 Ok(p) => p.split(),
1508 Err(e) => {
1509 println!("skipping: {e}");
1510 return;
1511 }
1512 };
1513
1514 let handle_stop = handle.clone();
1516 thread::spawn(move || {
1517 thread::sleep(Duration::from_millis(150));
1518 handle_stop.stop();
1519 });
1520
1521 let _ = runner.run();
1522 let events: Vec<_> = std::iter::from_fn(|| handle.poll_event()).collect();
1523 assert!(
1524 events.iter().any(|e| matches!(e, PlayerEvent::Eof)),
1525 "Eof event must be delivered after run() returns; collected {} events",
1526 events.len()
1527 );
1528 }
1529
1530 #[test]
1531 #[ignore = "requires assets/video/gameplay.mp4; run with -- --include-ignored"]
1532 fn position_update_should_be_emitted_for_each_video_frame() {
1533 let path =
1534 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/video/gameplay.mp4");
1535 if !path.exists() {
1536 println!("skipping: video asset not found");
1537 return;
1538 }
1539
1540 use std::sync::{Arc, Mutex};
1541 struct CountSink {
1542 count: Arc<Mutex<usize>>,
1543 max: usize,
1544 handle: PlayerHandle,
1545 }
1546 impl FrameSink for CountSink {
1547 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1548 let mut g = self
1549 .count
1550 .lock()
1551 .unwrap_or_else(std::sync::PoisonError::into_inner);
1552 *g += 1;
1553 if *g >= self.max {
1554 self.handle.stop();
1555 }
1556 }
1557 }
1558
1559 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1560 Ok(p) => p.split(),
1561 Err(e) => {
1562 println!("skipping: {e}");
1563 return;
1564 }
1565 };
1566
1567 let count = Arc::new(Mutex::new(0usize));
1568 runner.set_sink(Box::new(CountSink {
1569 count: Arc::clone(&count),
1570 max: 20,
1571 handle: handle.clone(),
1572 }));
1573 let _ = runner.run();
1574
1575 let frames = *count
1576 .lock()
1577 .unwrap_or_else(std::sync::PoisonError::into_inner);
1578 let position_updates: Vec<_> = std::iter::from_fn(|| handle.poll_event())
1579 .filter(|e| matches!(e, PlayerEvent::PositionUpdate(_)))
1580 .collect();
1581
1582 assert!(
1583 !position_updates.is_empty(),
1584 "at least one PositionUpdate event must be emitted; frames delivered={frames}"
1585 );
1586 assert!(
1587 position_updates.len() <= frames,
1588 "PositionUpdate count ({}) must not exceed frame count ({frames})",
1589 position_updates.len()
1590 );
1591 }
1592
1593 #[test]
1596 fn hardware_accel_variants_should_be_accessible_on_player_runner() {
1597 let _ = HardwareAccel::Auto;
1599 let _ = HardwareAccel::None;
1600 let _ = HardwareAccel::Nvdec;
1601 let _ = HardwareAccel::Qsv;
1602 let _ = HardwareAccel::Amf;
1603 let _ = HardwareAccel::VideoToolbox;
1604 let _ = HardwareAccel::Vaapi;
1605 }
1606
1607 #[test]
1608 fn set_hardware_accel_none_should_complete_without_error_on_audio_only_file() {
1609 let path = test_audio_path();
1613 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1614 Ok(p) => p.split(),
1615 Err(e) => {
1616 println!("skipping: audio file not available: {e}");
1617 return;
1618 }
1619 };
1620
1621 runner.set_hardware_accel(HardwareAccel::None);
1622 assert_eq!(runner.hw_accel, HardwareAccel::None);
1623
1624 let handle_stop = handle.clone();
1625 thread::spawn(move || {
1626 thread::sleep(Duration::from_millis(150));
1627 handle_stop.stop();
1628 });
1629
1630 let result = runner.run();
1631 assert!(
1632 result.is_ok(),
1633 "run() with HardwareAccel::None must return Ok; got {result:?}"
1634 );
1635 }
1636
1637 #[test]
1638 #[ignore = "requires assets/video/gameplay.mp4 and hardware decoder; run with -- --include-ignored"]
1639 fn hardware_accel_auto_should_deliver_frames_on_video_file() {
1640 let path = test_video_path();
1641 let (mut runner, handle) = match PreviewPlayer::open(&path) {
1642 Ok(p) => p.split(),
1643 Err(e) => {
1644 println!("skipping: video file not available: {e}");
1645 return;
1646 }
1647 };
1648
1649 runner.set_hardware_accel(HardwareAccel::Auto);
1650
1651 struct CountSink {
1652 count: usize,
1653 max: usize,
1654 handle: PlayerHandle,
1655 }
1656 impl FrameSink for CountSink {
1657 fn push_frame(&mut self, _rgba: &[u8], _w: u32, _h: u32, _pts: Duration) {
1658 self.count += 1;
1659 if self.count >= self.max {
1660 self.handle.stop();
1661 }
1662 }
1663 }
1664 runner.set_sink(Box::new(CountSink {
1665 count: 0,
1666 max: 5,
1667 handle: handle.clone(),
1668 }));
1669
1670 let result = runner.run();
1671 assert!(
1672 result.is_ok(),
1673 "run() with HardwareAccel::Auto must return Ok; got {result:?}"
1674 );
1675 assert!(
1676 handle.current_pts() > Duration::ZERO,
1677 "at least one frame must have been presented"
1678 );
1679 }
1680}