Skip to main content

dioxus_audio/
devices.rs

1//! Audio input discovery and selection.
2
3use dioxus::prelude::*;
4
5use crate::AudioError;
6#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
7use crate::AudioErrorKind;
8use crate::{AudioInputDevice, AudioInputId};
9
10/// Keep a selected input only while it remains present in the latest device
11/// enumeration. `None` continues to mean the browser's default input.
12pub fn reconcile_selected_device(
13    selected: Option<AudioInputId>,
14    devices: &[AudioInputDevice],
15) -> Option<AudioInputId> {
16    selected.filter(|selected_id| devices.iter().any(|device| device.id == *selected_id))
17}
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum MicrophonePermission {
22    Unsupported,
23    Unknown,
24    Prompt,
25    Granted,
26    Denied,
27}
28
29#[derive(Clone, Debug, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum DeviceListStatus {
32    Unsupported,
33    Loading,
34    Ready,
35    Failed(AudioError),
36}
37
38#[derive(Clone, Copy, PartialEq)]
39pub struct AudioInputDevices {
40    devices: ReadSignal<Vec<AudioInputDevice>>,
41    selected: Signal<Option<AudioInputId>>,
42    status: ReadSignal<DeviceListStatus>,
43    permission: ReadSignal<MicrophonePermission>,
44    refresh: Callback,
45    request_permission: Callback,
46}
47
48impl AudioInputDevices {
49    pub fn devices(self) -> ReadSignal<Vec<AudioInputDevice>> {
50        self.devices
51    }
52
53    pub fn selected(self) -> Signal<Option<AudioInputId>> {
54        self.selected
55    }
56
57    pub fn status(self) -> ReadSignal<DeviceListStatus> {
58        self.status
59    }
60
61    pub fn permission(self) -> ReadSignal<MicrophonePermission> {
62        self.permission
63    }
64
65    pub fn select(mut self, id: Option<AudioInputId>) {
66        self.selected.set(id);
67    }
68
69    pub fn refresh(self) {
70        self.refresh.call(());
71    }
72
73    pub fn request_permission(self) {
74        self.request_permission.call(());
75    }
76}
77
78pub fn use_audio_input_devices() -> AudioInputDevices {
79    let devices = use_signal(Vec::<AudioInputDevice>::new);
80    let selected = use_signal(|| None::<AudioInputId>);
81
82    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
83    let mut status = use_signal(|| DeviceListStatus::Loading);
84    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
85    let mut status = use_signal(|| DeviceListStatus::Loading);
86
87    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
88    let mut permission = use_signal(|| MicrophonePermission::Unknown);
89    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
90    let mut permission = use_signal(|| MicrophonePermission::Unknown);
91
92    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
93    use_effect(move || {
94        status.set(DeviceListStatus::Unsupported);
95        permission.set(MicrophonePermission::Unsupported);
96    });
97
98    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
99    let refresh_generation = use_hook(|| std::rc::Rc::new(std::cell::Cell::new(0_u64)));
100    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
101    let permission_runtime = use_hook(|| std::rc::Rc::new(PermissionRuntime::default()));
102    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
103    {
104        let permission_runtime = permission_runtime.clone();
105        use_hook(|| std::rc::Rc::new(PermissionUnmountGuard(permission_runtime)));
106    }
107
108    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
109    let refresh = use_callback(move |()| {
110        let request = refresh_generation.get().wrapping_add(1);
111        refresh_generation.set(request);
112        status.set(DeviceListStatus::Loading);
113        let refresh_generation = refresh_generation.clone();
114        spawn(async move {
115            let result = web::enumerate_inputs().await;
116            if refresh_generation.get() != request {
117                return;
118            }
119            match result {
120                Ok(latest) => {
121                    if latest.iter().any(|device| !device.label.is_empty()) {
122                        permission.set(MicrophonePermission::Granted);
123                    }
124                    let reconciled = reconcile_selected_device(selected(), &latest);
125                    if reconciled != selected() {
126                        let mut selected = selected;
127                        selected.set(reconciled);
128                    }
129                    let mut devices = devices;
130                    devices.set(latest);
131                    status.set(DeviceListStatus::Ready);
132                }
133                Err(error) => status.set(DeviceListStatus::Failed(error)),
134            }
135        });
136    });
137
138    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
139    let refresh = use_callback(|()| {});
140
141    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
142    let request_permission = use_callback(move |()| {
143        let request = permission_runtime.generation.get().wrapping_add(1);
144        permission_runtime.generation.set(request);
145        permission.set(MicrophonePermission::Prompt);
146        let permission_runtime = permission_runtime.clone();
147        let dioxus_runtime = dioxus::core::Runtime::current();
148        let dioxus_scope = dioxus_runtime.current_scope_id();
149        wasm_bindgen_futures::spawn_local(async move {
150            let result = web::request_microphone_permission().await;
151            if !permission_runtime.mounted.get() || permission_runtime.generation.get() != request {
152                return;
153            }
154            dioxus_runtime.in_scope(dioxus_scope, || match result {
155                Ok(()) => {
156                    permission.set(MicrophonePermission::Granted);
157                    refresh.call(());
158                }
159                Err(error) => {
160                    permission.set(if error.kind() == AudioErrorKind::PermissionDenied {
161                        MicrophonePermission::Denied
162                    } else {
163                        MicrophonePermission::Unknown
164                    });
165                    status.set(DeviceListStatus::Failed(error));
166                }
167            });
168        });
169    });
170
171    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
172    let request_permission = use_callback(|()| {});
173
174    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
175    {
176        use_hook(move || {
177            refresh.call(());
178            std::rc::Rc::new(web::DeviceChangeListener::new(refresh))
179        });
180    }
181
182    AudioInputDevices {
183        devices: devices.into(),
184        selected,
185        status: status.into(),
186        permission: permission.into(),
187        refresh,
188        request_permission,
189    }
190}
191
192#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
193struct PermissionRuntime {
194    generation: std::cell::Cell<u64>,
195    mounted: std::cell::Cell<bool>,
196}
197
198#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
199impl Default for PermissionRuntime {
200    fn default() -> Self {
201        Self {
202            generation: std::cell::Cell::new(0),
203            mounted: std::cell::Cell::new(true),
204        }
205    }
206}
207
208#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
209struct PermissionUnmountGuard(std::rc::Rc<PermissionRuntime>);
210
211#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
212impl Drop for PermissionUnmountGuard {
213    fn drop(&mut self) {
214        self.0.mounted.set(false);
215        self.0
216            .generation
217            .set(self.0.generation.get().wrapping_add(1));
218    }
219}
220
221#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
222pub(crate) mod web {
223    use js_sys::Array;
224    use wasm_bindgen::JsCast;
225    use wasm_bindgen::prelude::*;
226    use web_sys::{MediaDeviceInfo, MediaDeviceKind, MediaDevices, MediaStream};
227
228    use super::*;
229
230    pub async fn enumerate_inputs() -> Result<Vec<AudioInputDevice>, AudioError> {
231        let media_devices = media_devices()?;
232        let value = wasm_bindgen_futures::JsFuture::from(
233            media_devices
234                .enumerate_devices()
235                .map_err(audio_error_from_js)?,
236        )
237        .await
238        .map_err(audio_error_from_js)?;
239        let entries = Array::from(&value);
240
241        Ok(entries
242            .iter()
243            .filter_map(|entry| entry.dyn_into::<MediaDeviceInfo>().ok())
244            .filter(|device| device.kind() == MediaDeviceKind::Audioinput)
245            .map(|device| {
246                let id = device.device_id();
247                AudioInputDevice::new(AudioInputId::new(&id), device.label(), id == "default")
248            })
249            .collect())
250    }
251
252    pub async fn request_microphone_permission() -> Result<(), AudioError> {
253        let constraints = web_sys::MediaStreamConstraints::new();
254        constraints.set_audio(&JsValue::TRUE);
255        let value = wasm_bindgen_futures::JsFuture::from(
256            media_devices()?
257                .get_user_media_with_constraints(&constraints)
258                .map_err(audio_error_from_js)?,
259        )
260        .await
261        .map_err(audio_error_from_js)?;
262        let stream = value
263            .dyn_into::<MediaStream>()
264            .map_err(audio_error_from_js)?;
265        stop_stream(&stream);
266        Ok(())
267    }
268
269    pub struct DeviceChangeListener {
270        media_devices: Option<MediaDevices>,
271        callback: Option<Closure<dyn FnMut()>>,
272    }
273
274    impl DeviceChangeListener {
275        pub fn new(refresh: Callback) -> Self {
276            let Ok(media_devices) = media_devices() else {
277                return Self {
278                    media_devices: None,
279                    callback: None,
280                };
281            };
282            let runtime = dioxus::core::Runtime::current();
283            let scope = runtime.current_scope_id();
284            let callback =
285                Closure::wrap(
286                    Box::new(move || runtime.in_scope(scope, || refresh.call(())))
287                        as Box<dyn FnMut()>,
288                );
289            let _ = media_devices.add_event_listener_with_callback(
290                "devicechange",
291                callback.as_ref().unchecked_ref(),
292            );
293            Self {
294                media_devices: Some(media_devices),
295                callback: Some(callback),
296            }
297        }
298    }
299
300    impl Drop for DeviceChangeListener {
301        fn drop(&mut self) {
302            if let (Some(media_devices), Some(callback)) = (&self.media_devices, &self.callback) {
303                let _ = media_devices.remove_event_listener_with_callback(
304                    "devicechange",
305                    callback.as_ref().unchecked_ref(),
306                );
307            }
308        }
309    }
310
311    pub(crate) fn media_devices() -> Result<MediaDevices, AudioError> {
312        web_sys::window()
313            .ok_or_else(AudioError::unsupported)?
314            .navigator()
315            .media_devices()
316            .map_err(audio_error_from_js)
317    }
318
319    pub(crate) fn stop_stream(stream: &MediaStream) {
320        for track in stream.get_tracks().iter() {
321            if let Ok(track) = track.dyn_into::<web_sys::MediaStreamTrack>() {
322                track.stop();
323            }
324        }
325    }
326
327    pub(crate) fn audio_error_from_js(value: JsValue) -> AudioError {
328        let name = value
329            .dyn_ref::<web_sys::DomException>()
330            .map(web_sys::DomException::name)
331            .unwrap_or_default();
332        let message = value
333            .dyn_ref::<web_sys::DomException>()
334            .map(web_sys::DomException::message)
335            .or_else(|| value.as_string())
336            .filter(|message| !message.is_empty())
337            .unwrap_or_else(|| "browser audio operation failed".to_string());
338        if name == "OverconstrainedError" {
339            let constraint = js_sys::Reflect::get(&value, &JsValue::from_str("constraint"))
340                .ok()
341                .and_then(|constraint| constraint.as_string())
342                .unwrap_or_default();
343            return AudioError::overconstrained(constraint, message);
344        }
345        let kind = match name.as_str() {
346            "NotAllowedError" | "SecurityError" => AudioErrorKind::PermissionDenied,
347            "NotFoundError" => AudioErrorKind::DeviceNotFound,
348            "NotReadableError" | "AbortError" => AudioErrorKind::DeviceUnavailable,
349            _ => AudioErrorKind::Backend,
350        };
351        AudioError::new(kind, message)
352    }
353}