use gloo_utils::window;
use js_sys::{Array, Promise};
use std::cell::RefCell;
use std::rc::Rc;
use videocall_types::Callback;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
#[cfg(test)]
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::JsFuture;
use web_sys::{Event, MediaDeviceInfo, MediaDeviceKind};
pub trait MediaDevicesProvider: 'static {
fn enumerate_devices(&self) -> Promise;
fn set_device_change_handler(&self, handler: &js_sys::Function);
}
#[derive(Clone)]
pub struct BrowserMediaDevicesProvider;
impl MediaDevicesProvider for BrowserMediaDevicesProvider {
fn enumerate_devices(&self) -> Promise {
window()
.navigator()
.media_devices()
.expect("media devices")
.enumerate_devices()
.expect("enumerate devices")
}
fn set_device_change_handler(&self, handler: &js_sys::Function) {
window()
.navigator()
.media_devices()
.expect("media devices")
.set_ondevicechange(Some(handler));
}
}
#[cfg(test)]
type DeviceChangeHandler = Rc<RefCell<Option<Closure<dyn FnMut(Event)>>>>;
#[cfg(test)]
#[derive(Clone)]
pub struct MockMediaDevicesProvider {
devices: Rc<RefCell<Vec<MediaDeviceInfo>>>,
device_change_handler: DeviceChangeHandler,
}
#[cfg(test)]
impl MockMediaDevicesProvider {
pub fn new(initial_devices: Vec<MediaDeviceInfo>) -> Self {
Self {
devices: Rc::new(RefCell::new(initial_devices)),
device_change_handler: Rc::new(RefCell::new(None)),
}
}
pub fn simulate_device_change(&self, new_devices: Vec<MediaDeviceInfo>) {
*self.devices.borrow_mut() = new_devices;
if let Some(handler) = self.device_change_handler.borrow().as_ref() {
let handler_js = handler.as_ref().unchecked_ref::<js_sys::Function>();
let _ = handler_js.call0(&JsValue::NULL);
}
}
}
#[cfg(test)]
impl MediaDevicesProvider for MockMediaDevicesProvider {
fn enumerate_devices(&self) -> Promise {
let devices = self.devices.borrow().clone();
let array = Array::new();
for device in devices {
array.push(&device);
}
Promise::resolve(&array)
}
fn set_device_change_handler(&self, handler: &js_sys::Function) {
let handler_cloned = handler.clone();
*self.device_change_handler.borrow_mut() =
Some(Closure::wrap(Box::new(move |event: Event| {
let _ = handler_cloned.call1(&JsValue::NULL, &event);
}) as Box<dyn FnMut(Event)>));
}
}
pub struct SelectableDevices {
devices: Rc<RefCell<Vec<MediaDeviceInfo>>>,
selected: Rc<RefCell<Option<String>>>,
pub on_selected: Callback<String>,
}
impl SelectableDevices {
fn new() -> Self {
Self {
devices: Rc::new(RefCell::new(Vec::new())),
selected: Rc::new(RefCell::new(None)),
on_selected: Callback::noop(),
}
}
pub fn select(&mut self, device_id: &str) {
let devices = self.devices.borrow();
for device in devices.iter() {
if device.device_id() == device_id {
*self.selected.borrow_mut() = Some(device_id.to_string());
self.on_selected.emit(device_id.to_string());
}
}
}
pub fn devices(&self) -> Vec<MediaDeviceInfo> {
self.devices.borrow().clone()
}
pub fn set_devices(&self, new_devices: Vec<MediaDeviceInfo>) {
*self.devices.borrow_mut() = new_devices;
}
pub fn selected(&self) -> String {
match &*self.selected.borrow() {
Some(selected) => selected.to_string(),
None => {
let devices = self.devices.borrow();
match devices.first() {
Some(device) => device.device_id(),
None => "".to_string(),
}
}
}
}
}
impl Clone for SelectableDevices {
fn clone(&self) -> Self {
Self {
devices: self.devices.clone(),
selected: self.selected.clone(),
on_selected: self.on_selected.clone(),
}
}
}
pub struct MediaDeviceList<P: MediaDevicesProvider + Clone = BrowserMediaDevicesProvider> {
pub audio_inputs: SelectableDevices,
pub video_inputs: SelectableDevices,
pub audio_outputs: SelectableDevices,
pub on_loaded: Callback<()>,
pub on_devices_changed: Callback<()>,
provider: P,
device_change_closure: Option<Closure<dyn FnMut(Event)>>,
}
impl<P: MediaDevicesProvider + Clone> MediaDeviceList<P> {
pub fn with_provider(provider: P) -> Self {
Self {
audio_inputs: SelectableDevices::new(),
video_inputs: SelectableDevices::new(),
audio_outputs: SelectableDevices::new(),
on_loaded: Callback::noop(),
on_devices_changed: Callback::noop(),
provider,
device_change_closure: None,
}
}
fn setup_device_change_listener(&mut self) {
let provider_clone = self.provider.clone();
let on_devices_changed = self.on_devices_changed.clone();
let on_audio_selected = self.audio_inputs.on_selected.clone();
let on_video_selected = self.video_inputs.on_selected.clone();
let on_audio_output_selected = self.audio_outputs.on_selected.clone();
let audio_input_devices = self.audio_inputs.devices.clone();
let video_input_devices = self.video_inputs.devices.clone();
let audio_output_devices = self.audio_outputs.devices.clone();
let audio_input_selected = self.audio_inputs.selected.clone();
let video_input_selected = self.video_inputs.selected.clone();
let audio_output_selected = self.audio_outputs.selected.clone();
let closure = Closure::wrap(Box::new(move |_event: Event| {
let audio_input_devices_clone = audio_input_devices.clone();
let video_input_devices_clone = video_input_devices.clone();
let audio_output_devices_clone = audio_output_devices.clone();
let on_devices_changed_clone = on_devices_changed.clone();
let on_audio_selected_clone = on_audio_selected.clone();
let on_video_selected_clone = on_video_selected.clone();
let on_audio_output_selected_clone = on_audio_output_selected.clone();
let audio_input_selected_for_write = audio_input_selected.clone();
let video_input_selected_for_write = video_input_selected.clone();
let audio_output_selected_for_write = audio_output_selected.clone();
let provider_promise = provider_clone.enumerate_devices();
let current_audio_selection = audio_input_selected.borrow().clone().unwrap_or_default();
let current_video_selection = video_input_selected.borrow().clone().unwrap_or_default();
let current_audio_output_selection =
audio_output_selected.borrow().clone().unwrap_or_default();
wasm_bindgen_futures::spawn_local(async move {
let future = JsFuture::from(provider_promise);
let devices = future
.await
.expect("await devices")
.unchecked_into::<Array>();
let devices = devices.to_vec();
let devices = devices
.into_iter()
.map(|d| d.unchecked_into::<MediaDeviceInfo>())
.collect::<Vec<MediaDeviceInfo>>();
let audio_devices = devices
.clone()
.into_iter()
.filter(|device| device.kind() == MediaDeviceKind::Audioinput)
.collect::<Vec<MediaDeviceInfo>>();
let video_devices = devices
.clone()
.into_iter()
.filter(|device| device.kind() == MediaDeviceKind::Videoinput)
.collect::<Vec<MediaDeviceInfo>>();
let audio_output_device_list = devices
.into_iter()
.filter(|device| device.kind() == MediaDeviceKind::Audiooutput)
.collect::<Vec<MediaDeviceInfo>>();
let old_audio_devices: Vec<MediaDeviceInfo> =
audio_input_devices_clone.borrow().clone();
let old_video_devices: Vec<MediaDeviceInfo> =
video_input_devices_clone.borrow().clone();
let old_audio_output_devices: Vec<MediaDeviceInfo> =
audio_output_devices_clone.borrow().clone();
*audio_input_devices_clone.borrow_mut() = audio_devices.clone();
*video_input_devices_clone.borrow_mut() = video_devices.clone();
*audio_output_devices_clone.borrow_mut() = audio_output_device_list.clone();
let audio_device_still_exists = !current_audio_selection.is_empty()
&& audio_devices
.iter()
.any(|device| device.device_id() == current_audio_selection);
let video_device_still_exists = !current_video_selection.is_empty()
&& video_devices
.iter()
.any(|device| device.device_id() == current_video_selection);
let audio_output_device_still_exists = !current_audio_output_selection.is_empty()
&& audio_output_device_list
.iter()
.any(|device| device.device_id() == current_audio_output_selection);
let devices_changed = {
let old_audio_ids: Vec<String> =
old_audio_devices.iter().map(|d| d.device_id()).collect();
let new_audio_ids: Vec<String> =
audio_devices.iter().map(|d| d.device_id()).collect();
let old_video_ids: Vec<String> =
old_video_devices.iter().map(|d| d.device_id()).collect();
let new_video_ids: Vec<String> =
video_devices.iter().map(|d| d.device_id()).collect();
let old_audio_output_ids: Vec<String> = old_audio_output_devices
.iter()
.map(|d| d.device_id())
.collect();
let new_audio_output_ids: Vec<String> = audio_output_device_list
.iter()
.map(|d| d.device_id())
.collect();
old_audio_ids != new_audio_ids
|| old_video_ids != new_video_ids
|| old_audio_output_ids != new_audio_output_ids
};
if devices_changed {
on_devices_changed_clone.emit(());
}
if !audio_device_still_exists {
if let Some(device) = audio_devices.first() {
let new_id = device.device_id();
*audio_input_selected_for_write.borrow_mut() = Some(new_id.clone());
on_audio_selected_clone.emit(new_id);
}
}
if !video_device_still_exists {
if let Some(device) = video_devices.first() {
let new_id = device.device_id();
*video_input_selected_for_write.borrow_mut() = Some(new_id.clone());
on_video_selected_clone.emit(new_id);
}
}
if !audio_output_device_still_exists {
if let Some(device) = audio_output_device_list.first() {
let new_id = device.device_id();
*audio_output_selected_for_write.borrow_mut() = Some(new_id.clone());
on_audio_output_selected_clone.emit(new_id);
}
}
});
}) as Box<dyn FnMut(Event)>);
self.device_change_closure = Some(closure);
if let Some(closure_ref) = &self.device_change_closure {
self.provider
.set_device_change_handler(closure_ref.as_ref().unchecked_ref());
}
}
pub fn load(&mut self) {
self.setup_device_change_listener();
let on_loaded = self.on_loaded.clone();
let on_audio_selected = self.audio_inputs.on_selected.clone();
let on_video_selected = self.video_inputs.on_selected.clone();
let on_audio_output_selected = self.audio_outputs.on_selected.clone();
let audio_input_devices = self.audio_inputs.devices.clone();
let video_input_devices = self.video_inputs.devices.clone();
let audio_output_devices = self.audio_outputs.devices.clone();
let provider_promise = self.provider.enumerate_devices();
wasm_bindgen_futures::spawn_local(async move {
let future = JsFuture::from(provider_promise);
let devices = future
.await
.expect("await devices")
.unchecked_into::<Array>();
let devices = devices.to_vec();
let devices = devices
.into_iter()
.map(|d| d.unchecked_into::<MediaDeviceInfo>())
.collect::<Vec<MediaDeviceInfo>>();
let audio_devices = devices
.clone()
.into_iter()
.filter(|device| device.kind() == MediaDeviceKind::Audioinput)
.collect::<Vec<MediaDeviceInfo>>();
let video_devices = devices
.clone()
.into_iter()
.filter(|device| device.kind() == MediaDeviceKind::Videoinput)
.collect::<Vec<MediaDeviceInfo>>();
let audio_output_device_list = devices
.into_iter()
.filter(|device| device.kind() == MediaDeviceKind::Audiooutput)
.collect::<Vec<MediaDeviceInfo>>();
*audio_input_devices.borrow_mut() = audio_devices;
*video_input_devices.borrow_mut() = video_devices;
*audio_output_devices.borrow_mut() = audio_output_device_list;
on_loaded.emit(());
if let Some(device) = audio_input_devices.borrow().first() {
on_audio_selected.emit(device.device_id())
}
if let Some(device) = video_input_devices.borrow().first() {
on_video_selected.emit(device.device_id())
}
if let Some(device) = audio_output_devices.borrow().first() {
on_audio_output_selected.emit(device.device_id())
}
});
}
}
impl Default for MediaDeviceList {
fn default() -> Self {
Self::with_provider(BrowserMediaDevicesProvider)
}
}
#[allow(clippy::new_without_default)]
impl MediaDeviceList {
pub fn new() -> Self {
Self::default()
}
}
impl<P: MediaDevicesProvider + Clone> Clone for MediaDeviceList<P> {
fn clone(&self) -> Self {
Self {
audio_inputs: self.audio_inputs.clone(),
video_inputs: self.video_inputs.clone(),
audio_outputs: self.audio_outputs.clone(),
on_loaded: self.on_loaded.clone(),
on_devices_changed: self.on_devices_changed.clone(),
provider: self.provider.clone(),
device_change_closure: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::JsValue;
use wasm_bindgen_test::*;
fn create_mock_device(id: &str, kind: MediaDeviceKind, label: &str) -> MediaDeviceInfo {
let device = js_sys::Object::new();
js_sys::Reflect::set(&device, &"deviceId".into(), &id.into()).unwrap();
js_sys::Reflect::set(&device, &"kind".into(), &kind.into()).unwrap();
js_sys::Reflect::set(&device, &"label".into(), &label.into()).unwrap();
js_sys::Reflect::set(&device, &"groupId".into(), &"group1".into()).unwrap();
device.unchecked_into::<MediaDeviceInfo>()
}
#[wasm_bindgen_test]
fn test_basic_media_device_list_functionality() {
let mut media_device_list = MediaDeviceList::new();
assert_eq!(media_device_list.audio_inputs.devices().len(), 0);
assert_eq!(media_device_list.video_inputs.devices().len(), 0);
assert_eq!(media_device_list.audio_outputs.devices().len(), 0);
assert_eq!(media_device_list.audio_inputs.selected(), "");
assert_eq!(media_device_list.video_inputs.selected(), "");
assert_eq!(media_device_list.audio_outputs.selected(), "");
let loaded_called = Rc::new(RefCell::new(false));
let loaded_called_clone = loaded_called.clone();
media_device_list.on_loaded = Callback::from(move |_| {
*loaded_called_clone.borrow_mut() = true;
});
let selected_audio = Rc::new(RefCell::new(String::new()));
let selected_audio_clone = selected_audio.clone();
media_device_list.audio_inputs.on_selected = Callback::from(move |device_id| {
*selected_audio_clone.borrow_mut() = device_id;
});
let selected_video = Rc::new(RefCell::new(String::new()));
let selected_video_clone = selected_video.clone();
media_device_list.video_inputs.on_selected = Callback::from(move |device_id| {
*selected_video_clone.borrow_mut() = device_id;
});
let selected_audio_output = Rc::new(RefCell::new(String::new()));
let selected_audio_output_clone = selected_audio_output.clone();
media_device_list.audio_outputs.on_selected = Callback::from(move |device_id| {
*selected_audio_output_clone.borrow_mut() = device_id;
});
media_device_list.audio_inputs.select("non-existent-device");
assert_eq!(*selected_audio.borrow(), "");
media_device_list.video_inputs.select("non-existent-device");
assert_eq!(*selected_video.borrow(), "");
media_device_list
.audio_outputs
.select("non-existent-device");
assert_eq!(*selected_audio_output.borrow(), "");
}
async fn flush() {
for _ in 0..3 {
wasm_bindgen_futures::JsFuture::from(Promise::resolve(&JsValue::NULL))
.await
.unwrap();
}
}
#[wasm_bindgen_test]
async fn test_load_populates_device_lists_and_selects_first() {
let audio1 = create_mock_device("mic-1", MediaDeviceKind::Audioinput, "Mic 1");
let video1 = create_mock_device("cam-1", MediaDeviceKind::Videoinput, "Camera 1");
let spk1 = create_mock_device("spk-1", MediaDeviceKind::Audiooutput, "Speaker 1");
let provider =
MockMediaDevicesProvider::new(vec![audio1.clone(), video1.clone(), spk1.clone()]);
let mut mdl = MediaDeviceList::with_provider(provider);
let loaded = Rc::new(RefCell::new(false));
let loaded_c = loaded.clone();
mdl.on_loaded = Callback::from(move |_| *loaded_c.borrow_mut() = true);
let sel_audio = Rc::new(RefCell::new(String::new()));
let sel_audio_c = sel_audio.clone();
mdl.audio_inputs.on_selected = Callback::from(move |id| *sel_audio_c.borrow_mut() = id);
let sel_video = Rc::new(RefCell::new(String::new()));
let sel_video_c = sel_video.clone();
mdl.video_inputs.on_selected = Callback::from(move |id| *sel_video_c.borrow_mut() = id);
let sel_spk = Rc::new(RefCell::new(String::new()));
let sel_spk_c = sel_spk.clone();
mdl.audio_outputs.on_selected = Callback::from(move |id| *sel_spk_c.borrow_mut() = id);
mdl.load();
flush().await;
assert!(*loaded.borrow(), "on_loaded should have been called");
assert_eq!(mdl.audio_inputs.devices().len(), 1);
assert_eq!(mdl.video_inputs.devices().len(), 1);
assert_eq!(mdl.audio_outputs.devices().len(), 1);
assert_eq!(*sel_audio.borrow(), "mic-1");
assert_eq!(*sel_video.borrow(), "cam-1");
assert_eq!(*sel_spk.borrow(), "spk-1");
}
#[wasm_bindgen_test]
async fn test_switch_device_fires_on_selected() {
let mic1 = create_mock_device("mic-1", MediaDeviceKind::Audioinput, "Mic 1");
let mic2 = create_mock_device("mic-2", MediaDeviceKind::Audioinput, "Mic 2");
let provider = MockMediaDevicesProvider::new(vec![mic1.clone(), mic2.clone()]);
let mut mdl = MediaDeviceList::with_provider(provider);
let sel = Rc::new(RefCell::new(String::new()));
let sel_c = sel.clone();
mdl.audio_inputs.on_selected = Callback::from(move |id| *sel_c.borrow_mut() = id);
mdl.load();
flush().await;
assert_eq!(*sel.borrow(), "mic-1");
mdl.audio_inputs.select("mic-2");
assert_eq!(*sel.borrow(), "mic-2");
assert_eq!(mdl.audio_inputs.selected(), "mic-2");
}
#[wasm_bindgen_test]
async fn test_hot_plug_device_added() {
let mic1 = create_mock_device("mic-1", MediaDeviceKind::Audioinput, "Mic 1");
let provider = MockMediaDevicesProvider::new(vec![mic1.clone()]);
let mut mdl = MediaDeviceList::with_provider(provider.clone());
let changed = Rc::new(RefCell::new(false));
let changed_c = changed.clone();
mdl.on_devices_changed = Callback::from(move |_| *changed_c.borrow_mut() = true);
mdl.audio_inputs.on_selected = Callback::noop();
mdl.video_inputs.on_selected = Callback::noop();
mdl.audio_outputs.on_selected = Callback::noop();
mdl.load();
flush().await;
assert_eq!(mdl.audio_inputs.devices().len(), 1);
let mic2 = create_mock_device("mic-2", MediaDeviceKind::Audioinput, "Mic 2");
provider.simulate_device_change(vec![mic1.clone(), mic2.clone()]);
flush().await;
assert!(*changed.borrow(), "on_devices_changed should fire");
assert_eq!(mdl.audio_inputs.devices().len(), 2);
}
#[wasm_bindgen_test]
async fn test_hot_plug_device_removed() {
let mic1 = create_mock_device("mic-1", MediaDeviceKind::Audioinput, "Mic 1");
let mic2 = create_mock_device("mic-2", MediaDeviceKind::Audioinput, "Mic 2");
let provider = MockMediaDevicesProvider::new(vec![mic1.clone(), mic2.clone()]);
let mut mdl = MediaDeviceList::with_provider(provider.clone());
let changed = Rc::new(RefCell::new(false));
let changed_c = changed.clone();
mdl.on_devices_changed = Callback::from(move |_| *changed_c.borrow_mut() = true);
mdl.audio_inputs.on_selected = Callback::noop();
mdl.video_inputs.on_selected = Callback::noop();
mdl.audio_outputs.on_selected = Callback::noop();
mdl.load();
flush().await;
assert_eq!(mdl.audio_inputs.devices().len(), 2);
provider.simulate_device_change(vec![mic1.clone()]);
flush().await;
assert!(*changed.borrow(), "on_devices_changed should fire");
assert_eq!(mdl.audio_inputs.devices().len(), 1);
}
#[wasm_bindgen_test]
async fn test_selected_device_disappears_falls_back() {
let mic1 = create_mock_device("mic-1", MediaDeviceKind::Audioinput, "Mic 1");
let mic2 = create_mock_device("mic-2", MediaDeviceKind::Audioinput, "Mic 2");
let provider = MockMediaDevicesProvider::new(vec![mic1.clone(), mic2.clone()]);
let mut mdl = MediaDeviceList::with_provider(provider.clone());
let sel = Rc::new(RefCell::new(String::new()));
let sel_c = sel.clone();
mdl.audio_inputs.on_selected = Callback::from(move |id| *sel_c.borrow_mut() = id);
mdl.video_inputs.on_selected = Callback::noop();
mdl.audio_outputs.on_selected = Callback::noop();
mdl.load();
flush().await;
mdl.audio_inputs.select("mic-2");
assert_eq!(*sel.borrow(), "mic-2");
provider.simulate_device_change(vec![mic1.clone()]);
flush().await;
assert_eq!(
*sel.borrow(),
"mic-1",
"selection should fall back to first device when selected device disappears"
);
}
#[wasm_bindgen_test]
async fn test_selected_device_persists_through_change() {
let mic1 = create_mock_device("mic-1", MediaDeviceKind::Audioinput, "Mic 1");
let mic2 = create_mock_device("mic-2", MediaDeviceKind::Audioinput, "Mic 2");
let provider = MockMediaDevicesProvider::new(vec![mic1.clone(), mic2.clone()]);
let mut mdl = MediaDeviceList::with_provider(provider.clone());
let sel = Rc::new(RefCell::new(String::new()));
let sel_c = sel.clone();
mdl.audio_inputs.on_selected = Callback::from(move |id| *sel_c.borrow_mut() = id);
mdl.video_inputs.on_selected = Callback::noop();
mdl.audio_outputs.on_selected = Callback::noop();
mdl.load();
flush().await;
mdl.audio_inputs.select("mic-2");
assert_eq!(*sel.borrow(), "mic-2");
let mic3 = create_mock_device("mic-3", MediaDeviceKind::Audioinput, "Mic 3");
provider.simulate_device_change(vec![mic1, mic2, mic3]);
flush().await;
assert_eq!(
mdl.audio_inputs.selected(),
"mic-2",
"selected device should persist when an unrelated device is added"
);
}
}