Skip to main content

ff_preview/playback/
async_player.rs

1//! Async wrapper around [`PlayerHandle`].
2//!
3//! This module is only compiled when the `tokio` feature is enabled.
4
5use std::path::Path;
6use std::time::Duration;
7
8use super::player::PreviewPlayer;
9use super::player_handle::PlayerHandle;
10use crate::error::PreviewError;
11use crate::event::PlayerEvent;
12
13// ── AsyncPreviewPlayer ────────────────────────────────────────────────────────
14
15/// Async wrapper around [`PlayerHandle`].
16///
17/// On creation, a `spawn_blocking` thread opens the file, splits the player,
18/// and starts the runner. All control methods delegate directly to the
19/// underlying [`PlayerHandle`] — no inner `Mutex`.
20///
21/// # Usage
22///
23/// ```ignore
24/// use ff_preview::AsyncPreviewPlayer;
25/// use std::time::Duration;
26///
27/// let player = AsyncPreviewPlayer::open("clip.mp4").await?;
28/// player.play();
29/// player.seek(Duration::from_secs(30));
30/// while let Some(event) = player.next_event().await { ... }
31/// ```
32#[derive(Clone)]
33pub struct AsyncPreviewPlayer {
34    handle: PlayerHandle,
35}
36
37impl AsyncPreviewPlayer {
38    /// Open a media file asynchronously.
39    ///
40    /// File I/O and codec initialisation run on a `spawn_blocking` thread.
41    /// The runner is also started on a dedicated blocking thread and runs until
42    /// [`stop`](Self::stop) is called or EOF is reached.
43    ///
44    /// # Errors
45    ///
46    /// Returns [`PreviewError`] if the file is missing, unreadable, or contains
47    /// neither a video nor an audio stream.
48    pub async fn open(path: impl AsRef<Path> + Send + 'static) -> Result<Self, PreviewError> {
49        let path = path.as_ref().to_path_buf();
50        let task = tokio::task::spawn_blocking(move || {
51            PreviewPlayer::open(&path).map(PreviewPlayer::split)
52        });
53        let (runner, handle) = task.await.map_err(|e| PreviewError::Ffmpeg {
54            code: 0,
55            message: format!("tokio task join error: {e}"),
56        })??;
57
58        tokio::task::spawn_blocking(move || {
59            let _ = runner.run();
60        });
61
62        Ok(Self { handle })
63    }
64
65    /// Resume playback.
66    pub fn play(&self) {
67        self.handle.play();
68    }
69
70    /// Pause playback.
71    pub fn pause(&self) {
72        self.handle.pause();
73    }
74
75    /// Stop the presentation loop.
76    pub fn stop(&self) {
77        self.handle.stop();
78    }
79
80    /// Seek to `pts`.
81    pub fn seek(&self, pts: Duration) {
82        self.handle.seek(pts);
83    }
84
85    /// Set the playback rate.
86    pub fn set_rate(&self, rate: f64) {
87        self.handle.set_rate(rate);
88    }
89
90    /// PTS of the most recently presented frame.
91    #[must_use]
92    pub fn current_pts(&self) -> Duration {
93        self.handle.current_pts()
94    }
95
96    /// Container-reported duration, or `None` for live / streaming sources.
97    #[must_use]
98    pub fn duration(&self) -> Option<Duration> {
99        self.handle.duration()
100    }
101
102    /// Pull up to `n` interleaved stereo `f32` PCM samples at 48 kHz.
103    #[must_use]
104    pub fn pop_audio_samples(&self, n: usize) -> Vec<f32> {
105        self.handle.pop_audio_samples(n)
106    }
107
108    /// Poll for the next [`PlayerEvent`] without blocking.
109    ///
110    /// Returns `None` when no events are pending.
111    #[must_use]
112    pub fn poll_event(&self) -> Option<PlayerEvent> {
113        self.handle.poll_event()
114    }
115
116    /// Await the next [`PlayerEvent`].
117    ///
118    /// Blocks in a `spawn_blocking` thread until an event arrives or the
119    /// channel is closed. Returns `None` when the runner has exited.
120    pub async fn next_event(&self) -> Option<PlayerEvent> {
121        let handle = self.handle.clone();
122        tokio::task::spawn_blocking(move || handle.recv_event())
123            .await
124            .ok()
125            .flatten()
126    }
127}
128
129impl Drop for AsyncPreviewPlayer {
130    fn drop(&mut self) {
131        self.handle.stop();
132    }
133}
134
135// ── Tests ─────────────────────────────────────────────────────────────────────
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn test_video_path() -> std::path::PathBuf {
142        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/video/gameplay.mp4")
143    }
144
145    #[test]
146    fn async_preview_player_is_send_and_sync() {
147        fn assert_send_sync<T: Send + Sync>() {}
148        assert_send_sync::<AsyncPreviewPlayer>();
149    }
150
151    #[test]
152    #[ignore = "requires FFmpeg and assets/video/gameplay.mp4; run with -- --include-ignored"]
153    fn async_preview_player_should_open_and_report_nonzero_duration() {
154        let path = test_video_path();
155        match tokio::runtime::Builder::new_current_thread()
156            .enable_all()
157            .build()
158        {
159            Ok(rt) => rt.block_on(async {
160                let player = match AsyncPreviewPlayer::open(path.clone()).await {
161                    Ok(p) => p,
162                    Err(e) => {
163                        println!("skipping: open failed: {e}");
164                        return;
165                    }
166                };
167                assert!(
168                    player.duration().is_some_and(|d| d > Duration::ZERO),
169                    "duration must be positive for a valid media file"
170                );
171            }),
172            Err(e) => println!("skipping: failed to build tokio runtime: {e}"),
173        }
174    }
175}