1use dioxus::prelude::*;
24use dioxuscut_core::Composition;
25use std::time::{Duration, Instant};
26
27use crate::controls::Controls;
28
29#[derive(Props, Clone, PartialEq)]
31pub struct PlayerProps {
32 pub width: u32,
34 pub height: u32,
36 pub fps: f64,
38 pub duration_in_frames: u32,
40 #[props(default = true)]
42 pub controls: bool,
43 #[props(default = 0)]
45 pub initial_frame: u32,
46 #[props(default = true)]
48 pub loop_playback: bool,
49 pub children: Element,
51}
52
53#[derive(Clone, Debug, PartialEq)]
55struct PlayerState {
56 frame: u32,
57 playing: bool,
58 duration: u32,
59 playback_started_at: Option<Instant>,
60 playback_started_frame: u32,
61 playback_cycle: u64,
62 seek_revision: u64,
63}
64
65#[derive(Clone, Copy, Debug, PartialEq)]
66pub(crate) struct PlayerPlaybackState {
67 pub playing: bool,
68 pub seek_revision: u64,
69}
70
71#[component]
76pub fn Player(props: PlayerProps) -> Element {
77 let duration = props.duration_in_frames;
78 let fps = props.fps;
79 let loop_playback = props.loop_playback;
80 let tick_duration = frame_duration(fps);
81
82 let initial_frame = props.initial_frame.min(duration.saturating_sub(1));
83 let mut state = use_signal(|| PlayerState {
84 frame: initial_frame,
85 playing: false,
86 duration,
87 playback_started_at: None,
88 playback_started_frame: initial_frame,
89 playback_cycle: 0,
90 seek_revision: 0,
91 });
92
93 use_future(move || async move {
95 loop {
96 tokio::time::sleep(tick_duration).await;
97 let s = state.read();
98 let Some(started_at) = s.playback_started_at.filter(|_| s.playing) else {
99 continue;
100 };
101 let (next_frame, keep_playing, cycle) = frame_at_elapsed(
102 s.playback_started_frame,
103 started_at.elapsed(),
104 fps,
105 s.duration,
106 loop_playback,
107 );
108 let cycle_changed = cycle != s.playback_cycle;
109 let changed = next_frame != s.frame || keep_playing != s.playing || cycle_changed;
110 drop(s);
111 if changed {
112 let mut next_state = state.write();
113 next_state.frame = next_frame;
114 next_state.playing = keep_playing;
115 if cycle_changed {
116 next_state.playback_cycle = cycle;
117 next_state.seek_revision = next_state.seek_revision.wrapping_add(1);
118 }
119 if !keep_playing {
120 next_state.playback_started_at = None;
121 }
122 }
123 }
124 });
125
126 let current_frame = state.read().frame;
127 let is_playing = state.read().playing;
128 let seek_revision = state.read().seek_revision;
129
130 let mut playback_context = use_context_provider(|| {
131 Signal::new(PlayerPlaybackState {
132 playing: is_playing,
133 seek_revision,
134 })
135 });
136 let playback_snapshot = PlayerPlaybackState {
137 playing: is_playing,
138 seek_revision,
139 };
140 if *playback_context.peek() != playback_snapshot {
141 playback_context.set(playback_snapshot);
142 }
143
144 let on_play_pause = move |_| {
145 let mut next_state = state.write();
146 next_state.playing = !next_state.playing;
147 next_state.seek_revision = next_state.seek_revision.wrapping_add(1);
148 next_state.playback_cycle = 0;
149 if next_state.playing {
150 next_state.playback_started_frame = next_state.frame;
151 next_state.playback_started_at = Some(Instant::now());
152 } else {
153 next_state.playback_started_at = None;
154 }
155 };
156
157 let on_seek = move |f: u32| {
158 let mut next_state = state.write();
159 next_state.frame = f.min(duration.saturating_sub(1));
160 next_state.playback_started_frame = next_state.frame;
161 next_state.playback_started_at = next_state.playing.then(Instant::now);
162 next_state.playback_cycle = 0;
163 next_state.seek_revision = next_state.seek_revision.wrapping_add(1);
164 };
165
166 rsx! {
167 div {
168 class: "dioxuscut-player",
169 style: "display: flex; flex-direction: column; align-items: center; gap: 8px; font-family: sans-serif;",
170
171 div {
173 style: "position: relative; width: {props.width}px; height: {props.height}px; overflow: hidden; border-radius: 8px; box-shadow: 0 4px 24px rgba(0,0,0,0.4);",
174
175 Composition {
176 id: "player-composition",
177 width: props.width,
178 height: props.height,
179 fps,
180 duration_in_frames: duration,
181 frame: current_frame,
182 {props.children}
183 }
184
185 div {
187 style: "position: absolute; top: 8px; right: 10px; color: rgba(255,255,255,0.7); font-size: 11px; font-family: monospace; background: rgba(0,0,0,0.4); padding: 2px 6px; border-radius: 4px; pointer-events: none;",
188 "{current_frame} / {duration.saturating_sub(1)}"
189 }
190 }
191
192 if props.controls {
194 Controls {
195 frame: current_frame,
196 duration,
197 playing: is_playing,
198 on_play_pause,
199 on_seek,
200 }
201 }
202 }
203 }
204}
205
206fn frame_duration(fps: f64) -> tokio::time::Duration {
207 let safe_fps = if fps.is_finite() && fps > 0.0 {
208 fps
209 } else {
210 30.0
211 };
212 tokio::time::Duration::from_secs_f64(1.0 / safe_fps).max(tokio::time::Duration::from_millis(1))
213}
214
215fn frame_at_elapsed(
216 start_frame: u32,
217 elapsed: Duration,
218 fps: f64,
219 duration: u32,
220 loop_playback: bool,
221) -> (u32, bool, u64) {
222 if duration == 0 {
223 return (0, false, 0);
224 }
225 let safe_fps = if fps.is_finite() && fps > 0.0 {
226 fps
227 } else {
228 30.0
229 };
230 let elapsed_frames = (elapsed.as_secs_f64() * safe_fps)
231 .floor()
232 .clamp(0.0, u64::MAX as f64) as u64;
233 let absolute_frame = u64::from(start_frame).saturating_add(elapsed_frames);
234 if absolute_frame < u64::from(duration) {
235 return (absolute_frame as u32, true, 0);
236 }
237 if loop_playback {
238 (
239 (absolute_frame % u64::from(duration)) as u32,
240 true,
241 absolute_frame / u64::from(duration),
242 )
243 } else {
244 (duration - 1, false, 0)
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn frame_duration_respects_fps() {
254 assert_eq!(frame_duration(25.0), tokio::time::Duration::from_millis(40));
255 assert_eq!(
256 frame_duration(0.0),
257 tokio::time::Duration::from_secs_f64(1.0 / 30.0)
258 );
259 assert_eq!(frame_duration(1.0e12), Duration::from_millis(1));
260 }
261
262 #[test]
263 fn playback_stops_on_the_last_frame_when_looping_is_disabled() {
264 assert_eq!(
265 frame_at_elapsed(8, Duration::from_millis(100), 10.0, 10, false),
266 (9, true, 0)
267 );
268 assert_eq!(
269 frame_at_elapsed(8, Duration::from_millis(200), 10.0, 10, false),
270 (9, false, 0)
271 );
272 }
273
274 #[test]
275 fn playback_wraps_when_looping_is_enabled() {
276 assert_eq!(
277 frame_at_elapsed(9, Duration::from_millis(100), 10.0, 10, true),
278 (0, true, 1)
279 );
280 }
281
282 #[test]
283 fn wall_clock_frame_selection_skips_late_ticks() {
284 assert_eq!(
285 frame_at_elapsed(5, Duration::from_millis(220), 30.0, 100, false),
286 (11, true, 0)
287 );
288 }
289
290 #[test]
291 fn wall_clock_frame_selection_reports_loop_cycles() {
292 assert_eq!(
293 frame_at_elapsed(8, Duration::from_millis(300), 10.0, 10, true),
294 (1, true, 1)
295 );
296 }
297}