diff --git a/locales/en/app.json b/locales/en/app.json index 33f8f921..a47e5beb 100644 --- a/locales/en/app.json +++ b/locales/en/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", @@ -153,6 +151,16 @@ "effect_volume_label": "Sound effect volume" }, "developer_tab_title": "Developer", + "devices": { + "camera": "Camera", + "camera_numbered": "Camera {{n}}", + "default": "Default", + "default_named": "Default <2>({{name}})", + "microphone": "Microphone", + "microphone_numbered": "Microphone {{n}}", + "speaker": "Speaker", + "speaker_numbered": "Speaker {{n}}" + }, "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", "feedback_tab_h4": "Submit feedback", @@ -170,8 +178,7 @@ "reactions_show_label": "Show reactions", "show_hand_raised_timer_description": "Show a timer when a participant raises their hand", "show_hand_raised_timer_label": "Show hand raise duration" - }, - "speaker_device_selection_label": "Speaker" + } }, "star_rating_input_label_one": "{{count}} star", "star_rating_input_label_other": "{{count}} stars", diff --git a/src/livekit/MediaDevicesContext.tsx b/src/livekit/MediaDevicesContext.tsx index f25761f9..9edbb884 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 { type Observable } from "rxjs"; +import { map, startWith } from "rxjs"; +import { useObservableEagerState } from "observable-hooks"; import { logger } from "matrix-js-sdk/src/logger"; import { @@ -26,11 +27,25 @@ import { videoInput as videoInputSetting, type Setting, } from "../settings/settings"; -import { isFirefox } from "../Platform"; + +export type DeviceLabel = + | { type: "name"; name: string } + | { type: "number"; number: number } + | { type: "default"; name: string | null }; 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; } @@ -42,26 +57,10 @@ 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, usingNames: boolean, - alwaysDefault: boolean = false, ): MediaDevice { // Make sure we don't needlessly reset to a device observer without names, // once permissions are already given @@ -81,43 +80,91 @@ function useMediaDevice( kind, () => logger.error("Error creating MediaDeviceObserver"), requestPermissions, - ), + ).pipe(startWith([])), [kind, requestPermissions], ); - const available = useObservableState(deviceObserver, []); - const [preferredId, select] = useSetting(setting); + const available = useObservableEagerState( + useMemo( + () => + deviceObserver.pipe( + map((availableRaw) => { + // 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", name: availableRaw[0]?.label || null }], + ...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, deviceObserver], + ), + ); - return useMemo(() => { - let selectedId: string | undefined = undefined; - if (!alwaysDefault && 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 = useObservableEagerState( + useMemo( + () => + deviceObserver.pipe( + map( + (availableRaw) => + availableRaw.find((d) => d.deviceId === selectedId)?.groupId, + ), + ), + [deviceObserver, 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, alwaysDefault]); + }), + [available, selectedId, selectedGroupId, select], + ); } export const deviceStub: MediaDevice = { - available: [], + available: new Map(), selectedId: undefined, + selectedGroupId: undefined, select: () => {}, }; export const devicesStub: MediaDevices = { @@ -139,15 +186,6 @@ export const MediaDevicesProvider: FC = ({ children }) => { const [numCallersUsingNames, setNumCallersUsingNames] = useState(0); const usingNames = numCallersUsingNames > 0; - // Setting the audio device to something other than 'undefined' breaks echo-cancellation - // and even can introduce multiple different output devices for one call. - const alwaysUseDefaultAudio = isFirefox(); - - // On FF we dont need to query the names - // (call enumerateDevices + create meadia stream to trigger permissions) - // for ouput devices because the selector wont be shown on FF. - const useOutputNames = usingNames && !isFirefox(); - const audioInput = useMediaDevice( "audioinput", audioInputSetting, @@ -156,8 +194,7 @@ export const MediaDevicesProvider: FC = ({ children }) => { const audioOutput = useMediaDevice( "audiooutput", audioOutputSetting, - useOutputNames, - alwaysUseDefaultAudio, + usingNames, ); const videoInput = useMediaDevice( "videoinput", diff --git a/src/livekit/useLiveKit.ts b/src/livekit/useLiveKit.ts index 3cae96fd..41b305e5 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.test.tsx b/src/room/MuteStates.test.tsx index 0db95a53..719315e8 100644 --- a/src/room/MuteStates.test.tsx +++ b/src/room/MuteStates.test.tsx @@ -12,6 +12,7 @@ import { MemoryRouter } from "react-router-dom"; import { useMuteStates } from "./MuteStates"; import { + type DeviceLabel, type MediaDevice, type MediaDevices, MediaDevicesContext, @@ -62,10 +63,11 @@ const mockCamera: MediaDeviceInfo = { }, }; -function mockDevices(available: MediaDeviceInfo[]): MediaDevice { +function mockDevices(available: Map): MediaDevice { return { available, selectedId: "", + selectedGroupId: "", select: (): void => {}, }; } @@ -82,9 +84,17 @@ function mockMediaDevices( } = { microphone: true, speaker: true, camera: true }, ): MediaDevices { return { - audioInput: mockDevices(microphone ? [mockMicrophone] : []), - audioOutput: mockDevices(speaker ? [mockSpeaker] : []), - videoInput: mockDevices(camera ? [mockCamera] : []), + audioInput: mockDevices( + microphone + ? new Map([[mockMicrophone.deviceId, mockMicrophone]]) + : new Map(), + ), + audioOutput: mockDevices( + speaker ? new Map([[mockSpeaker.deviceId, mockSpeaker]]) : new Map(), + ), + videoInput: mockDevices( + camera ? new Map([[mockCamera.deviceId, mockCamera]]) : new Map(), + ), startUsingDeviceNames: (): void => {}, stopUsingDeviceNames: (): void => {}, }; diff --git a/src/room/MuteStates.ts b/src/room/MuteStates.ts index b4f2b43e..4a8aa9dd 100644 --- a/src/room/MuteStates.ts +++ b/src/room/MuteStates.ts @@ -58,12 +58,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.module.css b/src/settings/DeviceSelection.module.css index daa4510e..6686702f 100644 --- a/src/settings/DeviceSelection.module.css +++ b/src/settings/DeviceSelection.module.css @@ -16,3 +16,7 @@ flex-direction: column; gap: var(--cpd-space-4x); } + +.secondary { + color: var(--cpd-color-text-secondary); +} diff --git a/src/settings/DeviceSelection.tsx b/src/settings/DeviceSelection.tsx index da4fe11a..aebe0aac 100644 --- a/src/settings/DeviceSelection.tsx +++ b/src/settings/DeviceSelection.tsx @@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import { type ChangeEvent, type FC, useCallback, useId } from "react"; +import { + type ChangeEvent, + type FC, + type ReactElement, + type ReactNode, + useCallback, + useId, +} from "react"; import { Heading, InlineField, @@ -13,16 +20,23 @@ import { RadioControl, Separator, } from "@vector-im/compound-web"; +import { Trans, useTranslation } from "react-i18next"; import { type 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 +45,7 @@ export const DeviceSelection: FC = ({ devices, caption }) => { [devices], ); - if (devices.available.length == 0) return null; + if (devices.available.size == 0) return null; return (
@@ -42,29 +56,53 @@ export const DeviceSelection: FC = ({ devices, caption }) => { as="h4" className={styles.title} > - {caption} + {title}
- {devices.available.map(({ deviceId, label }, index) => ( - - } - > - - - ))} + {[...devices.available].map(([id, label]) => { + let labelText: ReactNode; + switch (label.type) { + case "name": + labelText = label.name; + break; + case "number": + labelText = numberedLabel(label.number); + break; + case "default": + labelText = + label.name === null ? ( + t("settings.devices.default") + ) : ( + + Default{" "} + + ({{ name: label.name } as unknown as ReactElement}) + + + ); + break; + } + + return ( + + } + > + + + ); + })}
); diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index c1466e8a..b7066095 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -25,7 +25,6 @@ import { soundEffectVolumeSetting, developerMode, } from "./settings"; -import { isFirefox } from "../Platform"; import { PreferencesSettingsTab } from "./PreferencesSettingsTab"; import { Slider } from "../Slider"; import { DeviceSelection } from "./DeviceSelection"; @@ -76,14 +75,16 @@ export const SettingsModal: FC = ({
+ t("settings.devices.microphone_numbered", { n }) + } + /> + t("settings.devices.speaker_numbered", { n })} /> - {!isFirefox() && ( - - )}

{t("settings.audio_tab.effect_volume_description")}

@@ -109,7 +110,8 @@ export const SettingsModal: FC = ({ t("settings.devices.camera_numbered", { n })} /> ), diff --git a/src/useAudioContext.test.tsx b/src/useAudioContext.test.tsx index 565208b1..2fda4add 100644 --- a/src/useAudioContext.test.tsx +++ b/src/useAudioContext.test.tsx @@ -101,7 +101,8 @@ test("will use the correct device", () => { audioInput: deviceStub, audioOutput: { selectedId: "chosen-device", - available: [], + selectedGroupId: "", + available: new Map(), select: () => {}, }, videoInput: deviceStub,