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;
25
26use crate::controls::Controls;
27
28/// Props for the `<Player>` component.
29#[derive(Props, Clone, PartialEq)]
30pub struct PlayerProps {
31    /// Composition width.
32    pub width: u32,
33    /// Composition height.
34    pub height: u32,
35    /// Frames per second.
36    pub fps: f64,
37    /// Total frame count.
38    pub duration_in_frames: u32,
39    /// Show playback controls below the canvas.
40    #[props(default = true)]
41    pub controls: bool,
42    /// Initial frame to start at.
43    #[props(default = 0)]
44    pub initial_frame: u32,
45    /// Whether the player should loop.
46    #[props(default = true)]
47    pub loop_playback: bool,
48    /// The composition content to render.
49    pub children: Element,
50}
51
52/// State tracked internally by the player.
53#[derive(Clone, Debug, PartialEq)]
54struct PlayerState {
55    frame: u32,
56    playing: bool,
57    duration: u32,
58}
59
60/// Interactive video player component.
61///
62/// Wraps a `<Composition>` and adds play/pause/seek controls driven by
63/// Dioxus reactive state.
64#[component]
65pub fn Player(props: PlayerProps) -> Element {
66    let duration = props.duration_in_frames;
67    let fps = props.fps;
68    let loop_playback = props.loop_playback;
69    let tick_duration = frame_duration(fps);
70
71    let mut state = use_signal(|| PlayerState {
72        frame: props.initial_frame,
73        playing: false,
74        duration,
75    });
76
77    // Advance frame on each animation tick when playing
78    use_future(move || async move {
79        loop {
80            tokio::time::sleep(tick_duration).await;
81            let s = state.read();
82            if s.playing {
83                let (next_frame, keep_playing) = advance_frame(s.frame, s.duration, loop_playback);
84                drop(s);
85                let mut next_state = state.write();
86                next_state.frame = next_frame;
87                next_state.playing = keep_playing;
88            }
89        }
90    });
91
92    let current_frame = state.read().frame;
93    let is_playing = state.read().playing;
94
95    let on_play_pause = move |_| {
96        let was_playing = state.read().playing;
97        state.write().playing = !was_playing;
98    };
99
100    let on_seek = move |f: u32| {
101        state.write().frame = f.min(duration.saturating_sub(1));
102    };
103
104    rsx! {
105        div {
106            class: "dioxuscut-player",
107            style: "display: flex; flex-direction: column; align-items: center; gap: 8px; font-family: sans-serif;",
108
109            // Composition viewport
110            div {
111                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);",
112
113                Composition {
114                    id: "player-composition",
115                    width: props.width,
116                    height: props.height,
117                    fps,
118                    duration_in_frames: duration,
119                    frame: current_frame,
120                    {props.children}
121                }
122
123                // Frame counter overlay
124                div {
125                    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;",
126                    "{current_frame} / {duration.saturating_sub(1)}"
127                }
128            }
129
130            // Controls
131            if props.controls {
132                Controls {
133                    frame: current_frame,
134                    duration,
135                    playing: is_playing,
136                    on_play_pause,
137                    on_seek,
138                }
139            }
140        }
141    }
142}
143
144fn frame_duration(fps: f64) -> tokio::time::Duration {
145    let safe_fps = if fps.is_finite() && fps > 0.0 {
146        fps
147    } else {
148        30.0
149    };
150    tokio::time::Duration::from_secs_f64(1.0 / safe_fps)
151}
152
153fn advance_frame(frame: u32, duration: u32, loop_playback: bool) -> (u32, bool) {
154    if duration == 0 {
155        return (0, false);
156    }
157
158    let next = frame.saturating_add(1);
159    if next < duration {
160        (next, true)
161    } else if loop_playback {
162        (0, true)
163    } else {
164        (duration - 1, false)
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn frame_duration_respects_fps() {
174        assert_eq!(frame_duration(25.0), tokio::time::Duration::from_millis(40));
175        assert_eq!(
176            frame_duration(0.0),
177            tokio::time::Duration::from_secs_f64(1.0 / 30.0)
178        );
179    }
180
181    #[test]
182    fn playback_stops_on_the_last_frame_when_looping_is_disabled() {
183        assert_eq!(advance_frame(8, 10, false), (9, true));
184        assert_eq!(advance_frame(9, 10, false), (9, false));
185    }
186
187    #[test]
188    fn playback_wraps_when_looping_is_enabled() {
189        assert_eq!(advance_frame(9, 10, true), (0, true));
190    }
191}