Skip to main content

dioxus_audio/components/
waveform.rs

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/// Render immutable Waveform Data as one responsive SVG path per channel.
21#[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/// Present and navigate one measured, source-time Waveform Viewport.
215///
216/// The server and first client render use `fallback_bucket_budget`. Once
217/// mounted, the component derives a per-channel budget from its observed CSS
218/// width and browser pixel density, capped at 2x density and 4,096 buckets.
219#[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/// Present Playback position and one controlled Waveform Selection over Waveform Data.
491///
492/// The three interactions remain native sliders. Pointer movement is presented as an
493/// internal draft and is committed through the Controller or `on_selection_change`
494/// when the native interaction completes.
495#[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}