Skip to main content

dioxus_audio/components/
recorder.rs

1use dioxus::prelude::*;
2use dioxus_icons::lucide::{Mic, Pause, Play, Square, X};
3
4use crate::AudioErrorKind;
5use crate::recorder::{AudioRecorder, RecorderStatus};
6
7/// Localizable messages emitted by [`RecorderStatusAnnouncer`].
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct RecorderAnnouncementLabels {
10    pub idle: String,
11    pub preparing: String,
12    pub recording: String,
13    pub paused: String,
14    pub stopping: String,
15    pub failed: String,
16}
17
18impl Default for RecorderAnnouncementLabels {
19    fn default() -> Self {
20        Self {
21            idle: "Recorder idle".to_string(),
22            preparing: "Preparing recording".to_string(),
23            recording: "Recording".to_string(),
24            paused: "Recording paused".to_string(),
25            stopping: "Finishing recording".to_string(),
26            failed: "Recording failed".to_string(),
27        }
28    }
29}
30
31/// An optional polite live region for coarse Recorder state changes.
32#[component]
33pub fn RecorderStatusAnnouncer(
34    recorder: AudioRecorder,
35    #[props(default)] labels: RecorderAnnouncementLabels,
36) -> Element {
37    let status = recorder.status()();
38    let message = match status {
39        RecorderStatus::Idle => labels.idle.as_str(),
40        RecorderStatus::Preparing => labels.preparing.as_str(),
41        RecorderStatus::Recording => labels.recording.as_str(),
42        RecorderStatus::Paused => labels.paused.as_str(),
43        RecorderStatus::Stopping => labels.stopping.as_str(),
44        RecorderStatus::Failed(_) => labels.failed.as_str(),
45    };
46
47    rsx! {
48        div {
49            class: "dioxus-audio dioxus-audio__status-announcer",
50            role: "status",
51            aria_live: "polite",
52            aria_atomic: "true",
53            "{message}"
54        }
55    }
56}
57
58/// A native button that starts a Recording.
59#[component]
60pub fn RecorderStartButton(
61    recorder: AudioRecorder,
62    #[props(default = "Start recording".to_string())] label: String,
63    #[props(default = "Clear recorded audio before starting".to_string())] completed_label: String,
64) -> Element {
65    let status = recorder.status()();
66    let has_completed = recorder.completed().read().is_some();
67    let disabled = has_completed || !can_start(&status);
68    let aria_label = if has_completed {
69        completed_label
70    } else {
71        label
72    };
73
74    rsx! {
75        button {
76            class: "dioxus-audio dioxus-audio__record-action dioxus-audio__record-action--record",
77            r#type: "button",
78            aria_label,
79            disabled,
80            onclick: move |_| { let _ = recorder.start(); },
81            Mic { size: 24 }
82        }
83    }
84}
85
86/// A native button that cancels a pending or active Recording.
87#[component]
88pub fn RecorderCancelButton(
89    recorder: AudioRecorder,
90    #[props(default = "Cancel recording preparation".to_string())] preparing_label: String,
91    #[props(default = "Cancel recording".to_string())] recording_label: String,
92    #[props(default)] on_cancelled: Option<EventHandler<()>>,
93) -> Element {
94    let status = recorder.status()();
95    let preparing = matches!(status, RecorderStatus::Preparing);
96    let disabled = !can_cancel(&status);
97    let aria_label = if preparing {
98        preparing_label
99    } else {
100        recording_label
101    };
102
103    rsx! {
104        button {
105            class: "dioxus-audio dioxus-audio__record-action",
106            r#type: "button",
107            aria_label,
108            disabled,
109            onclick: move |_| {
110                if recorder.cancel().is_ok()
111                    && let Some(on_cancelled) = on_cancelled
112                {
113                    on_cancelled.call(());
114                }
115            },
116            X { size: 20 }
117        }
118    }
119}
120
121/// A stable native button that pauses or resumes a Recording.
122#[component]
123pub fn RecorderPauseResumeButton(
124    recorder: AudioRecorder,
125    #[props(default = "Pause".to_string())] pause_label: String,
126    #[props(default = "Resume".to_string())] resume_label: String,
127) -> Element {
128    let status = recorder.status()();
129    let paused = matches!(status, RecorderStatus::Paused);
130    let disabled = !can_control_transport(&status);
131    let aria_label = if paused { resume_label } else { pause_label };
132
133    rsx! {
134        button {
135            class: "dioxus-audio dioxus-audio__record-action",
136            r#type: "button",
137            aria_label,
138            disabled,
139            onclick: move |_| {
140                if paused {
141                    let _ = recorder.resume();
142                } else {
143                    let _ = recorder.pause();
144                }
145            },
146            if paused {
147                Play { size: 20 }
148            } else {
149                Pause { size: 20 }
150            }
151        }
152    }
153}
154
155/// A native button that finishes a Recording.
156#[component]
157pub fn RecorderStopButton(
158    recorder: AudioRecorder,
159    #[props(default = "Stop recording".to_string())] stop_label: String,
160    #[props(default = "Finishing recording".to_string())] stopping_label: String,
161) -> Element {
162    let status = recorder.status()();
163    let stopping = matches!(status, RecorderStatus::Stopping);
164    let disabled = !can_control_transport(&status);
165    let aria_label = if stopping { stopping_label } else { stop_label };
166
167    rsx! {
168        button {
169            class: "dioxus-audio dioxus-audio__record-action dioxus-audio__record-action--stop",
170            r#type: "button",
171            aria_label,
172            aria_busy: stopping,
173            disabled,
174            onclick: move |_| { let _ = recorder.stop(); },
175            Square { size: 22 }
176        }
177    }
178}
179
180/// A native button that discards the retained completed Recorded Audio.
181#[component]
182pub fn RecorderClearButton(
183    recorder: AudioRecorder,
184    #[props(default = "Clear recorded audio".to_string())] label: String,
185) -> Element {
186    let disabled = recorder.completed().read().is_none();
187
188    rsx! {
189        button {
190            class: "dioxus-audio dioxus-audio__record-action",
191            r#type: "button",
192            aria_label: label,
193            disabled,
194            onclick: move |_| recorder.clear_completed(),
195            X { size: 20 }
196        }
197    }
198}
199
200fn can_start(status: &RecorderStatus) -> bool {
201    match status {
202        RecorderStatus::Idle => true,
203        RecorderStatus::Failed(error) => !matches!(
204            error.kind(),
205            AudioErrorKind::UnsupportedPlatform | AudioErrorKind::InvalidConfiguration
206        ),
207        RecorderStatus::Preparing
208        | RecorderStatus::Recording
209        | RecorderStatus::Paused
210        | RecorderStatus::Stopping => false,
211    }
212}
213
214fn can_cancel(status: &RecorderStatus) -> bool {
215    matches!(
216        status,
217        RecorderStatus::Preparing | RecorderStatus::Recording | RecorderStatus::Paused
218    )
219}
220
221fn can_control_transport(status: &RecorderStatus) -> bool {
222    matches!(status, RecorderStatus::Recording | RecorderStatus::Paused)
223}
224
225#[component]
226pub fn RecorderControls(
227    recorder: AudioRecorder,
228    #[props(default)] on_cancelled: Option<EventHandler<()>>,
229) -> Element {
230    let status = recorder.status()();
231    let state_name = recorder_state_name(&status);
232
233    rsx! {
234        div {
235            class: "dioxus-audio dioxus-audio__recorder-controls",
236            "data-state": state_name,
237            match status {
238                RecorderStatus::Idle | RecorderStatus::Failed(_) => rsx! {
239                    RecorderStartButton { recorder }
240                },
241                RecorderStatus::Preparing => rsx! {
242                    RecorderCancelButton { recorder, on_cancelled }
243                },
244                RecorderStatus::Recording | RecorderStatus::Paused => rsx! {
245                    RecorderCancelButton { recorder, on_cancelled }
246                    RecorderPauseResumeButton { recorder }
247                    RecorderStopButton { recorder }
248                },
249                RecorderStatus::Stopping => rsx! {
250                    RecorderStopButton { recorder }
251                },
252            }
253        }
254    }
255}
256
257fn recorder_state_name(status: &RecorderStatus) -> &'static str {
258    match status {
259        RecorderStatus::Idle => "idle",
260        RecorderStatus::Preparing => "preparing",
261        RecorderStatus::Recording => "recording",
262        RecorderStatus::Paused => "paused",
263        RecorderStatus::Stopping => "stopping",
264        RecorderStatus::Failed(_) => "error",
265    }
266}