/** * Device picker helper exposed for customers who want to build their * own "choose your camera and mic" screen. * * Browsers hide device labels (returning empty strings) until the page * has been granted permission to at least one device of that kind. * This helper handles the two-step dance: * * 1. Optionally call `getUserMedia({ video, audio })` once just to * unlock labels (a "probe" stream). * 2. Stop the probe stream immediately. * 3. Call `enumerateDevices()` and return the labelled list. * * Without step 1, labels are empty strings — customers building a * picker hit this gotcha first thing if they try `enumerateDevices()` * directly. We default to probing for both video and audio so the * returned list is fully labelled. * * Must be called inside a user-activation handler when `probe` is * true (because step 1 calls getUserMedia, which has activation * requirements). With `probe: false`, this is just a thin wrapper * around enumerateDevices and labels may be empty. */ export interface MediaDeviceInfoLite { deviceId: string; label: string; } export interface MediaDeviceList { cameras: MediaDeviceInfoLite[]; mics: MediaDeviceInfoLite[]; /** audiooutput devices (speakers/headphones). Note: Safari does not expose * these, so the list can be empty even when a speaker is in use. */ speakers: MediaDeviceInfoLite[]; } export interface EnumerateOptions { /** * If true (default), probe with a one-shot getUserMedia call to * unlock device labels. Set false if the page already has camera * AND mic permissions and you just want a labelled list. */ probe?: boolean; /** Probe for video labels. Default: true. */ video?: boolean; /** Probe for audio labels. Default: true. */ audio?: boolean; } export async function enumerateMediaDevices( options: EnumerateOptions = {}, ): Promise { const opts = { probe: options.probe ?? true, video: options.video ?? true, audio: options.audio ?? true, }; if (typeof navigator === "undefined" || !navigator.mediaDevices?.enumerateDevices) { return { cameras: [], mics: [], speakers: [] }; } if (opts.probe && (opts.video || opts.audio)) { let probeStream: MediaStream | null = null; try { probeStream = await navigator.mediaDevices.getUserMedia({ video: opts.video, audio: opts.audio, }); } catch { // Probe failed (permission denied, no devices, in-use). Fall // through and enumerate anyway — labels may be empty but // deviceIds are still useful for picker UIs that show generic // entries like "Camera 1", "Camera 2". } finally { probeStream?.getTracks().forEach((t) => t.stop()); } } const devices = await navigator.mediaDevices.enumerateDevices(); const cameras: MediaDeviceInfoLite[] = []; const mics: MediaDeviceInfoLite[] = []; const speakers: MediaDeviceInfoLite[] = []; for (const d of devices) { if (d.kind === "videoinput") { cameras.push({ deviceId: d.deviceId, label: d.label }); } else if (d.kind === "audioinput") { mics.push({ deviceId: d.deviceId, label: d.label }); } else if (d.kind === "audiooutput") { speakers.push({ deviceId: d.deviceId, label: d.label }); } } return { cameras, mics, speakers }; }