Skip to main content

dioxuscut_player/
player.rs

1//! `<Player>` component — interactive video player with playback controls.
2//!
3//! Equivalent to `@remotion/player`'s `<Player>`.
4//!
5//! # Example
6//! ```rust,ignore
7//! use dioxuscut_player::Player;
8//!
9//! fn App() -> Element {
10//!     rsx! {
11//!         Player {
12//!             component: MyComposition,
13//!             width: 1920,
14//!             height: 1080,
15//!             fps: 30.0,
16//!             duration_in_frames: 150,
17//!             controls: true,
18//!         }
19//!     }
20//! }
21//! ```
22
23use dioxus::prelude::*;
24use dioxuscut_core::Composition;
25use std::time::{Duration, Instant};
26
27use crate::controls::Controls;
28
29/// Props for the `<Player>` component.
30#[derive(Props, Clone, PartialEq)]
31pub struct PlayerProps {
32    /// Composition width.
33    pub width: u32,
34    /// Composition height.
35    pub height: u32,
36    /// Frames per second.
37    pub fps: f64,
38    /// Total frame count.
39    pub duration_in_frames: u32,
40    /// Show playback controls below the canvas.
41    #[props(default = true)]
42    pub controls: bool,
43    /// Initial frame to start at.
44    #[props(default = 0)]
45    pub initial_frame: u32,
46    /// Whether the player should loop.
47    #[props(default = true)]
48    pub loop_playback: bool,
49    /// The composition content to render.
50    pub children: Element,
51}
52
53/// State tracked internally by the player.
54#[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/// Interactive video player component.
72///
73/// Wraps a `<Composition>` and adds play/pause/seek controls driven by
74/// Dioxus reactive state.
75#[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    // Advance frame on each animation tick when playing
94    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            // Composition viewport
172            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                // Frame counter overlay
186                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            // Controls
193            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}