Skip to main content

ui_storybook/components/editor/
player_controls.rs

1//! `PlayerControls` — transport bar for the player view.
2//!
3//! Composed of: play/pause button, scrub track with progress fill + handle,
4//! and a `current / total` time display. Pure presentational — `position`
5//! is a `0.0..=1.0` fraction; the parent owns the signal that drives it.
6
7use leptos::prelude::*;
8
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10pub enum PlayState {
11    #[default]
12    Paused,
13    Playing,
14}
15
16#[component]
17pub fn PlayerControls(
18    /// Play/pause state — drives the glyph and aria-label of the toggle.
19    #[prop(optional)]
20    state: PlayState,
21    /// Scrub position as `0.0..=1.0`.
22    #[prop(optional)]
23    position: f32,
24    /// Total duration in seconds (used for the right-hand label).
25    #[prop(optional)]
26    duration_seconds: f32,
27    /// Optional click handler for the play/pause toggle. Pure-presentation
28    /// stories leave this `None` (default); the recorder shell (`app-ui`)
29    /// passes a callback that invokes the Tauri `player_play` /
30    /// `player_pause` commands.
31    #[prop(optional, into)]
32    on_toggle: Option<Callback<()>>,
33) -> impl IntoView {
34    let position = position.clamp(0.0, 1.0);
35    let duration = if duration_seconds <= 0.0 {
36        60.0
37    } else {
38        duration_seconds
39    };
40    let current = position * duration;
41
42    let pct = position * 100.0;
43    let fill_style = format!("width: {pct:.2}%");
44    let handle_style = format!("left: {pct:.2}%");
45
46    let (toggle_glyph, toggle_label, toggle_class) = match state {
47        PlayState::Paused => ("▶", "Play", "player-toggle player-toggle-paused"),
48        PlayState::Playing => ("❚❚", "Pause", "player-toggle player-toggle-playing"),
49    };
50
51    let toggle_click = move |_| {
52        if let Some(cb) = on_toggle {
53            cb.run(());
54        }
55    };
56
57    view! {
58        <div class="player-controls" role="group" aria-label="Player transport">
59            <button class=toggle_class type="button" aria-label=toggle_label on:click=toggle_click>
60                <span class="player-toggle-glyph">{toggle_glyph}</span>
61            </button>
62
63            <div class="player-time" data-role="current">{format_time(current)}</div>
64
65            <div class="player-scrub">
66                <div class="player-scrub-track">
67                    <div class="player-scrub-fill" style=fill_style></div>
68                    <div class="player-scrub-handle" style=handle_style></div>
69                </div>
70            </div>
71
72            <div class="player-time" data-role="total">{format_time(duration)}</div>
73        </div>
74    }
75}
76
77#[allow(
78    clippy::cast_possible_truncation,
79    clippy::cast_sign_loss,
80    reason = "seconds is positive and bounded by duration_seconds, fits in u32"
81)]
82fn format_time(seconds: f32) -> String {
83    let total = seconds.max(0.0).round() as u32;
84    let m = total / 60;
85    let s = total % 60;
86    format!("{m}:{s:02}")
87}