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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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}