1use std::cell::Cell;
2use std::fmt::Write as _;
3use std::ops::Range;
4use std::rc::Rc;
5use std::time::Duration;
6
7use dioxus::prelude::*;
8
9use crate::analysis::{WaveformSelection, downsample_peaks};
10use crate::playback::{AudioPlayerController, PlaybackSourceLifecycle};
11use crate::waveform::{
12 AmplitudeMode, AmplitudeSlice, SignedEnvelope, WaveformData, WaveformViewportController,
13};
14
15use super::format_accessible_duration;
16
17const MAXIMUM_NAVIGABLE_BUCKET_BUDGET: usize = 4096;
18const MAXIMUM_PIXEL_DENSITY: f64 = 2.0;
19
20#[component]
22pub fn Waveform(
23 data: WaveformData,
24 #[props(default = 512)] bucket_budget: usize,
25 #[props(default = 96.0)] height: f64,
26 #[props(default)] label: Option<String>,
27) -> Element {
28 let height = waveform_height(height);
29 let visible = Duration::ZERO..data.duration();
30
31 rsx! {
32 WaveformSvg {
33 data,
34 visible,
35 bucket_budget: bucket_budget.max(1),
36 height,
37 label,
38 }
39 }
40}
41
42#[derive(Clone, PartialEq)]
43struct WaveformGeometry {
44 paths: Vec<String>,
45 view_start: f64,
46 view_span: f64,
47 amplitude_mode: &'static str,
48 channel_count: usize,
49 resolution: usize,
50 bucket_count: usize,
51}
52
53#[component]
54fn WaveformSvg(
55 data: WaveformData,
56 visible: Range<Duration>,
57 bucket_budget: usize,
58 height: f64,
59 label: Option<String>,
60) -> Element {
61 let geometry_builds = use_hook(|| Rc::new(Cell::new(0_usize)));
62 let geometry_builds_for_memo = geometry_builds.clone();
63 let geometry = use_memo(use_reactive!(|(data, visible, bucket_budget, height)| {
64 geometry_builds_for_memo.set(geometry_builds_for_memo.get() + 1);
65 build_waveform_geometry(&data, visible, bucket_budget, height)
66 }));
67 let geometry = geometry();
68 let role = label.as_ref().map(|_| "img");
69
70 rsx! {
71 svg {
72 class: "dioxus-audio dioxus-audio__waveform dioxus-audio__waveform-data",
73 role,
74 "aria-label": label,
75 "aria-hidden": role.is_none(),
76 "data-amplitude-mode": geometry.amplitude_mode,
77 "data-channel-count": geometry.channel_count,
78 "data-resolution": geometry.resolution,
79 "data-bucket-count": geometry.bucket_count,
80 "data-bucket-budget": bucket_budget,
81 "data-geometry-build-count": geometry_builds.get(),
82 width: "100%",
83 height: "{height}",
84 view_box: "{geometry.view_start} 0 {geometry.view_span} {height}",
85 preserve_aspect_ratio: "none",
86 for path_data in geometry.paths.iter() {
87 path {
88 class: "dioxus-audio__waveform-channel",
89 d: path_data,
90 }
91 }
92 }
93 }
94}
95
96fn build_waveform_geometry(
97 data: &WaveformData,
98 visible: Range<Duration>,
99 bucket_budget: usize,
100 height: f64,
101) -> WaveformGeometry {
102 let view = data
103 .select(visible.clone(), bucket_budget.max(1))
104 .expect("Waveform Data and presentation budget are valid");
105 let channel_height = height / data.channel_count() as f64;
106 let bucket_span =
107 view.bucket_span().numerator().as_secs_f64() / view.bucket_span().divisor() as f64;
108 let axis = BucketAxis {
109 first_bucket: view.first_bucket(),
110 bucket_span,
111 source_end: data.duration().as_secs_f64(),
112 };
113 let paths = (0..data.channel_count())
114 .filter_map(|channel| {
115 let top = channel as f64 * channel_height;
116 match view.channel(channel)? {
117 AmplitudeSlice::Magnitudes(values) => {
118 Some(magnitude_path(values, top, channel_height, axis))
119 }
120 AmplitudeSlice::SignedEnvelopes(values) => {
121 Some(signed_envelope_path(values, top, channel_height, axis))
122 }
123 }
124 })
125 .collect::<Vec<_>>();
126 let amplitude_mode = match data.mode() {
127 AmplitudeMode::Magnitude => "magnitude",
128 AmplitudeMode::SignedEnvelope => "signed-envelope",
129 };
130 WaveformGeometry {
131 paths,
132 view_start: visible.start.as_secs_f64(),
133 view_span: (visible.end - visible.start).as_secs_f64(),
134 amplitude_mode,
135 channel_count: data.channel_count(),
136 resolution: view.resolution_index(),
137 bucket_count: view.bucket_count(),
138 }
139}
140
141#[derive(Clone, Copy)]
142struct BucketAxis {
143 first_bucket: usize,
144 bucket_span: f64,
145 source_end: f64,
146}
147
148impl BucketAxis {
149 fn start(self, local_bucket: usize) -> f64 {
150 (self.first_bucket + local_bucket) as f64 * self.bucket_span
151 }
152
153 fn end(self, local_bucket: usize) -> f64 {
154 (self.start(local_bucket) + self.bucket_span).min(self.source_end)
155 }
156}
157
158fn magnitude_path(values: &[f32], top: f64, height: f64, axis: BucketAxis) -> String {
159 let baseline = top + height;
160 let first_x = axis.start(0);
161 let mut path = format!("M{first_x} {baseline}");
162 for (index, value) in values.iter().enumerate() {
163 let x = axis.start(index);
164 let next_x = axis.end(index);
165 let y = baseline - f64::from(*value) * height;
166 let _ = write!(path, "L{x} {y}H{next_x}");
167 }
168 let final_x = axis.end(values.len() - 1);
169 let _ = write!(path, "L{final_x} {baseline}Z");
170 path
171}
172
173fn signed_envelope_path(
174 values: &[SignedEnvelope],
175 top: f64,
176 height: f64,
177 axis: BucketAxis,
178) -> String {
179 let center = top + height / 2.0;
180 let amplitude_height = height / 2.0;
181 let upper = |value: SignedEnvelope| center - f64::from(value.max) * amplitude_height;
182 let lower = |value: SignedEnvelope| center - f64::from(value.min) * amplitude_height;
183 let first_x = axis.start(0);
184
185 let mut path = format!("M{first_x} {}", upper(values[0]));
186 for (index, value) in values.iter().copied().enumerate() {
187 let next_x = axis.end(index);
188 if index > 0 {
189 let _ = write!(path, "V{}", upper(value));
190 }
191 let _ = write!(path, "H{next_x}");
192 }
193
194 let last = values[values.len() - 1];
195 let final_x = axis.end(values.len() - 1);
196 let _ = write!(path, "L{final_x} {}", lower(last));
197 for index in (0..values.len()).rev() {
198 if index + 1 < values.len() {
199 let _ = write!(path, "V{}", lower(values[index]));
200 }
201 let x = axis.start(index);
202 let _ = write!(path, "H{x}");
203 }
204 path.push('Z');
205 path
206}
207
208#[derive(Clone, Copy, PartialEq, Eq)]
209struct WaveformMeasurement {
210 bucket_budget: usize,
211 measured: bool,
212}
213
214#[component]
220pub fn NavigableWaveform(
221 data: WaveformData,
222 controller: WaveformViewportController,
223 #[props(default)] playback: Option<AudioPlayerController>,
224 #[props(default = 512)] fallback_bucket_budget: usize,
225 #[props(default = 96.0)] height: f64,
226 #[props(default = true)] show_overview: bool,
227 #[props(default = "Waveform viewport".to_string())] label: String,
228) -> Element {
229 assert_eq!(
230 data.duration(),
231 controller.total_duration(),
232 "Waveform Data and Viewport Controller durations must match"
233 );
234 let height = waveform_height(height);
235 let fallback_bucket_budget = fallback_bucket_budget.clamp(1, MAXIMUM_NAVIGABLE_BUCKET_BUDGET);
236 let mut measurement = use_signal(move || WaveformMeasurement {
237 bucket_budget: fallback_bucket_budget,
238 measured: false,
239 });
240 let current_measurement = measurement();
241 let total_duration = controller.total_duration();
242 let playback_position = playback.and_then(|playback| {
243 let source = playback.snapshot()().source;
244 let playback_duration = playback.duration()();
245 (matches!(source, PlaybackSourceLifecycle::Playable) && !playback_duration.is_zero()).then(
246 || {
247 playback.position()()
248 .min(playback_duration)
249 .min(total_duration)
250 },
251 )
252 });
253 use_effect(use_reactive!(|(playback_position, controller)| {
254 if let Some(position) = playback_position {
255 controller.follow_playback(position);
256 }
257 }));
258 let visible = controller.visible_range();
259 let span = visible.end - visible.start;
260 let center = visible.start + span / 2;
261 let following = controller.is_following();
262 let visible_playback = playback_position.filter(|position| {
263 *position >= visible.start
264 && (*position < visible.end
265 || (*position == total_duration && visible.end == total_duration))
266 });
267 let zoom_anchor = if following {
268 visible_playback.unwrap_or(center)
269 } else {
270 center
271 };
272 let playhead_percent = visible_playback
273 .map(|position| (position - visible.start).as_secs_f64() / span.as_secs_f64() * 100.0);
274 let visible_text = format_visible_range(&visible);
275 let at_start = visible.start.is_zero();
276 let at_end = visible.end == total_duration;
277 let fully_zoomed_out = span == total_duration;
278 let fully_zoomed_in = span <= Duration::from_nanos(1);
279 let overview_minimum = span.as_secs_f64() / 2.0;
280 let overview_maximum = total_duration.as_secs_f64() - overview_minimum;
281 let overview_step = (span.as_secs_f64() / 100.0).max(0.001);
282 let budget_source = if current_measurement.measured {
283 "measured"
284 } else {
285 "fallback"
286 };
287 let render_data = data.clone();
288
289 rsx! {
290 div {
291 class: "dioxus-audio dioxus-audio__waveform-viewport",
292 role: "group",
293 aria_label: label,
294 "data-budget-source": budget_source,
295 "data-following": following,
296 onresize: move |event| {
297 let Ok(size) = event.data().get_content_box_size() else {
298 return;
299 };
300 let Some(bucket_budget) = measured_bucket_budget(size.width) else {
301 return;
302 };
303 let next = WaveformMeasurement {
304 bucket_budget,
305 measured: true,
306 };
307 if *measurement.peek() != next {
308 measurement.set(next);
309 }
310 },
311 div { class: "dioxus-audio__viewport-stage",
312 WaveformSvg {
313 data: render_data,
314 visible: visible.clone(),
315 bucket_budget: current_measurement.bucket_budget,
316 height,
317 label: None,
318 }
319 if let Some(playhead_percent) = playhead_percent {
320 div {
321 class: "dioxus-audio__viewport-playhead",
322 style: "left: {playhead_percent}%",
323 aria_hidden: "true",
324 }
325 }
326 }
327 output {
328 class: "dioxus-audio__viewport-range",
329 "{visible_text}"
330 }
331 nav {
332 class: "dioxus-audio__viewport-controls",
333 aria_label: "Waveform viewport controls",
334 button {
335 class: "dioxus-audio__viewport-control",
336 r#type: "button",
337 aria_label: "Pan backward",
338 disabled: at_start,
339 onclick: move |_| controller.pan(-0.5),
340 "Pan back"
341 }
342 button {
343 class: "dioxus-audio__viewport-control",
344 r#type: "button",
345 aria_label: "Zoom out",
346 disabled: fully_zoomed_out,
347 onclick: move |_| controller.zoom(0.5, zoom_anchor),
348 "Zoom out"
349 }
350 button {
351 class: "dioxus-audio__viewport-control",
352 r#type: "button",
353 aria_label: "Reset view",
354 disabled: fully_zoomed_out,
355 onclick: move |_| controller.reset(),
356 "Reset"
357 }
358 button {
359 class: "dioxus-audio__viewport-control",
360 r#type: "button",
361 aria_label: "Zoom in",
362 disabled: fully_zoomed_in,
363 onclick: move |_| controller.zoom(2.0, zoom_anchor),
364 "Zoom in"
365 }
366 button {
367 class: "dioxus-audio__viewport-control",
368 r#type: "button",
369 aria_label: "Pan forward",
370 disabled: at_end,
371 onclick: move |_| controller.pan(0.5),
372 "Pan forward"
373 }
374 button {
375 class: "dioxus-audio__viewport-control",
376 r#type: "button",
377 aria_label: "Resume follow",
378 disabled: playback_position.is_none(),
379 onclick: move |_| {
380 if let Some(position) = playback_position {
381 controller.resume_follow(position);
382 }
383 },
384 "Resume follow"
385 }
386 }
387 if show_overview {
388 label { class: "dioxus-audio__viewport-overview",
389 span { "Overview" }
390 input {
391 r#type: "range",
392 min: overview_minimum,
393 max: overview_maximum.max(overview_minimum),
394 step: overview_step,
395 value: center.as_secs_f64(),
396 disabled: fully_zoomed_out,
397 aria_label: "Overview position",
398 aria_valuetext: visible_text,
399 oninput: move |event| {
400 let Ok(requested_center) = event.value().parse::<f64>() else {
401 return;
402 };
403 if !requested_center.is_finite() {
404 return;
405 }
406 let span_seconds = span.as_secs_f64();
407 let maximum_start = total_duration.as_secs_f64() - span_seconds;
408 let start = (requested_center - span_seconds / 2.0)
409 .clamp(0.0, maximum_start);
410 controller.show_range(
411 Duration::from_secs_f64(start)
412 ..Duration::from_secs_f64(start + span_seconds),
413 );
414 },
415 }
416 }
417 }
418 }
419 }
420}
421
422fn waveform_height(height: f64) -> f64 {
423 if height.is_finite() {
424 height.max(1.0)
425 } else {
426 96.0
427 }
428}
429
430fn measured_bucket_budget(width: f64) -> Option<usize> {
431 if !width.is_finite() || width <= 0.0 {
432 return None;
433 }
434 let density = browser_pixel_density().clamp(1.0, MAXIMUM_PIXEL_DENSITY);
435 Some(((width * density).round() as usize).clamp(1, MAXIMUM_NAVIGABLE_BUCKET_BUDGET))
436}
437
438#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
439fn browser_pixel_density() -> f64 {
440 web_sys::window()
441 .map(|window| window.device_pixel_ratio())
442 .filter(|density| density.is_finite() && *density > 0.0)
443 .unwrap_or(1.0)
444}
445
446#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
447fn browser_pixel_density() -> f64 {
448 1.0
449}
450
451fn format_visible_range(visible: &Range<Duration>) -> String {
452 let span = visible.end - visible.start;
453 let fractional_digits = if span >= Duration::from_millis(10) {
454 2
455 } else if span >= Duration::from_millis(1) {
456 3
457 } else if span >= Duration::from_micros(1) {
458 6
459 } else {
460 9
461 };
462 format!(
463 "Visible {} to {}",
464 format_source_time(visible.start, fractional_digits),
465 format_source_time(visible.end, fractional_digits)
466 )
467}
468
469fn format_source_time(duration: Duration, fractional_digits: usize) -> String {
470 let total_seconds = duration.as_secs();
471 let hours = total_seconds / 3_600;
472 let minutes = total_seconds / 60 % 60;
473 let seconds = total_seconds % 60;
474 let divisor = 10_u32.pow(9 - fractional_digits as u32);
475 let fraction_value = duration.subsec_nanos() / divisor;
476 let fraction = if fraction_value == 0 {
477 String::new()
478 } else {
479 let fraction = format!("{fraction_value:0fractional_digits$}");
480 format!(".{}", fraction.trim_end_matches('0'))
481 };
482
483 if hours > 0 {
484 format!("{hours}:{minutes:02}:{seconds:02}{fraction}")
485 } else {
486 format!("{minutes}:{seconds:02}{fraction}")
487 }
488}
489
490#[component]
496pub fn InteractiveWaveform(
497 data: WaveformData,
498 controller: AudioPlayerController,
499 selection: WaveformSelection,
500 on_selection_change: EventHandler<WaveformSelection>,
501 #[props(default = 512)] bucket_budget: usize,
502 #[props(default = 96.0)] height: f64,
503 #[props(default = 0.01)] fine_step_secs: f64,
504 #[props(default = 1.0)] coarse_step_secs: f64,
505 #[props(default = "Interactive waveform".to_string())] label: String,
506 #[props(default = "Playback position".to_string())] playback_label: String,
507 #[props(default = "Selection start".to_string())] selection_start_label: String,
508 #[props(default = "Selection end".to_string())] selection_end_label: String,
509) -> Element {
510 let waveform_duration = data.duration().as_secs_f64();
511 let playback_duration = controller.duration()().as_secs_f64();
512 let playback_position = controller.position()().as_secs_f64().min(waveform_duration);
513 let playback_source = controller.snapshot()().source;
514 let has_playback_duration = playback_duration.is_finite() && playback_duration > 0.0;
515 let can_seek =
516 has_playback_duration && matches!(playback_source, PlaybackSourceLifecycle::Playable);
517 let seek_end = if has_playback_duration {
518 playback_duration.min(waveform_duration)
519 } else {
520 0.0
521 };
522 let fine_step_secs = positive_step(fine_step_secs, 0.01);
523 let coarse_step_secs = positive_step(coarse_step_secs, 1.0);
524 let controlled_selection = selection.clamped_to_duration(waveform_duration);
525 let mut draft_seek = use_signal(|| None::<f64>);
526 let mut draft_selection = use_signal(|| None::<WaveformSelection>);
527 use_effect(use_reactive!(|(
528 controlled_selection,
529 waveform_duration,
530 seek_end,
531 can_seek,
532 )| {
533 let _ = (controlled_selection, waveform_duration, seek_end, can_seek);
534 draft_selection.set(None);
535 draft_seek.set(None);
536 }));
537 let presented_position = draft_seek().unwrap_or(playback_position);
538 let presented_selection = draft_selection().unwrap_or(controlled_selection);
539 let start_percent = presented_selection.start() / waveform_duration * 100.0;
540 let end_percent = presented_selection.end() / waveform_duration * 100.0;
541 let selection_width = end_percent - start_percent;
542 let position_percent = presented_position / waveform_duration * 100.0;
543 let collapsed = presented_selection.is_collapsed();
544 let position_value_text = format_accessible_duration(presented_position);
545 let start_value_text = format_accessible_duration(presented_selection.start());
546 let end_value_text = format_accessible_duration(presented_selection.end());
547 let (collapse_edge, start_hit_offset, end_hit_offset) = if !collapsed {
548 ("none", "0rem", "0rem")
549 } else if presented_selection.start() == 0.0 {
550 ("start", "0rem", "1.5rem")
551 } else if presented_selection.end() == waveform_duration {
552 ("end", "-1.5rem", "0rem")
553 } else {
554 ("middle", "-0.75rem", "0.75rem")
555 };
556
557 rsx! {
558 div {
559 class: "dioxus-audio dioxus-audio__interactive-waveform",
560 role: "group",
561 aria_label: label,
562 "data-collapsed": collapsed,
563 "data-collapse-edge": collapse_edge,
564 Waveform { data, bucket_budget, height, label: None }
565 div {
566 class: "dioxus-audio__interactive-position",
567 style: "left: {position_percent}%",
568 }
569 div {
570 class: "dioxus-audio__interactive-selection",
571 style: "left: {start_percent}%; width: {selection_width}%",
572 }
573 input {
574 class: "dioxus-audio__interactive-input dioxus-audio__interactive-input--playback",
575 r#type: "range",
576 min: "0",
577 max: "{waveform_duration}",
578 step: "{fine_step_secs}",
579 value: "{presented_position}",
580 disabled: !can_seek,
581 aria_label: playback_label,
582 aria_valuemin: 0.0,
583 aria_valuemax: seek_end,
584 aria_valuetext: position_value_text,
585 oninput: move |event| {
586 if let Some(value) = source_time(&event.value(), waveform_duration) {
587 draft_seek.set(Some(value.min(seek_end)));
588 }
589 },
590 onchange: move |event| {
591 if let Some(value) = source_time(&event.value(), waveform_duration) {
592 draft_seek.set(None);
593 if can_seek {
594 controller.seek(std::time::Duration::from_secs_f64(
595 value.min(playback_duration),
596 ));
597 }
598 }
599 },
600 onkeydown: move |event| {
601 if let Some(value) = keyboard_source_time(
602 event.key(),
603 presented_position,
604 0.0,
605 seek_end,
606 fine_step_secs,
607 coarse_step_secs,
608 ) {
609 event.prevent_default();
610 draft_seek.set(None);
611 controller.seek(std::time::Duration::from_secs_f64(value));
612 }
613 },
614 onpointercancel: move |_| draft_seek.set(None),
615 }
616 input {
617 class: "dioxus-audio__interactive-input dioxus-audio__interactive-input--selection dioxus-audio__interactive-input--start",
618 style: "--_dxa-position: {start_percent}%; --_dxa-hit-offset: {start_hit_offset}",
619 r#type: "range",
620 min: "0",
621 max: "{waveform_duration}",
622 step: "{fine_step_secs}",
623 value: "{presented_selection.start()}",
624 aria_label: selection_start_label,
625 aria_valuemin: 0.0,
626 aria_valuemax: presented_selection.end(),
627 aria_valuetext: start_value_text,
628 oninput: move |event| {
629 if let Some(value) = source_time(&event.value(), waveform_duration) {
630 let current = *draft_selection.peek();
631 let current = current.unwrap_or(controlled_selection);
632 draft_selection.set(Some(current.with_start(value)));
633 }
634 },
635 onchange: move |event| {
636 if let Some(value) = source_time(&event.value(), waveform_duration) {
637 let current = *draft_selection.peek();
638 let next = current
639 .unwrap_or(controlled_selection)
640 .with_start(value)
641 .clamped_to_duration(waveform_duration);
642 draft_selection.set(None);
643 commit_selection_change(
644 controller,
645 controlled_selection,
646 next,
647 on_selection_change,
648 );
649 }
650 },
651 onkeydown: move |event| {
652 if let Some(value) = keyboard_source_time(
653 event.key(),
654 presented_selection.start(),
655 0.0,
656 presented_selection.end(),
657 fine_step_secs,
658 coarse_step_secs,
659 ) {
660 event.prevent_default();
661 draft_selection.set(None);
662 commit_selection_change(
663 controller,
664 controlled_selection,
665 controlled_selection.with_start(value),
666 on_selection_change,
667 );
668 }
669 },
670 onpointercancel: move |_| draft_selection.set(None),
671 }
672 input {
673 class: "dioxus-audio__interactive-input dioxus-audio__interactive-input--selection dioxus-audio__interactive-input--end",
674 style: "--_dxa-position: {end_percent}%; --_dxa-hit-offset: {end_hit_offset}",
675 r#type: "range",
676 min: "0",
677 max: "{waveform_duration}",
678 step: "{fine_step_secs}",
679 value: "{presented_selection.end()}",
680 aria_label: selection_end_label,
681 aria_valuemin: presented_selection.start(),
682 aria_valuemax: waveform_duration,
683 aria_valuetext: end_value_text,
684 oninput: move |event| {
685 if let Some(value) = source_time(&event.value(), waveform_duration) {
686 let current = *draft_selection.peek();
687 let current = current.unwrap_or(controlled_selection);
688 draft_selection.set(Some(current.with_end(value).clamped_to_duration(waveform_duration)));
689 }
690 },
691 onchange: move |event| {
692 if let Some(value) = source_time(&event.value(), waveform_duration) {
693 let current = *draft_selection.peek();
694 let next = current
695 .unwrap_or(controlled_selection)
696 .with_end(value)
697 .clamped_to_duration(waveform_duration);
698 draft_selection.set(None);
699 commit_selection_change(
700 controller,
701 controlled_selection,
702 next,
703 on_selection_change,
704 );
705 }
706 },
707 onkeydown: move |event| {
708 if let Some(value) = keyboard_source_time(
709 event.key(),
710 presented_selection.end(),
711 presented_selection.start(),
712 waveform_duration,
713 fine_step_secs,
714 coarse_step_secs,
715 ) {
716 event.prevent_default();
717 draft_selection.set(None);
718 commit_selection_change(
719 controller,
720 controlled_selection,
721 controlled_selection.with_end(value),
722 on_selection_change,
723 );
724 }
725 },
726 onpointercancel: move |_| draft_selection.set(None),
727 }
728 }
729 }
730}
731
732fn commit_selection_change(
733 controller: AudioPlayerController,
734 current: WaveformSelection,
735 next: WaveformSelection,
736 on_selection_change: EventHandler<WaveformSelection>,
737) {
738 if next != current {
739 controller.retarget_bounded_after_selection_commit(next);
740 }
741 on_selection_change.call(next);
742}
743
744fn positive_step(value: f64, fallback: f64) -> f64 {
745 if value.is_finite() && value > 0.0 {
746 value
747 } else {
748 fallback
749 }
750}
751
752fn source_time(value: &str, duration: f64) -> Option<f64> {
753 value
754 .parse::<f64>()
755 .ok()
756 .filter(|value| value.is_finite())
757 .map(|value| value.clamp(0.0, duration))
758}
759
760fn keyboard_source_time(
761 key: Key,
762 current: f64,
763 start: f64,
764 end: f64,
765 fine_step: f64,
766 coarse_step: f64,
767) -> Option<f64> {
768 let value = match key {
769 Key::ArrowDown | Key::ArrowLeft => current - fine_step,
770 Key::ArrowUp | Key::ArrowRight => current + fine_step,
771 Key::Home => start,
772 Key::End => end,
773 Key::PageDown => current - coarse_step,
774 Key::PageUp => current + coarse_step,
775 _ => return None,
776 };
777 Some(value.clamp(start, end))
778}
779
780#[component]
781pub fn WaveformPreview(
782 peaks: Vec<u8>,
783 #[props(default = 32)] bars: usize,
784 #[props(default = 32.0)] height: f64,
785 #[props(default)] label: Option<String>,
786) -> Element {
787 let bars = bars.max(1);
788 let reduced = downsample_peaks(&peaks, bars);
789 let values = if reduced.is_empty() || reduced.len() == bars {
790 reduced
791 } else {
792 (0..bars)
793 .map(|index| reduced[index * reduced.len() / bars])
794 .collect()
795 };
796 let amplitude_scale = values.iter().copied().max().unwrap_or(255).max(32) as f64;
797 let bar_width = 2.0;
798 let gap = 1.0;
799 let height = if height.is_finite() {
800 height.max(1.0)
801 } else {
802 32.0
803 };
804 let width = (values.len().max(1) as f64 * (bar_width + gap)).max(1.0);
805 let role = label.as_ref().map(|_| "img");
806
807 rsx! {
808 svg {
809 class: "dioxus-audio dioxus-audio__waveform",
810 role,
811 "aria-label": label,
812 "aria-hidden": role.is_none(),
813 width: "100%",
814 height: "{height}",
815 view_box: "0 0 {width} {height}",
816 preserve_aspect_ratio: "none",
817 if values.is_empty() {
818 line {
819 x1: "0",
820 x2: "{width}",
821 y1: "{height / 2.0}",
822 y2: "{height / 2.0}",
823 class: "dioxus-audio__waveform-idle",
824 }
825 } else {
826 for (index, value) in values.iter().enumerate() {
827 {
828 let bar_height = ((*value as f64 / amplitude_scale) * height).max(1.0);
829 let x = index as f64 * (bar_width + gap);
830 let y = (height - bar_height) / 2.0;
831 rsx! {
832 rect {
833 x: "{x}",
834 y: "{y}",
835 width: "{bar_width}",
836 height: "{bar_height}",
837 rx: "1",
838 }
839 }
840 }
841 }
842 }
843 }
844 }
845}
846
847#[component]
848pub fn WaveformRangeSelector(
849 peaks: Vec<u8>,
850 duration_secs: f64,
851 selection: WaveformSelection,
852 on_change: EventHandler<WaveformSelection>,
853 #[props(default)] label: Option<String>,
854) -> Element {
855 let duration_secs = if duration_secs.is_finite() && duration_secs > 0.0 {
856 duration_secs
857 } else {
858 0.0
859 };
860 let selection = selection.clamped_to_duration(duration_secs);
861 let start_percent = if duration_secs > 0.0 {
862 selection.start() / duration_secs * 100.0
863 } else {
864 0.0
865 };
866 let end_percent = if duration_secs > 0.0 {
867 selection.end() / duration_secs * 100.0
868 } else {
869 0.0
870 };
871 let selection_width = end_percent - start_percent;
872 let collapsed = selection.is_collapsed();
873 let label = label.unwrap_or_else(|| "Select audio range".to_string());
874 let start_value_text = format_accessible_duration(selection.start());
875 let end_value_text = format_accessible_duration(selection.end());
876 let disabled = duration_secs == 0.0;
877
878 rsx! {
879 div {
880 class: "dioxus-audio dioxus-audio__range",
881 role: "group",
882 aria_label: label,
883 "data-collapsed": collapsed,
884 WaveformPreview { peaks, label: None }
885 div {
886 class: "dioxus-audio__range-selection",
887 style: "left: {start_percent}%; width: {selection_width}%",
888 }
889 input {
890 class: "dioxus-audio__range-input dioxus-audio__range-input--start",
891 r#type: "range",
892 min: "0",
893 max: "{duration_secs}",
894 step: "any",
895 value: "{selection.start()}",
896 disabled,
897 aria_label: "Selection start",
898 aria_valuetext: start_value_text,
899 oninput: move |event| {
900 if let Ok(value) = event.value().parse::<f64>() {
901 on_change.call(selection.with_start(value).clamped_to_duration(duration_secs));
902 }
903 },
904 }
905 input {
906 class: "dioxus-audio__range-input dioxus-audio__range-input--end",
907 r#type: "range",
908 min: "0",
909 max: "{duration_secs}",
910 step: "any",
911 value: "{selection.end()}",
912 disabled,
913 aria_label: "Selection end",
914 aria_valuetext: end_value_text,
915 oninput: move |event| {
916 if let Ok(value) = event.value().parse::<f64>() {
917 on_change.call(selection.with_end(value).clamped_to_duration(duration_secs));
918 }
919 },
920 }
921 }
922 }
923}