Skip to main content

playback/
lib.rs

1//! `playback` — player state machine that pumps decoded frames into wisp.
2//!
3//! Sits between [`decode::VideoStream`] (the upstream codec backend) and
4//! `wisp::VideoTexture` (the GPU upload target). The shell (Tauri / native
5//! winit) drives it via:
6//!
7//! - [`Player::play`] / [`Player::pause`] — transport control
8//! - [`Player::tick(dt)`](Player::tick) — once per render frame; advances
9//!   playback time and uploads any frames whose presentation timestamp has
10//!   come due
11//!
12//! # Quick start
13//!
14//! ```rust,no_run
15//! use std::time::Duration;
16//! use decode::mock::MockVideoStream;
17//! use playback::Player;
18//! use pollster::block_on;
19//! use wisp::application::{AppConfig, Application};
20//!
21//! let app = block_on(Application::new(AppConfig::default())).unwrap();
22//! let stream = MockVideoStream::scrolling_gradient(640, 360, 90);
23//! let mut player = Player::new(&app, Box::new(stream));
24//! player.play();
25//! player.tick(&app, Duration::from_millis(33));   // ~one source frame
26//! ```
27
28use std::time::Duration;
29
30use decode::{VideoFrame, VideoStream};
31use wisp::Texture;
32use wisp::VideoTexture;
33use wisp::application::Application;
34
35pub mod editor_player;
36pub use editor_player::EditorPlayer;
37
38/// Transport state.
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40pub enum PlayState {
41    /// No frames advance, but [`Player::texture`] still serves the last
42    /// uploaded frame.
43    #[default]
44    Paused,
45    /// `tick` advances `elapsed` and pulls frames as they come due.
46    Playing,
47    /// The stream has yielded `None`. Equivalent to `Paused` but distinct
48    /// so the UI can swap "Pause" for "Replay" etc.
49    Ended,
50}
51
52/// Player state machine. Owns the upstream stream and the GPU texture
53/// holding the current frame.
54pub struct Player {
55    stream: Box<dyn VideoStream + Send>,
56    texture: VideoTexture,
57    state: PlayState,
58    elapsed: Duration,
59    /// Wallclock time at which the *next* frame in the stream becomes
60    /// presentable. Initialised to zero so the first `tick` always pulls.
61    next_due: Duration,
62    last_uploaded_pts: Option<f64>,
63    last_uploaded_index: Option<u64>,
64}
65
66impl Player {
67    /// Build a player around an arbitrary [`VideoStream`].
68    ///
69    /// Allocates a `VideoTexture` matching the stream dimensions; that
70    /// allocation is reused for every frame.
71    #[must_use]
72    pub fn new(app: &Application, stream: Box<dyn VideoStream + Send>) -> Self {
73        let texture = VideoTexture::new(app, stream.width(), stream.height());
74        Self {
75            stream,
76            texture,
77            state: PlayState::Paused,
78            elapsed: Duration::ZERO,
79            next_due: Duration::ZERO,
80            last_uploaded_pts: None,
81            last_uploaded_index: None,
82        }
83    }
84
85    /// Transition to [`PlayState::Playing`]. No-op if already playing or
86    /// the stream has ended.
87    pub fn play(&mut self) {
88        if self.state == PlayState::Paused {
89            self.state = PlayState::Playing;
90        }
91    }
92
93    /// Transition to [`PlayState::Paused`]. No-op if not playing.
94    pub fn pause(&mut self) {
95        if self.state == PlayState::Playing {
96            self.state = PlayState::Paused;
97        }
98    }
99
100    /// Advance playback by `dt`. Pulls and uploads as many frames as have
101    /// come due — typically zero or one per render tick, but this handles
102    /// burst catch-up (multiple frames due) cleanly.
103    ///
104    /// No-op when paused / ended. Returns the number of frames uploaded
105    /// so callers can drive a redraw signal off it.
106    pub fn tick(&mut self, app: &Application, dt: Duration) -> u32 {
107        if self.state != PlayState::Playing {
108            return 0;
109        }
110        self.elapsed += dt;
111
112        let mut uploaded = 0u32;
113        while self.elapsed >= self.next_due {
114            let Some(frame) = self.stream.next_frame() else {
115                self.state = PlayState::Ended;
116                break;
117            };
118            self.upload(app, &frame);
119            let frame_dt = Duration::from_secs_f64(1.0 / f64::from(self.stream.frame_rate()));
120            self.next_due += frame_dt;
121            uploaded += 1;
122        }
123        uploaded
124    }
125
126    fn upload(&mut self, app: &Application, frame: &VideoFrame) {
127        self.texture.upload_bgra(app, &frame.bgra);
128        self.last_uploaded_pts = Some(frame.pts_seconds);
129        self.last_uploaded_index = Some(frame.frame_index);
130    }
131
132    /// Borrow the current frame's GPU texture for use as a `Sprite` source.
133    #[must_use]
134    pub fn texture(&self) -> &Texture {
135        self.texture.texture()
136    }
137
138    /// Current transport state.
139    #[must_use]
140    pub fn state(&self) -> PlayState {
141        self.state
142    }
143
144    /// Total wallclock time advanced since `play()` first fired.
145    #[must_use]
146    pub fn elapsed(&self) -> Duration {
147        self.elapsed
148    }
149
150    /// Presentation timestamp of the last uploaded frame, if any.
151    #[must_use]
152    pub fn last_uploaded_pts(&self) -> Option<f64> {
153        self.last_uploaded_pts
154    }
155
156    /// Frame index of the last uploaded frame, if any.
157    #[must_use]
158    pub fn last_uploaded_index(&self) -> Option<u64> {
159        self.last_uploaded_index
160    }
161
162    /// Duration hint, derived from the stream's frame count + frame rate.
163    #[must_use]
164    pub fn duration_hint(&self) -> Option<Duration> {
165        let count = self.stream.frame_count_hint()?;
166        #[allow(
167            clippy::cast_precision_loss,
168            reason = "frame counts in practice fit comfortably in f64's 52-bit mantissa (2^52 frames at 60 fps ≈ 2.4 million years)"
169        )]
170        let count_f = count as f64;
171        Some(Duration::from_secs_f64(
172            count_f / f64::from(self.stream.frame_rate()),
173        ))
174    }
175}