Skip to main content

dioxus_audio/components/
player.rs

1use std::time::Duration;
2
3use dioxus::prelude::*;
4use dioxus_icons::lucide::{Pause, Play, Repeat2, RotateCcw, RotateCw, Square, Volume2, VolumeX};
5
6use super::AudioScrubber;
7use crate::AudioErrorKind;
8use crate::playback::{
9    AudioPlayerController, BoundedPlaybackEvent, BoundedPlaybackMode, PlaybackAudibilityCapability,
10    PlaybackNetworkActivity, PlaybackPlayFailure, PlaybackReadiness, PlaybackSource,
11    PlaybackSourceFailure, PlaybackSourceLifecycle, PlaybackStatus, PlaybackTimeRange,
12    PlaybackTransport, use_audio_player,
13};
14
15/// Localizable messages emitted by [`PlaybackStatusAnnouncer`].
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct PlaybackAnnouncementLabels {
18    pub empty: String,
19    pub dormant: String,
20    pub loading: String,
21    pub ready: String,
22    pub starting: String,
23    pub playing: String,
24    pub paused: String,
25    pub ended: String,
26    pub waiting: String,
27    pub stalled: String,
28    pub interaction_required: String,
29    pub failed: String,
30}
31
32impl Default for PlaybackAnnouncementLabels {
33    fn default() -> Self {
34        Self {
35            empty: "No audio loaded".to_string(),
36            dormant: "Audio ready to load".to_string(),
37            loading: "Audio loading".to_string(),
38            ready: "Audio ready".to_string(),
39            starting: "Playback starting".to_string(),
40            playing: "Audio playing".to_string(),
41            paused: "Audio paused".to_string(),
42            ended: "Playback ended".to_string(),
43            waiting: "Audio waiting for media".to_string(),
44            stalled: "Audio loading stalled".to_string(),
45            interaction_required: "Playback needs interaction".to_string(),
46            failed: "Playback failed".to_string(),
47        }
48    }
49}
50
51/// Localizable messages for discrete Bounded Playback lifecycle changes.
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct BoundedPlaybackAnnouncementLabels {
54    pub starting_once: String,
55    pub starting_loop: String,
56    pub completed: String,
57    pub cancelled: String,
58    pub wrapping: String,
59    pub failed: String,
60}
61
62impl Default for BoundedPlaybackAnnouncementLabels {
63    fn default() -> Self {
64        Self {
65            starting_once: "Bounded Playback starting".to_string(),
66            starting_loop: "Looped Bounded Playback starting".to_string(),
67            completed: "Bounded Playback complete".to_string(),
68            cancelled: "Bounded Playback cancelled".to_string(),
69            wrapping: "Bounded Playback wrapping".to_string(),
70            failed: "Bounded Playback failed".to_string(),
71        }
72    }
73}
74
75/// An optional polite live region for coarse Playback lifecycle changes.
76#[component]
77pub fn PlaybackStatusAnnouncer(
78    controller: AudioPlayerController,
79    #[props(default)] labels: PlaybackAnnouncementLabels,
80    #[props(default)] bounded_labels: BoundedPlaybackAnnouncementLabels,
81) -> Element {
82    let status = controller.status()();
83    let snapshot = controller.snapshot()();
84    let bounded_message = snapshot.bounded_event.map(|event| match event {
85        BoundedPlaybackEvent::Started(mode) => match mode {
86            BoundedPlaybackMode::Once => bounded_labels.starting_once.as_str(),
87            BoundedPlaybackMode::Loop => bounded_labels.starting_loop.as_str(),
88        },
89        BoundedPlaybackEvent::Wrapping => bounded_labels.wrapping.as_str(),
90        BoundedPlaybackEvent::Completed => bounded_labels.completed.as_str(),
91        BoundedPlaybackEvent::Cancelled => bounded_labels.cancelled.as_str(),
92        BoundedPlaybackEvent::Failed => bounded_labels.failed.as_str(),
93    });
94    let message = if snapshot.source_failure.is_some() {
95        labels.failed.as_str()
96    } else if let Some(message) = bounded_message {
97        message
98    } else if snapshot.bounded.is_some() {
99        ""
100    } else if snapshot.source == PlaybackSourceLifecycle::Dormant {
101        labels.dormant.as_str()
102    } else if matches!(
103        snapshot.play_failure,
104        Some(PlaybackPlayFailure::InteractionRequired(_))
105    ) {
106        labels.interaction_required.as_str()
107    } else if snapshot.network == PlaybackNetworkActivity::Stalled {
108        labels.stalled.as_str()
109    } else if snapshot.readiness == PlaybackReadiness::Waiting {
110        labels.waiting.as_str()
111    } else if snapshot.transport == PlaybackTransport::PlayPending {
112        labels.starting.as_str()
113    } else {
114        match status {
115            PlaybackStatus::Empty => labels.empty.as_str(),
116            PlaybackStatus::Loading => labels.loading.as_str(),
117            PlaybackStatus::Ready => labels.ready.as_str(),
118            PlaybackStatus::Playing => labels.playing.as_str(),
119            PlaybackStatus::Paused => labels.paused.as_str(),
120            PlaybackStatus::Ended => labels.ended.as_str(),
121            PlaybackStatus::Failed(_) => labels.failed.as_str(),
122        }
123    };
124
125    rsx! {
126        div {
127            class: "dioxus-audio dioxus-audio__status-announcer",
128            role: "status",
129            aria_live: "polite",
130            aria_atomic: "true",
131            "{message}"
132        }
133    }
134}
135
136/// A native button that plays or pauses a Playback Controller.
137///
138/// Supplying `on_request_audio` also makes the button usable while Playback is
139/// empty. The callback can load a source; Playback starts when that source is
140/// ready.
141#[component]
142pub fn PlaybackPlayPauseButton(
143    controller: AudioPlayerController,
144    #[props(default = "Play".to_string())] play_label: String,
145    #[props(default = "Pause".to_string())] pause_label: String,
146    #[props(default)] on_request_audio: Option<EventHandler<()>>,
147) -> Element {
148    let snapshot = controller.snapshot()();
149    let mut play_requested = use_signal(|| false);
150    let requested = play_requested();
151    let pending = snapshot.transport == PlaybackTransport::PlayPending;
152    let playing = snapshot.transport == PlaybackTransport::Playing;
153    let pausable = pending || playing;
154    let empty = matches!(snapshot.source, PlaybackSourceLifecycle::Empty);
155    let can_request_audio = on_request_audio.is_some();
156    let disabled = match &snapshot.source {
157        PlaybackSourceLifecycle::Empty => !can_request_audio,
158        PlaybackSourceLifecycle::Dormant
159        | PlaybackSourceLifecycle::Loading
160        | PlaybackSourceLifecycle::Playable => false,
161        PlaybackSourceLifecycle::Failed => true,
162    };
163    let busy = pending
164        || (requested
165            && matches!(
166                snapshot.source,
167                PlaybackSourceLifecycle::Empty | PlaybackSourceLifecycle::Loading
168            ));
169    let aria_label = if pausable { pause_label } else { play_label };
170
171    use_effect(move || {
172        if !play_requested() {
173            return;
174        }
175
176        match controller.snapshot()().source {
177            PlaybackSourceLifecycle::Dormant
178            | PlaybackSourceLifecycle::Loading
179            | PlaybackSourceLifecycle::Playable => {
180                if controller.play().is_ok() {
181                    play_requested.set(false);
182                }
183            }
184            PlaybackSourceLifecycle::Failed => play_requested.set(false),
185            PlaybackSourceLifecycle::Empty => {}
186        }
187    });
188
189    rsx! {
190        button {
191            class: "dioxus-audio dioxus-audio__control dioxus-audio__control--primary",
192            r#type: "button",
193            aria_label,
194            aria_busy: busy,
195            aria_disabled: requested
196                && matches!(snapshot.source, PlaybackSourceLifecycle::Loading),
197            disabled,
198            onclick: move |_| {
199                if empty {
200                    if let Some(on_request_audio) = on_request_audio {
201                        play_requested.set(true);
202                        on_request_audio.call(());
203                    }
204                } else if pausable {
205                    let _ = controller.pause();
206                } else if !busy {
207                    let _ = controller.play();
208                }
209            },
210            if pausable {
211                Pause { size: 28 }
212            } else {
213                Play { size: 28 }
214            }
215        }
216    }
217}
218
219/// A native button that stops Playback and resets its position.
220#[component]
221pub fn PlaybackStopButton(
222    controller: AudioPlayerController,
223    #[props(default = "Stop".to_string())] label: String,
224) -> Element {
225    let snapshot = controller.snapshot()();
226    let position = controller.position()();
227    let stopped = snapshot.transport == PlaybackTransport::Idle && position.is_zero();
228    let disabled = !matches!(snapshot.source, PlaybackSourceLifecycle::Playable) || stopped;
229
230    rsx! {
231        button {
232            class: "dioxus-audio dioxus-audio__control",
233            r#type: "button",
234            aria_label: label,
235            disabled,
236            onclick: move |_| { let _ = controller.stop(); },
237            Square { size: 18 }
238        }
239    }
240}
241
242/// A native toggle button for whole-source repeat.
243#[component]
244pub fn PlaybackRepeatButton(
245    controller: AudioPlayerController,
246    #[props(default = "Repeat".to_string())] label: String,
247) -> Element {
248    let snapshot = controller.snapshot()();
249    let repeat = snapshot.repeat;
250    let unsupported = matches!(
251        controller.status()(),
252        PlaybackStatus::Failed(ref error) if error.kind() == AudioErrorKind::UnsupportedPlatform
253    );
254
255    rsx! {
256        button {
257            class: "dioxus-audio dioxus-audio__control",
258            r#type: "button",
259            aria_label: label,
260            aria_pressed: if repeat { "true" } else { "false" },
261            disabled: unsupported,
262            onclick: move |_| controller.toggle_repeat(),
263            Repeat2 { size: 20 }
264        }
265    }
266}
267
268/// A native toggle button that mutes Playback without pausing it.
269#[component]
270pub fn PlaybackMuteButton(
271    controller: AudioPlayerController,
272    #[props(default = "Mute".to_string())] label: String,
273) -> Element {
274    let snapshot = controller.snapshot()();
275    let unsupported = matches!(
276        controller.status()(),
277        PlaybackStatus::Failed(ref error) if error.kind() == AudioErrorKind::UnsupportedPlatform
278    );
279
280    rsx! {
281        button {
282            class: "dioxus-audio dioxus-audio__control",
283            r#type: "button",
284            aria_label: label,
285            aria_pressed: if snapshot.muted { "true" } else { "false" },
286            disabled: unsupported,
287            onclick: move |_| controller.toggle_muted(),
288            if snapshot.muted {
289                VolumeX { size: 20 }
290            } else {
291                Volume2 { size: 20 }
292            }
293        }
294    }
295}
296
297/// A Controller-backed native slider for normalized Playback audibility.
298///
299/// The control is disabled when the Controller reports no level capability.
300/// A best-effort media-element capability does not guarantee perceived loudness
301/// on every browser.
302#[component]
303pub fn PlaybackAudibilitySlider(
304    controller: AudioPlayerController,
305    #[props(default = "Audibility level".to_string())] label: String,
306    #[props(default)] value_text: Option<String>,
307) -> Element {
308    let snapshot = controller.snapshot()();
309    let level = snapshot.audibility_level.value();
310    let value_text = value_text.unwrap_or_else(|| format!("{} percent", (level * 100.0).round()));
311
312    rsx! {
313        input {
314            class: "dioxus-audio dioxus-audio__audibility",
315            r#type: "range",
316            min: "0",
317            max: "1",
318            step: "0.01",
319            value: "{level}",
320            disabled: snapshot.audibility_capability == PlaybackAudibilityCapability::Unavailable,
321            aria_label: label,
322            aria_valuetext: value_text,
323            oninput: move |event| {
324                if let Ok(level) = event.value().parse::<f64>() {
325                    let _ = controller.set_audibility_level(level);
326                }
327            },
328        }
329    }
330}
331
332/// A native button that seeks Playback by a signed number of seconds.
333#[component]
334pub fn PlaybackSkipButton(
335    controller: AudioPlayerController,
336    #[props(default = 15.0)] seconds: f64,
337    #[props(default)] label: Option<String>,
338) -> Element {
339    let playable = matches!(
340        controller.snapshot()().source,
341        PlaybackSourceLifecycle::Playable
342    );
343    let valid = seconds.is_finite() && seconds != 0.0;
344    let aria_label = label.unwrap_or_else(|| skip_label(seconds));
345
346    rsx! {
347        button {
348            class: "dioxus-audio dioxus-audio__control",
349            r#type: "button",
350            aria_label,
351            disabled: !playable || !valid,
352            onclick: move |_| controller.skip(seconds),
353            if seconds < 0.0 {
354                RotateCcw { size: 20 }
355            } else {
356                RotateCw { size: 20 }
357            }
358        }
359    }
360}
361
362/// A native button that cycles through configurable Playback rates.
363#[component]
364pub fn PlaybackRateButton(
365    controller: AudioPlayerController,
366    #[props(default = vec![1.0, 1.5, 2.0])] rates: Vec<f64>,
367    #[props(default = "Playback speed".to_string())] label: String,
368) -> Element {
369    let rate = controller.rate()();
370    let rates: Vec<_> = rates
371        .into_iter()
372        .filter(|rate| rate.is_finite() && *rate > 0.0)
373        .collect();
374    let next_rate = if rates.is_empty() {
375        None
376    } else if let Some(current) = rates
377        .iter()
378        .position(|candidate| (*candidate - rate).abs() < f64::EPSILON)
379    {
380        Some(rates[(current + 1) % rates.len()])
381    } else {
382        rates.first().copied()
383    };
384    let unsupported = matches!(
385        controller.status()(),
386        PlaybackStatus::Failed(ref error) if error.kind() == AudioErrorKind::UnsupportedPlatform
387    );
388    let rate_text = rate.to_string();
389    let aria_label = format!("{label}: {rate_text}x");
390
391    rsx! {
392        button {
393            class: "dioxus-audio dioxus-audio__rate",
394            r#type: "button",
395            aria_label,
396            disabled: unsupported || next_rate.is_none(),
397            onclick: move |_| {
398                if let Some(next_rate) = next_rate {
399                    let _ = controller.set_rate(next_rate);
400                }
401            },
402            "{rate_text}x"
403        }
404    }
405}
406
407/// A Controller-backed native Playback position slider.
408#[component]
409pub fn PlaybackSeekSlider(
410    controller: AudioPlayerController,
411    #[props(default = "Seek audio".to_string())] label: String,
412    #[props(default)] value_text: Option<String>,
413) -> Element {
414    let snapshot = controller.snapshot()();
415    let position = controller.position()().as_secs_f64();
416    let duration = controller.duration()().as_secs_f64();
417
418    rsx! {
419        AudioScrubber {
420            position_secs: position,
421            duration_secs: duration,
422            disabled: !matches!(snapshot.source, PlaybackSourceLifecycle::Playable),
423            aria_label: label,
424            value_text,
425            on_seek: move |seconds| controller.seek(Duration::from_secs_f64(seconds)),
426        }
427    }
428}
429
430#[component]
431pub fn AudioPlayer(
432    source: ReadSignal<Option<PlaybackSource>>,
433    on_request_audio: EventHandler<()>,
434    #[props(default = 0.0)] duration_secs: f64,
435) -> Element {
436    let controller = use_audio_player(
437        source,
438        Duration::from_secs_f64(finite_non_negative(duration_secs)),
439    );
440    let status = controller.status()();
441    let snapshot = controller.snapshot()();
442    let position = controller.position()().as_secs_f64();
443    let duration = controller.duration()().as_secs_f64();
444    let remaining = (duration - position).max(0.0);
445
446    rsx! {
447        div {
448            class: "dioxus-audio dioxus-audio__player",
449            "data-state": playback_state_name(&status),
450            "data-source": source_lifecycle_name(&snapshot.source),
451            "data-transport": transport_state_name(snapshot.transport),
452            "data-readiness": readiness_state_name(snapshot.readiness),
453            "data-network": network_activity_name(snapshot.network),
454            "data-buffered": format_time_ranges(&snapshot.buffered),
455            "data-seekable": format_time_ranges(&snapshot.seekable),
456            "data-source-failure": source_failure_name(snapshot.source_failure.as_ref()),
457            "data-play-failure": play_failure_name(snapshot.play_failure.as_ref()),
458            "data-repeat": if snapshot.repeat { "true" } else { "false" },
459            "data-muted": if snapshot.muted { "true" } else { "false" },
460            "data-audibility-level": snapshot.audibility_level.value().to_string(),
461            "data-audibility-capability": audibility_capability_name(snapshot.audibility_capability),
462            PlaybackSeekSlider { controller }
463            div { class: "dioxus-audio__player-times",
464                span { "{format_time(position)}" }
465                span { "-{format_time(remaining)}" }
466            }
467            div { class: "dioxus-audio__player-controls",
468                PlaybackSkipButton { controller, seconds: -15.0 }
469                PlaybackStopButton { controller }
470                PlaybackPlayPauseButton { controller, on_request_audio }
471                PlaybackSkipButton { controller, seconds: 15.0 }
472                PlaybackRateButton { controller }
473                PlaybackMuteButton { controller }
474                PlaybackAudibilitySlider { controller }
475                PlaybackRepeatButton { controller }
476            }
477            if let PlaybackStatus::Failed(ref error) = status {
478                div {
479                    class: "dioxus-audio__player-error",
480                    role: "alert",
481                    "{error}"
482                }
483            }
484        }
485    }
486}
487
488fn skip_label(seconds: f64) -> String {
489    let amount = seconds.abs().to_string();
490    let unit = if amount == "1" { "second" } else { "seconds" };
491    if seconds < 0.0 {
492        format!("Skip back {amount} {unit}")
493    } else {
494        format!("Skip forward {amount} {unit}")
495    }
496}
497
498fn finite_non_negative(value: f64) -> f64 {
499    if value.is_finite() {
500        value.max(0.0)
501    } else {
502        0.0
503    }
504}
505
506fn format_time(seconds: f64) -> String {
507    let seconds = finite_non_negative(seconds) as u64;
508    format!("{}:{:02}", seconds / 60, seconds % 60)
509}
510
511fn playback_state_name(status: &PlaybackStatus) -> &'static str {
512    match status {
513        PlaybackStatus::Empty => "empty",
514        PlaybackStatus::Loading => "loading",
515        PlaybackStatus::Ready => "ready",
516        PlaybackStatus::Playing => "playing",
517        PlaybackStatus::Paused => "paused",
518        PlaybackStatus::Ended => "ended",
519        PlaybackStatus::Failed(_) => "error",
520    }
521}
522
523fn source_lifecycle_name(source: &PlaybackSourceLifecycle) -> &'static str {
524    match source {
525        PlaybackSourceLifecycle::Empty => "empty",
526        PlaybackSourceLifecycle::Dormant => "dormant",
527        PlaybackSourceLifecycle::Loading => "loading",
528        PlaybackSourceLifecycle::Playable => "playable",
529        PlaybackSourceLifecycle::Failed => "failed",
530    }
531}
532
533fn transport_state_name(transport: PlaybackTransport) -> &'static str {
534    match transport {
535        PlaybackTransport::Idle => "idle",
536        PlaybackTransport::PlayPending => "play-pending",
537        PlaybackTransport::Playing => "playing",
538        PlaybackTransport::Paused => "paused",
539        PlaybackTransport::Ended => "ended",
540    }
541}
542
543fn readiness_state_name(readiness: PlaybackReadiness) -> &'static str {
544    match readiness {
545        PlaybackReadiness::Unavailable => "unavailable",
546        PlaybackReadiness::LoadingMetadata => "loading-metadata",
547        PlaybackReadiness::Metadata => "metadata",
548        PlaybackReadiness::Playable => "playable",
549        PlaybackReadiness::Waiting => "waiting",
550    }
551}
552
553fn play_failure_name(failure: Option<&PlaybackPlayFailure>) -> &'static str {
554    match failure {
555        None => "none",
556        Some(PlaybackPlayFailure::InteractionRequired(_)) => "interaction-required",
557        Some(PlaybackPlayFailure::Unknown(_)) => "unknown",
558    }
559}
560
561fn network_activity_name(activity: PlaybackNetworkActivity) -> &'static str {
562    match activity {
563        PlaybackNetworkActivity::Inactive => "inactive",
564        PlaybackNetworkActivity::Unknown => "unknown",
565        PlaybackNetworkActivity::Loading => "loading",
566        PlaybackNetworkActivity::Idle => "idle",
567        PlaybackNetworkActivity::Stalled => "stalled",
568    }
569}
570
571fn format_time_ranges(ranges: &[PlaybackTimeRange]) -> String {
572    ranges
573        .iter()
574        .map(|range| {
575            format!(
576                "{}-{}",
577                range.start().as_secs_f64(),
578                range.end().as_secs_f64()
579            )
580        })
581        .collect::<Vec<_>>()
582        .join(",")
583}
584
585fn source_failure_name(failure: Option<&PlaybackSourceFailure>) -> &'static str {
586    match failure {
587        None => "none",
588        Some(PlaybackSourceFailure::GraphIneligible(_)) => "graph-ineligible",
589        Some(PlaybackSourceFailure::Unsupported(_)) => "unsupported",
590        Some(PlaybackSourceFailure::Network(_)) => "network",
591        Some(PlaybackSourceFailure::Decode(_)) => "decode",
592        Some(PlaybackSourceFailure::Unknown(_)) => "unknown",
593    }
594}
595
596fn audibility_capability_name(capability: PlaybackAudibilityCapability) -> &'static str {
597    match capability {
598        PlaybackAudibilityCapability::EffectiveGraphGain => "effective-graph-gain",
599        PlaybackAudibilityCapability::BestEffortMediaElement => "best-effort-media-element",
600        PlaybackAudibilityCapability::Unavailable => "unavailable",
601    }
602}