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:
@@ -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",
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user