From f249b7d46386aee15505a4c1f3a78f0479955c48 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 21 Nov 2024 14:43:30 -0500 Subject: [PATCH] Create a virtual default audio output Managing your audio output manually is kind of cumbersome; Chrome creates a default audio output for us, but now that audio outputs are enabled on Firefox as well, I find it necessary for a good user experience that there always be a way to set it to "whatever the default is". --- locales/en-GB/app.json | 14 ++-- src/livekit/MediaDevicesContext.tsx | 105 ++++++++++++++++++---------- src/livekit/useLiveKit.ts | 6 +- src/room/MuteStates.ts | 4 +- src/settings/DeviceSelection.tsx | 31 +++++--- src/settings/SettingsModal.tsx | 11 ++- 6 files changed, 109 insertions(+), 62 deletions(-) diff --git a/locales/en-GB/app.json b/locales/en-GB/app.json index 0b9142d2..d71b8c9c 100644 --- a/locales/en-GB/app.json +++ b/locales/en-GB/app.json @@ -48,13 +48,11 @@ "audio": "Audio", "avatar": "Avatar", "back": "Back", - "camera": "Camera", "display_name": "Display name", "encrypted": "Encrypted", "error": "Error", "home": "Home", "loading": "Loading…", - "microphone": "Microphone", "next": "Next", "options": "Options", "password": "Password", @@ -149,6 +147,15 @@ "developer_settings_label": "Developer Settings", "developer_settings_label_description": "Expose developer settings in the settings window.", "developer_tab_title": "Developer", + "devices": { + "camera": "Camera", + "camera_numbered": "Camera {{n}}", + "default": "Default", + "microphone": "Microphone", + "microphone_numbered": "Microphone {{n}}", + "speaker": "Speaker", + "speaker_numbered": "Speaker {{n}}" + }, "duplicate_tiles_label": "Number of additional tile copies per participant", "feedback_tab_body": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.", "feedback_tab_description_label": "Your feedback", @@ -168,8 +175,7 @@ "preferences_tab_body": "Here you can configure extra options for an improved experience", "preferences_tab_h4": "Preferences", "preferences_tab_show_hand_raised_timer_description": "Show a timer when a participant raises their hand", - "preferences_tab_show_hand_raised_timer_label": "Show hand raise duration", - "speaker_device_selection_label": "Speaker" + "preferences_tab_show_hand_raised_timer_label": "Show hand raise duration" }, "star_rating_input_label_one": "{{count}} stars", "star_rating_input_label_other": "{{count}} stars", diff --git a/src/livekit/MediaDevicesContext.tsx b/src/livekit/MediaDevicesContext.tsx index 334eb2ec..5382b331 100644 --- a/src/livekit/MediaDevicesContext.tsx +++ b/src/livekit/MediaDevicesContext.tsx @@ -16,7 +16,8 @@ import { useState, } from "react"; import { createMediaDeviceObserver } from "@livekit/components-core"; -import { Observable } from "rxjs"; +import { startWith } from "rxjs"; +import { useObservableEagerState } from "observable-hooks"; import { logger } from "matrix-js-sdk/src/logger"; import { @@ -27,9 +28,24 @@ import { Setting, } from "../settings/settings"; +export type DeviceLabel = + | { type: "name"; name: string } + | { type: "number"; number: number } + | { type: "default" }; + export interface MediaDevice { - available: MediaDeviceInfo[]; + /** + * A map from available device IDs to labels. + */ + available: Map; selectedId: string | undefined; + /** + * The group ID of the selected device. + */ + // This is exposed sort of ad-hoc because it's only needed for knowing when to + // restart the tracks of default input devices, and ideally this behavior + // would be encapsulated somehow… + selectedGroupId: string | undefined; select: (deviceId: string) => void; } @@ -41,21 +57,6 @@ export interface MediaDevices { stopUsingDeviceNames: () => void; } -// Cargo-culted from @livekit/components-react -function useObservableState( - observable: Observable | undefined, - startWith: T, -): T { - const [state, setState] = useState(startWith); - useEffect(() => { - // observable state doesn't run in SSR - if (typeof window === "undefined" || !observable) return; - const subscription = observable.subscribe(setState); - return (): void => subscription.unsubscribe(); - }, [observable]); - return state; -} - function useMediaDevice( kind: MediaDeviceKind, setting: Setting, @@ -79,43 +80,73 @@ function useMediaDevice( kind, () => logger.error("Error creating MediaDeviceObserver"), requestPermissions, - ), + ).pipe(startWith([])), [kind, requestPermissions], ); - const available = useObservableState(deviceObserver, []); - const [preferredId, select] = useSetting(setting); + const availableRaw = useObservableEagerState(deviceObserver); + const available = useMemo(() => { + // Sometimes browsers (particularly Firefox) can return multiple device + // entries for the exact same device ID; using a map deduplicates them + let available = new Map( + availableRaw.map((d, i) => [ + d.deviceId, + d.label + ? { type: "name", name: d.label } + : { type: "number", number: i + 1 }, + ]), + ); + // Create a virtual default audio output for browsers that don't have one. + // Its device ID must be the empty string because that's what setSinkId + // recognizes. + if ( + kind === "audiooutput" && + available.size && + !available.has("") && + !available.has("default") + ) + available = new Map([["", { type: "default" }], ...available]); + // Note: creating virtual default input devices would be another problem + // entirely, because requesting a media stream from deviceId "" won't + // automatically track the default device. + return available; + }, [kind, availableRaw]); - return useMemo(() => { - let selectedId: string | undefined = undefined; - if (available) { + const [preferredId, select] = useSetting(setting); + const selectedId = useMemo(() => { + if (available.size) { // If the preferred device is available, use it. Or if every available // device ID is falsy, the browser is probably just being paranoid about // fingerprinting and we should still try using the preferred device. // Worst case it is not available and the browser will gracefully fall // back to some other device for us when requesting the media stream. // Otherwise, select the first available device. - selectedId = - available.some((d) => d.deviceId === preferredId) || - available.every((d) => d.deviceId === "") - ? preferredId - : available.at(0)?.deviceId; + return (preferredId !== undefined && available.has(preferredId)) || + (available.size === 1 && available.has("")) + ? preferredId + : available.keys().next().value; } + return undefined; + }, [available, preferredId]); + const selectedGroupId = useMemo( + () => availableRaw.find((d) => d.deviceId === selectedId)?.groupId, + [availableRaw, selectedId], + ); - return { - available: available - ? // Sometimes browsers (particularly Firefox) can return multiple - // device entries for the exact same device ID; deduplicate them - [...new Map(available.map((d) => [d.deviceId, d])).values()] - : [], + return useMemo( + () => ({ + available, selectedId, + selectedGroupId, select, - }; - }, [available, preferredId, select]); + }), + [available, selectedId, selectedGroupId, select], + ); } const deviceStub: MediaDevice = { - available: [], + available: new Map(), selectedId: undefined, + selectedGroupId: undefined, select: () => {}, }; const devicesStub: MediaDevices = { diff --git a/src/livekit/useLiveKit.ts b/src/livekit/useLiveKit.ts index 4645fab7..458ecaa0 100644 --- a/src/livekit/useLiveKit.ts +++ b/src/livekit/useLiveKit.ts @@ -290,18 +290,14 @@ export function useLiveKit( room.localParticipant.audioTrackPublications.values(), ).find((d) => d.source === Track.Source.Microphone)?.track; - const defaultDevice = device.available.find( - (d) => d.deviceId === "default", - ); if ( - defaultDevice && activeMicTrack && // only restart if the stream is still running: LiveKit will detect // when a track stops & restart appropriately, so this is not our job. // Plus, we need to avoid restarting again if the track is already in // the process of being restarted. activeMicTrack.mediaStreamTrack.readyState !== "ended" && - defaultDevice.groupId !== + device.selectedGroupId !== activeMicTrack.mediaStreamTrack.getSettings().groupId ) { // It's different, so restart the track, ie. cause Livekit to do another diff --git a/src/room/MuteStates.ts b/src/room/MuteStates.ts index 261be59e..5fcadc90 100644 --- a/src/room/MuteStates.ts +++ b/src/room/MuteStates.ts @@ -54,12 +54,12 @@ function useMuteState( ): MuteState { const [enabled, setEnabled] = useReactiveState( (prev) => - device.available.length > 0 ? (prev ?? enabledByDefault()) : undefined, + device.available.size > 0 ? (prev ?? enabledByDefault()) : undefined, [device], ); return useMemo( () => - device.available.length === 0 + device.available.size === 0 ? deviceUnavailable : { enabled: enabled ?? false, diff --git a/src/settings/DeviceSelection.tsx b/src/settings/DeviceSelection.tsx index 005973a0..c4020822 100644 --- a/src/settings/DeviceSelection.tsx +++ b/src/settings/DeviceSelection.tsx @@ -13,16 +13,23 @@ import { RadioControl, Separator, } from "@vector-im/compound-web"; +import { useTranslation } from "react-i18next"; import { MediaDevice } from "../livekit/MediaDevicesContext"; import styles from "./DeviceSelection.module.css"; interface Props { devices: MediaDevice; - caption: string; + title: string; + numberedLabel: (number: number) => string; } -export const DeviceSelection: FC = ({ devices, caption }) => { +export const DeviceSelection: FC = ({ + devices, + title, + numberedLabel, +}) => { + const { t } = useTranslation(); const groupId = useId(); const onChange = useCallback( (e: ChangeEvent) => { @@ -31,7 +38,7 @@ export const DeviceSelection: FC = ({ devices, caption }) => { [devices], ); - if (devices.available.length == 0) return null; + if (devices.available.size == 0) return null; return (
@@ -42,26 +49,28 @@ export const DeviceSelection: FC = ({ devices, caption }) => { as="h4" className={styles.title} > - {caption} + {title}
- {devices.available.map(({ deviceId, label }, index) => ( + {[...devices.available].map(([id, label]) => ( } > ))} diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 79545f1d..3ac980ca 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -93,11 +93,15 @@ export const SettingsModal: FC = ({
+ t("settings.devices.microphone_numbered", { n }) + } /> t("settings.devices.speaker_numbered", { n })} />
@@ -123,7 +127,8 @@ export const SettingsModal: FC = ({ t("settings.devices.camera_numbered", { n })} /> ),