use dioxus::prelude::*;
use dioxuscut_core::Composition;
use crate::controls::Controls;
#[derive(Props, Clone, PartialEq)]
pub struct PlayerProps {
pub width: u32,
pub height: u32,
pub fps: f64,
pub duration_in_frames: u32,
#[props(default = true)]
pub controls: bool,
#[props(default = 0)]
pub initial_frame: u32,
#[props(default = true)]
pub loop_playback: bool,
pub children: Element,
}
#[derive(Clone, Debug, PartialEq)]
struct PlayerState {
frame: u32,
playing: bool,
duration: u32,
}
#[component]
pub fn Player(props: PlayerProps) -> Element {
let duration = props.duration_in_frames;
let fps = props.fps;
let loop_playback = props.loop_playback;
let tick_duration = frame_duration(fps);
let mut state = use_signal(|| PlayerState {
frame: props.initial_frame,
playing: false,
duration,
});
use_future(move || async move {
loop {
tokio::time::sleep(tick_duration).await;
let s = state.read();
if s.playing {
let (next_frame, keep_playing) = advance_frame(s.frame, s.duration, loop_playback);
drop(s);
let mut next_state = state.write();
next_state.frame = next_frame;
next_state.playing = keep_playing;
}
}
});
let current_frame = state.read().frame;
let is_playing = state.read().playing;
let on_play_pause = move |_| {
let was_playing = state.read().playing;
state.write().playing = !was_playing;
};
let on_seek = move |f: u32| {
state.write().frame = f.min(duration.saturating_sub(1));
};
rsx! {
div {
class: "dioxuscut-player",
style: "display: flex; flex-direction: column; align-items: center; gap: 8px; font-family: sans-serif;",
div {
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);",
Composition {
id: "player-composition",
width: props.width,
height: props.height,
fps,
duration_in_frames: duration,
frame: current_frame,
{props.children}
}
div {
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;",
"{current_frame} / {duration.saturating_sub(1)}"
}
}
if props.controls {
Controls {
frame: current_frame,
duration,
playing: is_playing,
on_play_pause,
on_seek,
}
}
}
}
}
fn frame_duration(fps: f64) -> tokio::time::Duration {
let safe_fps = if fps.is_finite() && fps > 0.0 {
fps
} else {
30.0
};
tokio::time::Duration::from_secs_f64(1.0 / safe_fps)
}
fn advance_frame(frame: u32, duration: u32, loop_playback: bool) -> (u32, bool) {
if duration == 0 {
return (0, false);
}
let next = frame.saturating_add(1);
if next < duration {
(next, true)
} else if loop_playback {
(0, true)
} else {
(duration - 1, false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_duration_respects_fps() {
assert_eq!(frame_duration(25.0), tokio::time::Duration::from_millis(40));
assert_eq!(
frame_duration(0.0),
tokio::time::Duration::from_secs_f64(1.0 / 30.0)
);
}
#[test]
fn playback_stops_on_the_last_frame_when_looping_is_disabled() {
assert_eq!(advance_frame(8, 10, false), (9, true));
assert_eq!(advance_frame(9, 10, false), (9, false));
}
#[test]
fn playback_wraps_when_looping_is_enabled() {
assert_eq!(advance_frame(9, 10, true), (0, true));
}
}