Merge pull request #2816 from robintown/firefox-audio-output
Support selection of audio output device when using Firefox
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",
|
||||||
@@ -153,6 +151,16 @@
|
|||||||
"effect_volume_label": "Sound effect volume"
|
"effect_volume_label": "Sound effect volume"
|
||||||
},
|
},
|
||||||
"developer_tab_title": "Developer",
|
"developer_tab_title": "Developer",
|
||||||
|
"devices": {
|
||||||
|
"camera": "Camera",
|
||||||
|
"camera_numbered": "Camera {{n}}",
|
||||||
|
"default": "Default",
|
||||||
|
"default_named": "Default <2>({{name}})</2>",
|
||||||
|
"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_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",
|
||||||
"feedback_tab_h4": "Submit feedback",
|
"feedback_tab_h4": "Submit feedback",
|
||||||
@@ -170,8 +178,7 @@
|
|||||||
"reactions_show_label": "Show reactions",
|
"reactions_show_label": "Show reactions",
|
||||||
"show_hand_raised_timer_description": "Show a timer when a participant raises their hand",
|
"show_hand_raised_timer_description": "Show a timer when a participant raises their hand",
|
||||||
"show_hand_raised_timer_label": "Show hand raise duration"
|
"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_one": "{{count}} star",
|
||||||
"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 { type Observable } from "rxjs";
|
import { map, 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 {
|
||||||
@@ -26,11 +27,25 @@ import {
|
|||||||
videoInput as videoInputSetting,
|
videoInput as videoInputSetting,
|
||||||
type Setting,
|
type Setting,
|
||||||
} from "../settings/settings";
|
} 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 {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,26 +57,10 @@ 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>,
|
||||||
usingNames: boolean,
|
usingNames: boolean,
|
||||||
alwaysDefault: boolean = false,
|
|
||||||
): MediaDevice {
|
): MediaDevice {
|
||||||
// Make sure we don't needlessly reset to a device observer without names,
|
// Make sure we don't needlessly reset to a device observer without names,
|
||||||
// once permissions are already given
|
// once permissions are already given
|
||||||
@@ -81,43 +80,91 @@ 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 available = useObservableEagerState(
|
||||||
const [preferredId, select] = useSetting(setting);
|
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<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", 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(() => {
|
const [preferredId, select] = useSetting(setting);
|
||||||
let selectedId: string | undefined = undefined;
|
const selectedId = useMemo(() => {
|
||||||
if (!alwaysDefault && 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 = useObservableEagerState(
|
||||||
|
useMemo(
|
||||||
|
() =>
|
||||||
|
deviceObserver.pipe(
|
||||||
|
map(
|
||||||
|
(availableRaw) =>
|
||||||
|
availableRaw.find((d) => d.deviceId === selectedId)?.groupId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[deviceObserver, 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, alwaysDefault]);
|
[available, selectedId, selectedGroupId, select],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deviceStub: MediaDevice = {
|
export const deviceStub: MediaDevice = {
|
||||||
available: [],
|
available: new Map(),
|
||||||
selectedId: undefined,
|
selectedId: undefined,
|
||||||
|
selectedGroupId: undefined,
|
||||||
select: () => {},
|
select: () => {},
|
||||||
};
|
};
|
||||||
export const devicesStub: MediaDevices = {
|
export const devicesStub: MediaDevices = {
|
||||||
@@ -139,15 +186,6 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
|
|||||||
const [numCallersUsingNames, setNumCallersUsingNames] = useState(0);
|
const [numCallersUsingNames, setNumCallersUsingNames] = useState(0);
|
||||||
const usingNames = numCallersUsingNames > 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(
|
const audioInput = useMediaDevice(
|
||||||
"audioinput",
|
"audioinput",
|
||||||
audioInputSetting,
|
audioInputSetting,
|
||||||
@@ -156,8 +194,7 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
|
|||||||
const audioOutput = useMediaDevice(
|
const audioOutput = useMediaDevice(
|
||||||
"audiooutput",
|
"audiooutput",
|
||||||
audioOutputSetting,
|
audioOutputSetting,
|
||||||
useOutputNames,
|
usingNames,
|
||||||
alwaysUseDefaultAudio,
|
|
||||||
);
|
);
|
||||||
const videoInput = useMediaDevice(
|
const videoInput = useMediaDevice(
|
||||||
"videoinput",
|
"videoinput",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { MemoryRouter } from "react-router-dom";
|
|||||||
|
|
||||||
import { useMuteStates } from "./MuteStates";
|
import { useMuteStates } from "./MuteStates";
|
||||||
import {
|
import {
|
||||||
|
type DeviceLabel,
|
||||||
type MediaDevice,
|
type MediaDevice,
|
||||||
type MediaDevices,
|
type MediaDevices,
|
||||||
MediaDevicesContext,
|
MediaDevicesContext,
|
||||||
@@ -62,10 +63,11 @@ const mockCamera: MediaDeviceInfo = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function mockDevices(available: MediaDeviceInfo[]): MediaDevice {
|
function mockDevices(available: Map<string, DeviceLabel>): MediaDevice {
|
||||||
return {
|
return {
|
||||||
available,
|
available,
|
||||||
selectedId: "",
|
selectedId: "",
|
||||||
|
selectedGroupId: "",
|
||||||
select: (): void => {},
|
select: (): void => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -82,9 +84,17 @@ function mockMediaDevices(
|
|||||||
} = { microphone: true, speaker: true, camera: true },
|
} = { microphone: true, speaker: true, camera: true },
|
||||||
): MediaDevices {
|
): MediaDevices {
|
||||||
return {
|
return {
|
||||||
audioInput: mockDevices(microphone ? [mockMicrophone] : []),
|
audioInput: mockDevices(
|
||||||
audioOutput: mockDevices(speaker ? [mockSpeaker] : []),
|
microphone
|
||||||
videoInput: mockDevices(camera ? [mockCamera] : []),
|
? 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 => {},
|
startUsingDeviceNames: (): void => {},
|
||||||
stopUsingDeviceNames: (): void => {},
|
stopUsingDeviceNames: (): void => {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,12 +58,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,
|
||||||
|
|||||||
@@ -16,3 +16,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--cpd-space-4x);
|
gap: var(--cpd-space-4x);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
color: var(--cpd-color-text-secondary);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
Please see LICENSE in the repository root for full details.
|
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 {
|
import {
|
||||||
Heading,
|
Heading,
|
||||||
InlineField,
|
InlineField,
|
||||||
@@ -13,16 +20,23 @@ import {
|
|||||||
RadioControl,
|
RadioControl,
|
||||||
Separator,
|
Separator,
|
||||||
} from "@vector-im/compound-web";
|
} from "@vector-im/compound-web";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { type MediaDevice } from "../livekit/MediaDevicesContext";
|
import { type 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 +45,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,29 +56,53 @@ 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
|
let labelText: ReactNode;
|
||||||
key={deviceId}
|
switch (label.type) {
|
||||||
name={groupId}
|
case "name":
|
||||||
control={
|
labelText = label.name;
|
||||||
<RadioControl
|
break;
|
||||||
checked={deviceId === devices.selectedId}
|
case "number":
|
||||||
onChange={onChange}
|
labelText = numberedLabel(label.number);
|
||||||
value={deviceId}
|
break;
|
||||||
/>
|
case "default":
|
||||||
}
|
labelText =
|
||||||
>
|
label.name === null ? (
|
||||||
<Label>
|
t("settings.devices.default")
|
||||||
{!!label && label.trim().length > 0
|
) : (
|
||||||
? label
|
<Trans
|
||||||
: `${caption} ${index + 1}`}
|
i18nKey="settings.devices.default_named"
|
||||||
</Label>
|
name={label.name}
|
||||||
</InlineField>
|
>
|
||||||
))}
|
Default{" "}
|
||||||
|
<span className={styles.secondary}>
|
||||||
|
({{ name: label.name } as unknown as ReactElement})
|
||||||
|
</span>
|
||||||
|
</Trans>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InlineField
|
||||||
|
key={id}
|
||||||
|
name={groupId}
|
||||||
|
control={
|
||||||
|
<RadioControl
|
||||||
|
checked={id === devices.selectedId}
|
||||||
|
onChange={onChange}
|
||||||
|
value={id}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Label>{labelText}</Label>
|
||||||
|
</InlineField>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
soundEffectVolumeSetting,
|
soundEffectVolumeSetting,
|
||||||
developerMode,
|
developerMode,
|
||||||
} from "./settings";
|
} from "./settings";
|
||||||
import { isFirefox } from "../Platform";
|
|
||||||
import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
|
import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
|
||||||
import { Slider } from "../Slider";
|
import { Slider } from "../Slider";
|
||||||
import { DeviceSelection } from "./DeviceSelection";
|
import { DeviceSelection } from "./DeviceSelection";
|
||||||
@@ -76,14 +75,16 @@ 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
|
||||||
|
devices={devices.audioOutput}
|
||||||
|
title={t("settings.devices.speaker")}
|
||||||
|
numberedLabel={(n) => t("settings.devices.speaker_numbered", { n })}
|
||||||
/>
|
/>
|
||||||
{!isFirefox() && (
|
|
||||||
<DeviceSelection
|
|
||||||
devices={devices.audioOutput}
|
|
||||||
caption={t("settings.speaker_device_selection_label")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<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>
|
||||||
<p>{t("settings.audio_tab.effect_volume_description")}</p>
|
<p>{t("settings.audio_tab.effect_volume_description")}</p>
|
||||||
@@ -109,7 +110,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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ test("will use the correct device", () => {
|
|||||||
audioInput: deviceStub,
|
audioInput: deviceStub,
|
||||||
audioOutput: {
|
audioOutput: {
|
||||||
selectedId: "chosen-device",
|
selectedId: "chosen-device",
|
||||||
available: [],
|
selectedGroupId: "",
|
||||||
|
available: new Map(),
|
||||||
select: () => {},
|
select: () => {},
|
||||||
},
|
},
|
||||||
videoInput: deviceStub,
|
videoInput: deviceStub,
|
||||||
|
|||||||
Reference in New Issue
Block a user