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".
This commit is contained in:
Robin
2024-11-21 14:43:30 -05:00
parent e5117b962c
commit f249b7d463
6 changed files with 109 additions and 62 deletions

View File

@@ -48,13 +48,11 @@
"audio": "Audio", "audio": "Audio",
"avatar": "Avatar", "avatar": "Avatar",
"back": "Back", "back": "Back",
"camera": "Camera",
"display_name": "Display name", "display_name": "Display name",
"encrypted": "Encrypted", "encrypted": "Encrypted",
"error": "Error", "error": "Error",
"home": "Home", "home": "Home",
"loading": "Loading…", "loading": "Loading…",
"microphone": "Microphone",
"next": "Next", "next": "Next",
"options": "Options", "options": "Options",
"password": "Password", "password": "Password",
@@ -149,6 +147,15 @@
"developer_settings_label": "Developer Settings", "developer_settings_label": "Developer Settings",
"developer_settings_label_description": "Expose developer settings in the settings window.", "developer_settings_label_description": "Expose developer settings in the settings window.",
"developer_tab_title": "Developer", "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", "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_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_description_label": "Your feedback",
@@ -168,8 +175,7 @@
"preferences_tab_body": "Here you can configure extra options for an improved experience", "preferences_tab_body": "Here you can configure extra options for an improved experience",
"preferences_tab_h4": "Preferences", "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_description": "Show a timer when a participant raises their hand",
"preferences_tab_show_hand_raised_timer_label": "Show hand raise duration", "preferences_tab_show_hand_raised_timer_label": "Show hand raise duration"
"speaker_device_selection_label": "Speaker"
}, },
"star_rating_input_label_one": "{{count}} stars", "star_rating_input_label_one": "{{count}} stars",
"star_rating_input_label_other": "{{count}} stars", "star_rating_input_label_other": "{{count}} stars",

View File

@@ -16,7 +16,8 @@ import {
useState, useState,
} from "react"; } from "react";
import { createMediaDeviceObserver } from "@livekit/components-core"; 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 { logger } from "matrix-js-sdk/src/logger";
import { import {
@@ -27,9 +28,24 @@ import {
Setting, Setting,
} from "../settings/settings"; } from "../settings/settings";
export type DeviceLabel =
| { type: "name"; name: string }
| { type: "number"; number: number }
| { type: "default" };
export interface MediaDevice { export interface MediaDevice {
available: MediaDeviceInfo[]; /**
* A map from available device IDs to labels.
*/
available: Map<string, DeviceLabel>;
selectedId: string | undefined; 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; select: (deviceId: string) => void;
} }
@@ -41,21 +57,6 @@ export interface MediaDevices {
stopUsingDeviceNames: () => void; stopUsingDeviceNames: () => void;
} }
// Cargo-culted from @livekit/components-react
function useObservableState<T>(
observable: Observable<T> | undefined,
startWith: T,
): T {
const [state, setState] = useState<T>(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( function useMediaDevice(
kind: MediaDeviceKind, kind: MediaDeviceKind,
setting: Setting<string | undefined>, setting: Setting<string | undefined>,
@@ -79,43 +80,73 @@ function useMediaDevice(
kind, kind,
() => logger.error("Error creating MediaDeviceObserver"), () => logger.error("Error creating MediaDeviceObserver"),
requestPermissions, requestPermissions,
), ).pipe(startWith([])),
[kind, requestPermissions], [kind, requestPermissions],
); );
const available = useObservableState(deviceObserver, []); const availableRaw = useObservableEagerState(deviceObserver);
const [preferredId, select] = useSetting(setting); 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<string, DeviceLabel>(
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(() => { const [preferredId, select] = useSetting(setting);
let selectedId: string | undefined = undefined; const selectedId = useMemo(() => {
if (available) { if (available.size) {
// If the preferred device is available, use it. Or if every available // If the preferred device is available, use it. Or if every available
// device ID is falsy, the browser is probably just being paranoid about // device ID is falsy, the browser is probably just being paranoid about
// fingerprinting and we should still try using the preferred device. // fingerprinting and we should still try using the preferred device.
// Worst case it is not available and the browser will gracefully fall // Worst case it is not available and the browser will gracefully fall
// back to some other device for us when requesting the media stream. // back to some other device for us when requesting the media stream.
// Otherwise, select the first available device. // Otherwise, select the first available device.
selectedId = return (preferredId !== undefined && available.has(preferredId)) ||
available.some((d) => d.deviceId === preferredId) || (available.size === 1 && available.has(""))
available.every((d) => d.deviceId === "") ? preferredId
? preferredId : available.keys().next().value;
: available.at(0)?.deviceId;
} }
return undefined;
}, [available, preferredId]);
const selectedGroupId = useMemo(
() => availableRaw.find((d) => d.deviceId === selectedId)?.groupId,
[availableRaw, selectedId],
);
return { return useMemo(
available: available () => ({
? // Sometimes browsers (particularly Firefox) can return multiple available,
// device entries for the exact same device ID; deduplicate them
[...new Map(available.map((d) => [d.deviceId, d])).values()]
: [],
selectedId, selectedId,
selectedGroupId,
select, select,
}; }),
}, [available, preferredId, select]); [available, selectedId, selectedGroupId, select],
);
} }
const deviceStub: MediaDevice = { const deviceStub: MediaDevice = {
available: [], available: new Map(),
selectedId: undefined, selectedId: undefined,
selectedGroupId: undefined,
select: () => {}, select: () => {},
}; };
const devicesStub: MediaDevices = { const devicesStub: MediaDevices = {

View File

@@ -290,18 +290,14 @@ export function useLiveKit(
room.localParticipant.audioTrackPublications.values(), room.localParticipant.audioTrackPublications.values(),
).find((d) => d.source === Track.Source.Microphone)?.track; ).find((d) => d.source === Track.Source.Microphone)?.track;
const defaultDevice = device.available.find(
(d) => d.deviceId === "default",
);
if ( if (
defaultDevice &&
activeMicTrack && activeMicTrack &&
// only restart if the stream is still running: LiveKit will detect // only restart if the stream is still running: LiveKit will detect
// when a track stops & restart appropriately, so this is not our job. // 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 // Plus, we need to avoid restarting again if the track is already in
// the process of being restarted. // the process of being restarted.
activeMicTrack.mediaStreamTrack.readyState !== "ended" && activeMicTrack.mediaStreamTrack.readyState !== "ended" &&
defaultDevice.groupId !== device.selectedGroupId !==
activeMicTrack.mediaStreamTrack.getSettings().groupId activeMicTrack.mediaStreamTrack.getSettings().groupId
) { ) {
// It's different, so restart the track, ie. cause Livekit to do another // It's different, so restart the track, ie. cause Livekit to do another

View File

@@ -54,12 +54,12 @@ function useMuteState(
): MuteState { ): MuteState {
const [enabled, setEnabled] = useReactiveState<boolean | undefined>( const [enabled, setEnabled] = useReactiveState<boolean | undefined>(
(prev) => (prev) =>
device.available.length > 0 ? (prev ?? enabledByDefault()) : undefined, device.available.size > 0 ? (prev ?? enabledByDefault()) : undefined,
[device], [device],
); );
return useMemo( return useMemo(
() => () =>
device.available.length === 0 device.available.size === 0
? deviceUnavailable ? deviceUnavailable
: { : {
enabled: enabled ?? false, enabled: enabled ?? false,

View File

@@ -13,16 +13,23 @@ import {
RadioControl, RadioControl,
Separator, Separator,
} from "@vector-im/compound-web"; } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { MediaDevice } from "../livekit/MediaDevicesContext"; import { MediaDevice } from "../livekit/MediaDevicesContext";
import styles from "./DeviceSelection.module.css"; import styles from "./DeviceSelection.module.css";
interface Props { interface Props {
devices: MediaDevice; devices: MediaDevice;
caption: string; title: string;
numberedLabel: (number: number) => string;
} }
export const DeviceSelection: FC<Props> = ({ devices, caption }) => { export const DeviceSelection: FC<Props> = ({
devices,
title,
numberedLabel,
}) => {
const { t } = useTranslation();
const groupId = useId(); const groupId = useId();
const onChange = useCallback( const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => { (e: ChangeEvent<HTMLInputElement>) => {
@@ -31,7 +38,7 @@ export const DeviceSelection: FC<Props> = ({ devices, caption }) => {
[devices], [devices],
); );
if (devices.available.length == 0) return null; if (devices.available.size == 0) return null;
return ( return (
<div className={styles.selection}> <div className={styles.selection}>
@@ -42,26 +49,28 @@ export const DeviceSelection: FC<Props> = ({ devices, caption }) => {
as="h4" as="h4"
className={styles.title} className={styles.title}
> >
{caption} {title}
</Heading> </Heading>
<Separator className={styles.separator} /> <Separator className={styles.separator} />
<div className={styles.options}> <div className={styles.options}>
{devices.available.map(({ deviceId, label }, index) => ( {[...devices.available].map(([id, label]) => (
<InlineField <InlineField
key={deviceId} key={id}
name={groupId} name={groupId}
control={ control={
<RadioControl <RadioControl
checked={deviceId === devices.selectedId} checked={id === devices.selectedId}
onChange={onChange} onChange={onChange}
value={deviceId} value={id}
/> />
} }
> >
<Label> <Label>
{!!label && label.trim().length > 0 {label.type === "name"
? label ? label.name
: `${caption} ${index + 1}`} : label.type === "number"
? numberedLabel(label.number)
: t("settings.devices.default")}
</Label> </Label>
</InlineField> </InlineField>
))} ))}

View File

@@ -93,11 +93,15 @@ export const SettingsModal: FC<Props> = ({
<Form> <Form>
<DeviceSelection <DeviceSelection
devices={devices.audioInput} devices={devices.audioInput}
caption={t("common.microphone")} title={t("settings.devices.microphone")}
numberedLabel={(n) =>
t("settings.devices.microphone_numbered", { n })
}
/> />
<DeviceSelection <DeviceSelection
devices={devices.audioOutput} devices={devices.audioOutput}
caption={t("settings.speaker_device_selection_label")} title={t("settings.devices.speaker")}
numberedLabel={(n) => t("settings.devices.speaker_numbered", { n })}
/> />
<div className={styles.volumeSlider}> <div className={styles.volumeSlider}>
<label>{t("settings.audio_tab.effect_volume_label")}</label> <label>{t("settings.audio_tab.effect_volume_label")}</label>
@@ -123,7 +127,8 @@ export const SettingsModal: FC<Props> = ({
<Form> <Form>
<DeviceSelection <DeviceSelection
devices={devices.videoInput} devices={devices.videoInput}
caption={t("common.camera")} title={t("settings.devices.camera")}
numberedLabel={(n) => t("settings.devices.camera_numbered", { n })}
/> />
</Form> </Form>
), ),