mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
Wire speaker selection through the call footer
- The footer view model now carries the output devices, the current output and the callback that changes it, alongside the ones it already carried for input. - That callback is withheld where the platform cannot route audio to a chosen device, and its absence is what renders the section disabled — an absent action rather than a flag the view has to interpret. - An empty output list stays a list rather than becoming an absence, so the section is still drawn where a platform enumerates nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -137,10 +137,13 @@ export const Default: Story = {
|
|||||||
debugTileLayout: false,
|
debugTileLayout: false,
|
||||||
tileStoreGeneration: undefined,
|
tileStoreGeneration: undefined,
|
||||||
audioOptions: [],
|
audioOptions: [],
|
||||||
|
audioOutputOptions: [],
|
||||||
videoOptions: [],
|
videoOptions: [],
|
||||||
selectedAudio: undefined,
|
selectedAudio: undefined,
|
||||||
|
selectedAudioOutput: undefined,
|
||||||
selectedVideo: undefined,
|
selectedVideo: undefined,
|
||||||
selectAudioButtonOption: undefined,
|
selectAudioButtonOption: undefined,
|
||||||
|
selectAudioOutputOption: undefined,
|
||||||
selectVideoButtonOption: undefined,
|
selectVideoButtonOption: undefined,
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -158,11 +161,16 @@ export const WithAudioAndVideoOptions: Story = {
|
|||||||
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
||||||
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
||||||
],
|
],
|
||||||
|
audioOutputOptions: [
|
||||||
|
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
|
||||||
|
{ label: { type: "name", name: "Headset" }, id: "2" },
|
||||||
|
],
|
||||||
videoOptions: [
|
videoOptions: [
|
||||||
{ label: { type: "name", name: "Camera 1" }, id: "1" },
|
{ label: { type: "name", name: "Camera 1" }, id: "1" },
|
||||||
{ label: { type: "name", name: "Camera 2" }, id: "2" },
|
{ label: { type: "name", name: "Camera 2" }, id: "2" },
|
||||||
],
|
],
|
||||||
selectedAudio: "2",
|
selectedAudio: "2",
|
||||||
|
selectedAudioOutput: "default",
|
||||||
selectedVideo: "1",
|
selectedVideo: "1",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -99,11 +99,15 @@ export interface FooterState {
|
|||||||
|
|
||||||
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
||||||
audioOptions: MenuOptions[];
|
audioOptions: MenuOptions[];
|
||||||
|
/** Output devices shown as their own section in the audio menu. */
|
||||||
|
audioOutputOptions: MenuOptions[];
|
||||||
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
||||||
videoOptions: MenuOptions[];
|
videoOptions: MenuOptions[];
|
||||||
selectedAudio: string | undefined;
|
selectedAudio: string | undefined;
|
||||||
|
selectedAudioOutput: string | undefined;
|
||||||
selectedVideo: string | undefined;
|
selectedVideo: string | undefined;
|
||||||
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
|
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
|
||||||
|
selectAudioOutputOption: ((deviceId: string) => void) | undefined;
|
||||||
selectVideoButtonOption: ((option: string) => void) | undefined;
|
selectVideoButtonOption: ((option: string) => void) | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +147,9 @@ export const CallFooter: FC<FooterProps> = ({
|
|||||||
const audioOptions = useBehavior(vm.audioOptions$);
|
const audioOptions = useBehavior(vm.audioOptions$);
|
||||||
const selectedAudio = useBehavior(vm.selectedAudio$);
|
const selectedAudio = useBehavior(vm.selectedAudio$);
|
||||||
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
|
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
|
||||||
|
const audioOutputOptions = useBehavior(vm.audioOutputOptions$);
|
||||||
|
const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$);
|
||||||
|
const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$);
|
||||||
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
|
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
|
||||||
const toggleBlur = useBehavior(vm.toggleBlur$);
|
const toggleBlur = useBehavior(vm.toggleBlur$);
|
||||||
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
|
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
|
||||||
@@ -168,7 +175,6 @@ export const CallFooter: FC<FooterProps> = ({
|
|||||||
if ((audioOptions?.length ?? 0) > 0) {
|
if ((audioOptions?.length ?? 0) > 0) {
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<MediaMuteAndSwitchButton
|
<MediaMuteAndSwitchButton
|
||||||
title={"Mic Source"}
|
|
||||||
key="audio"
|
key="audio"
|
||||||
iconsAndLabels="audio"
|
iconsAndLabels="audio"
|
||||||
enabled={audioEnabled ?? false}
|
enabled={audioEnabled ?? false}
|
||||||
@@ -178,6 +184,9 @@ export const CallFooter: FC<FooterProps> = ({
|
|||||||
options={audioOptions}
|
options={audioOptions}
|
||||||
selectedOption={selectedAudio}
|
selectedOption={selectedAudio}
|
||||||
onSelect={selectAudioButtonOption}
|
onSelect={selectAudioButtonOption}
|
||||||
|
outputOptions={audioOutputOptions}
|
||||||
|
selectedOutputOption={selectedAudioOutput}
|
||||||
|
onSelectOutput={selectAudioOutputOption}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -197,7 +206,6 @@ export const CallFooter: FC<FooterProps> = ({
|
|||||||
if ((videoOptions?.length ?? 0) > 0) {
|
if ((videoOptions?.length ?? 0) > 0) {
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<MediaMuteAndSwitchButton
|
<MediaMuteAndSwitchButton
|
||||||
title={"Camera Source"}
|
|
||||||
key="video"
|
key="video"
|
||||||
iconsAndLabels="video"
|
iconsAndLabels="video"
|
||||||
enabled={videoEnabled ?? false}
|
enabled={videoEnabled ?? false}
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ vi.mock("@livekit/track-processors", () => ({
|
|||||||
supportsBackgroundProcessors: (): boolean => false,
|
supportsBackgroundProcessors: (): boolean => false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
|
||||||
|
vi.mock("livekit-client", () => ({
|
||||||
|
supportsAudioOutputSelection: (): boolean => outputSelectionMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the minimum set of CallViewModel fields required by
|
* Returns the minimum set of CallViewModel fields required by
|
||||||
* createCallFooterViewModel, with all other properties stubbed to
|
* createCallFooterViewModel, with all other properties stubbed to
|
||||||
@@ -96,6 +101,71 @@ const twoMicsAndOneCamMediaDevices = mockMediaDevices({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("createCallFooterViewModel", () => {
|
describe("createCallFooterViewModel", () => {
|
||||||
|
describe("selectAudioOutputOption", () => {
|
||||||
|
function buildFooterVm(): ReturnType<typeof createCallFooterViewModel> {
|
||||||
|
platformMock.mockReturnValue("desktop");
|
||||||
|
return createCallFooterViewModel(
|
||||||
|
testScope(),
|
||||||
|
buildMinimalCallViewModel(gridLayout),
|
||||||
|
mockMuteStates(),
|
||||||
|
twoMicsAndOneCamMediaDevices,
|
||||||
|
/* reactionIdentifier */ undefined,
|
||||||
|
{ showControls: true, header: HeaderStyle.Standard },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("is withheld where the platform cannot route audio to a chosen device", () => {
|
||||||
|
outputSelectionMock.mockReturnValue(false);
|
||||||
|
// Undefined is what renders the speaker section disabled.
|
||||||
|
expect(buildFooterVm().selectAudioOutputOption$.value).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is offered where the platform can route audio to a chosen device", () => {
|
||||||
|
outputSelectionMock.mockReturnValue(true);
|
||||||
|
expect(buildFooterVm().selectAudioOutputOption$.value).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("audioOutputOptions", () => {
|
||||||
|
it("is an empty list, not absent, where the platform enumerates no outputs", () => {
|
||||||
|
platformMock.mockReturnValue("desktop");
|
||||||
|
outputSelectionMock.mockReturnValue(true);
|
||||||
|
|
||||||
|
const vm = createCallFooterViewModel(
|
||||||
|
testScope(),
|
||||||
|
buildMinimalCallViewModel(gridLayout),
|
||||||
|
mockMuteStates(),
|
||||||
|
mockMediaDevices({
|
||||||
|
audioInput: {
|
||||||
|
available$: constant(
|
||||||
|
new Map<string, DeviceLabel>([
|
||||||
|
["mic1", { type: "name", name: "Microphone 1" }],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
selected$: constant(undefined),
|
||||||
|
select: vi.fn(),
|
||||||
|
},
|
||||||
|
// Safari enumerates no output devices whatsoever. Reproduced by the
|
||||||
|
// condition rather than by the browser, so it is checked on the
|
||||||
|
// Linux CI runners that have no Safari to check it with.
|
||||||
|
audioOutput: {
|
||||||
|
available$: constant(new Map<string, DeviceLabel>()),
|
||||||
|
selected$: constant(undefined),
|
||||||
|
select: vi.fn(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/* reactionIdentifier */ undefined,
|
||||||
|
{ showControls: true, header: HeaderStyle.Standard },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Empty rather than undefined: undefined means this menu has no notion
|
||||||
|
// of outputs at all, as the camera menu has none, and hides the section.
|
||||||
|
// Empty means there are none to list, and the menu still shows the
|
||||||
|
// section with a default in it, disabled.
|
||||||
|
expect(vm.audioOutputOptions$.value).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("audioOptions and videoOptions", () => {
|
describe("audioOptions and videoOptions", () => {
|
||||||
function checkEmptyFor(platform: string, layout: Layout): void {
|
function checkEmptyFor(platform: string, layout: Layout): void {
|
||||||
platformMock.mockReturnValue(platform);
|
platformMock.mockReturnValue(platform);
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { combineLatest, map, switchMap } from "rxjs";
|
import { combineLatest, map, type Observable, switchMap } from "rxjs";
|
||||||
import { supportsBackgroundProcessors } from "@livekit/track-processors";
|
import { supportsBackgroundProcessors } from "@livekit/track-processors";
|
||||||
|
import { supportsAudioOutputSelection } from "livekit-client";
|
||||||
|
|
||||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
|
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
|
||||||
@@ -67,49 +68,50 @@ function buildDeviceBehaviors(
|
|||||||
| "audioOptions$"
|
| "audioOptions$"
|
||||||
| "selectedAudio$"
|
| "selectedAudio$"
|
||||||
| "selectAudioButtonOption$"
|
| "selectAudioButtonOption$"
|
||||||
|
| "audioOutputOptions$"
|
||||||
|
| "selectedAudioOutput$"
|
||||||
|
| "selectAudioOutputOption$"
|
||||||
| "videoOptions$"
|
| "videoOptions$"
|
||||||
| "selectedVideo$"
|
| "selectedVideo$"
|
||||||
| "selectVideoButtonOption$"
|
| "selectVideoButtonOption$"
|
||||||
| "toggleBlur$"
|
| "toggleBlur$"
|
||||||
| "videoBlurEnabled$"
|
| "videoBlurEnabled$"
|
||||||
> {
|
> {
|
||||||
return {
|
const options$ = (
|
||||||
audioOptions$: scope.behavior(
|
available$: Behavior<Map<string, MenuOptions["label"]>>,
|
||||||
disableSwitcher$.pipe(
|
): Observable<MenuOptions[]> =>
|
||||||
switchMap((disable) =>
|
disableSwitcher$.pipe(
|
||||||
disable
|
switchMap((disable) =>
|
||||||
? constant([] as MenuOptions[])
|
disable
|
||||||
: mediaDevices.audioInput.available$.pipe(
|
? constant([] as MenuOptions[])
|
||||||
map((available) =>
|
: available$.pipe(
|
||||||
[...available.entries()].map(([id, label]) => ({
|
map((available) =>
|
||||||
id,
|
[...available.entries()].map(([id, label]) => ({ id, label })),
|
||||||
label,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioOptions$: scope.behavior(options$(mediaDevices.audioInput.available$)),
|
||||||
selectedAudio$: scope.behavior(
|
selectedAudio$: scope.behavior(
|
||||||
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
|
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
|
||||||
),
|
),
|
||||||
selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
|
selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
|
||||||
videoOptions$: scope.behavior(
|
audioOutputOptions$: scope.behavior(
|
||||||
disableSwitcher$.pipe(
|
options$(mediaDevices.audioOutput.available$),
|
||||||
switchMap((disable) =>
|
|
||||||
disable
|
|
||||||
? constant([] as MenuOptions[])
|
|
||||||
: mediaDevices.videoInput.available$.pipe(
|
|
||||||
map((available) =>
|
|
||||||
[...available.entries()].map(([id, label]) => ({
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
selectedAudioOutput$: scope.behavior(
|
||||||
|
mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)),
|
||||||
|
),
|
||||||
|
// Safari and most Firefox builds cannot route audio to a chosen device at
|
||||||
|
// all. Withholding the callback is what renders the section disabled.
|
||||||
|
selectAudioOutputOption$: constant(
|
||||||
|
supportsAudioOutputSelection()
|
||||||
|
? mediaDevices.audioOutput.select
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
videoOptions$: scope.behavior(options$(mediaDevices.videoInput.available$)),
|
||||||
selectedVideo$: scope.behavior(
|
selectedVideo$: scope.behavior(
|
||||||
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
|
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
|
||||||
),
|
),
|
||||||
@@ -263,10 +265,13 @@ export function createLobbyFooterViewModel(
|
|||||||
reactionData: undefined,
|
reactionData: undefined,
|
||||||
tileStoreGeneration: undefined,
|
tileStoreGeneration: undefined,
|
||||||
audioOptions: undefined,
|
audioOptions: undefined,
|
||||||
|
audioOutputOptions: undefined,
|
||||||
videoOptions: undefined,
|
videoOptions: undefined,
|
||||||
selectedAudio: undefined,
|
selectedAudio: undefined,
|
||||||
|
selectedAudioOutput: undefined,
|
||||||
selectedVideo: undefined,
|
selectedVideo: undefined,
|
||||||
selectAudioButtonOption: undefined,
|
selectAudioButtonOption: undefined,
|
||||||
|
selectAudioOutputOption: undefined,
|
||||||
selectVideoButtonOption: undefined,
|
selectVideoButtonOption: undefined,
|
||||||
}),
|
}),
|
||||||
...buildMuteBehaviors(scope, muteStates),
|
...buildMuteBehaviors(scope, muteStates),
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { of } from "rxjs";
|
||||||
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
|
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
import { type MatrixClient } from "matrix-js-sdk";
|
||||||
@@ -19,6 +21,7 @@ import {
|
|||||||
import { LobbyView } from "./LobbyView";
|
import { LobbyView } from "./LobbyView";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
import { mockMediaDevices, mockMuteStates } from "../utils/test";
|
import { mockMediaDevices, mockMuteStates } from "../utils/test";
|
||||||
|
import { type MediaDevices } from "../state/MediaDevices";
|
||||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||||
import { type ProcessorState } from "../livekit/TrackProcessorContext";
|
import { type ProcessorState } from "../livekit/TrackProcessorContext";
|
||||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
@@ -77,9 +80,10 @@ function renderLobbyView(
|
|||||||
props: Partial<Parameters<typeof LobbyView>[0]> = {},
|
props: Partial<Parameters<typeof LobbyView>[0]> = {},
|
||||||
withAppBar = false,
|
withAppBar = false,
|
||||||
platform = "android",
|
platform = "android",
|
||||||
|
devices: Partial<MediaDevices> = {},
|
||||||
): ReturnType<typeof render> {
|
): ReturnType<typeof render> {
|
||||||
platformMock.mockReturnValue(platform);
|
platformMock.mockReturnValue(platform);
|
||||||
const mediaDevices = mockMediaDevices({});
|
const mediaDevices = mockMediaDevices(devices);
|
||||||
const muteStates = mockMuteStates();
|
const muteStates = mockMuteStates();
|
||||||
const hideHeader = withAppBar ? true : false;
|
const hideHeader = withAppBar ? true : false;
|
||||||
const lobbyView = (
|
const lobbyView = (
|
||||||
@@ -178,3 +182,71 @@ describe("LobbyView", () => {
|
|||||||
expect(await axe(container)).toHaveNoViolations();
|
expect(await axe(container)).toHaveNoViolations();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("LobbyView microphone level", () => {
|
||||||
|
const realMediaDevices = Object.getOwnPropertyDescriptor(
|
||||||
|
navigator,
|
||||||
|
"mediaDevices",
|
||||||
|
);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
// Put navigator back, or every later test in the run inherits the stub.
|
||||||
|
if (realMediaDevices === undefined) {
|
||||||
|
Reflect.deleteProperty(navigator, "mediaDevices");
|
||||||
|
} else {
|
||||||
|
Object.defineProperty(navigator, "mediaDevices", realMediaDevices);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Just enough of the Web Audio and capture APIs for the meter to run. */
|
||||||
|
function stubAudioCapture(): void {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"AudioContext",
|
||||||
|
class {
|
||||||
|
public readonly state = "running";
|
||||||
|
public createAnalyser(): object {
|
||||||
|
return {
|
||||||
|
fftSize: 1024,
|
||||||
|
getByteTimeDomainData: (): void => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
public createMediaStreamSource(): object {
|
||||||
|
return { connect: (): void => {} };
|
||||||
|
}
|
||||||
|
public close(): void {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Only this property: replacing navigator wholesale drops the getters on
|
||||||
|
// its prototype, such as userAgent.
|
||||||
|
Object.defineProperty(navigator, "mediaDevices", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [] }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("shows the microphone level meter", async () => {
|
||||||
|
stubAudioCapture();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { getByRole } = renderLobbyView({}, false, "desktop", {
|
||||||
|
requestDeviceNames: (): void => {},
|
||||||
|
audioInput: {
|
||||||
|
available$: of(
|
||||||
|
new Map([["mic1", { type: "name", name: "Microphone 1" }]]),
|
||||||
|
),
|
||||||
|
selected$: of({ id: "mic1" }),
|
||||||
|
select: (): void => {},
|
||||||
|
},
|
||||||
|
} as unknown as Partial<MediaDevices>);
|
||||||
|
|
||||||
|
// The meter lives with the microphone picker, which the pre-join screen
|
||||||
|
// reaches through the same chevron as a call in progress.
|
||||||
|
await user.click(getByRole("button", { name: "Microphone" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("meter", { name: "Microphone level" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { of } from "rxjs";
|
|
||||||
|
|
||||||
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
|
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||||
vi.mock("../Platform", () => ({
|
vi.mock("../Platform", () => ({
|
||||||
@@ -15,9 +14,23 @@ vi.mock("../Platform", () => ({
|
|||||||
},
|
},
|
||||||
isFirefox: (): boolean => false,
|
isFirefox: (): boolean => false,
|
||||||
}));
|
}));
|
||||||
vi.mock("@livekit/components-core", () => ({
|
// One observer per device kind, so a test can add and remove hardware.
|
||||||
createMediaDeviceObserver: () => of([]),
|
const observers = vi.hoisted(
|
||||||
}));
|
() => new Map<string, { next: (devices: unknown[]) => void }>(),
|
||||||
|
);
|
||||||
|
vi.mock("@livekit/components-core", async () => {
|
||||||
|
const { BehaviorSubject: Subject } = await import("rxjs");
|
||||||
|
return {
|
||||||
|
createMediaDeviceObserver: (kind: string) => {
|
||||||
|
let observer = observers.get(kind);
|
||||||
|
if (observer === undefined) {
|
||||||
|
observer = new Subject<unknown[]>([]);
|
||||||
|
observers.set(kind, observer);
|
||||||
|
}
|
||||||
|
return observer;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
import { AudioOutput, MediaDevices } from "./MediaDevices";
|
import { AudioOutput, MediaDevices } from "./MediaDevices";
|
||||||
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput";
|
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput";
|
||||||
@@ -57,3 +70,132 @@ describe("MediaDevices audio output", () => {
|
|||||||
expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput);
|
expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function device(deviceId: string, label: string, groupId = deviceId): object {
|
||||||
|
return { deviceId, label, groupId, kind: "audioinput" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the hardware of one kind, as the browser would report it. */
|
||||||
|
function setDevices(kind: string, devices: object[]): void {
|
||||||
|
const observer = observers.get(kind);
|
||||||
|
if (observer === undefined) throw new Error(`nothing observing ${kind}`);
|
||||||
|
observer.next(devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newMediaDevices(): MediaDevices {
|
||||||
|
return new MediaDevices(new ObservableScope(), {
|
||||||
|
controlledAudioDevices: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MediaDevices selection", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
for (const kind of observers.keys()) setDevices(kind, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("persists the selected device across sessions", () => {
|
||||||
|
const devices = newMediaDevices();
|
||||||
|
setDevices("audioinput", [
|
||||||
|
device("mic1", "Microphone 1"),
|
||||||
|
device("mic2", "Microphone 2"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
devices.audioInput.select("mic2");
|
||||||
|
|
||||||
|
// A later call on the same machine reads the same stored preference.
|
||||||
|
expect(newMediaDevices().audioInput.selected$.value?.id).toBe("mic2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates the available devices when hardware changes", () => {
|
||||||
|
const devices = newMediaDevices();
|
||||||
|
setDevices("audioinput", [device("mic1", "Microphone 1")]);
|
||||||
|
expect([...devices.audioInput.available$.value.keys()]).toEqual(["mic1"]);
|
||||||
|
|
||||||
|
// A headset is plugged in.
|
||||||
|
setDevices("audioinput", [
|
||||||
|
device("mic1", "Microphone 1"),
|
||||||
|
device("mic2", "Headset"),
|
||||||
|
]);
|
||||||
|
expect([...devices.audioInput.available$.value.keys()]).toEqual([
|
||||||
|
"mic1",
|
||||||
|
"mic2",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// And unplugged again.
|
||||||
|
setDevices("audioinput", [device("mic1", "Microphone 1")]);
|
||||||
|
expect([...devices.audioInput.available$.value.keys()]).toEqual(["mic1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to the default device when the selected device disappears", () => {
|
||||||
|
const devices = newMediaDevices();
|
||||||
|
setDevices("audioinput", [
|
||||||
|
device("mic1", "Microphone 1"),
|
||||||
|
device("mic2", "Headset"),
|
||||||
|
]);
|
||||||
|
devices.audioInput.select("mic2");
|
||||||
|
expect(devices.audioInput.selected$.value?.id).toBe("mic2");
|
||||||
|
|
||||||
|
// The headset is unplugged mid-call.
|
||||||
|
setDevices("audioinput", [device("mic1", "Microphone 1")]);
|
||||||
|
|
||||||
|
expect(devices.audioInput.selected$.value?.id).toBe("mic1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back when the remembered device is absent", () => {
|
||||||
|
const devices = newMediaDevices();
|
||||||
|
setDevices("audioinput", [device("mic1", "Microphone 1")]);
|
||||||
|
// Remembered from a previous call, on hardware that is not here now.
|
||||||
|
devices.audioInput.select("a-device-from-last-time");
|
||||||
|
|
||||||
|
expect(devices.audioInput.selected$.value?.id).toBe("mic1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to numbered labels when labels are unavailable", () => {
|
||||||
|
const devices = newMediaDevices();
|
||||||
|
// The browser withholds names until permission has been granted.
|
||||||
|
setDevices("audioinput", [device("mic1", ""), device("mic2", "")]);
|
||||||
|
|
||||||
|
expect([...devices.audioInput.available$.value.values()]).toEqual([
|
||||||
|
{ type: "number", number: 1 },
|
||||||
|
{ type: "number", number: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists Default as a distinct entry", () => {
|
||||||
|
const devices = newMediaDevices();
|
||||||
|
setDevices("audiooutput", [device("spk1", "Speakers")]);
|
||||||
|
|
||||||
|
const available = devices.audioOutput.available$.value;
|
||||||
|
// Default follows the operating system and re-points when it changes, so
|
||||||
|
// it is its own choice rather than an alias for the device it resolves to.
|
||||||
|
// It carries no name of its own precisely because which device it resolves
|
||||||
|
// to is not knowable from here.
|
||||||
|
expect(available.get("spk1")).toEqual({ type: "name", name: "Speakers" });
|
||||||
|
expect(available.get("")).toEqual({ type: "default", name: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting one device kind leaves the others unchanged", () => {
|
||||||
|
const devices = newMediaDevices();
|
||||||
|
setDevices("audioinput", [
|
||||||
|
device("mic1", "Microphone 1"),
|
||||||
|
device("mic2", "Headset"),
|
||||||
|
]);
|
||||||
|
setDevices("audiooutput", [
|
||||||
|
device("spk1", "Speakers"),
|
||||||
|
device("spk2", "Headset"),
|
||||||
|
]);
|
||||||
|
setDevices("videoinput", [device("cam1", "Camera 1")]);
|
||||||
|
|
||||||
|
devices.audioOutput.select("spk2");
|
||||||
|
const audioInputBefore = devices.audioInput.selected$.value?.id;
|
||||||
|
const videoInputBefore = devices.videoInput.selected$.value?.id;
|
||||||
|
|
||||||
|
devices.audioInput.select("mic2");
|
||||||
|
|
||||||
|
expect(devices.audioOutput.selected$.value?.id).toBe("spk2");
|
||||||
|
expect(devices.videoInput.selected$.value?.id).toBe(videoInputBefore);
|
||||||
|
expect(audioInputBefore).not.toBe("mic2");
|
||||||
|
expect(devices.audioInput.selected$.value?.id).toBe("mic2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user