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}