dioxuscut_player/
player.rs1use dioxus::prelude::*;
24use dioxuscut_core::Composition;
25
26use crate::controls::Controls;
27
28#[derive(Props, Clone, PartialEq)]
30pub struct PlayerProps {
31 pub width: u32,
33 pub height: u32,
35 pub fps: f64,
37 pub duration_in_frames: u32,
39 #[props(default = true)]
41 pub controls: bool,
42 #[props(default = 0)]
44 pub initial_frame: u32,
45 #[props(default = true)]
47 pub loop_playback: bool,
48 pub children: Element,
50}
51
52#[derive(Clone, Debug, PartialEq)]
54struct PlayerState {
55 frame: u32,
56 playing: bool,
57 duration: u32,
58}
59
60#[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 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 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 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 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}