diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index 3c3f46074..6b94c929a 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -137,10 +137,13 @@ export const Default: Story = { debugTileLayout: false, tileStoreGeneration: undefined, audioOptions: [], + audioOutputOptions: [], videoOptions: [], selectedAudio: undefined, + selectedAudioOutput: undefined, selectedVideo: undefined, selectAudioButtonOption: undefined, + selectAudioOutputOption: undefined, selectVideoButtonOption: undefined, }, parameters: { @@ -158,11 +161,16 @@ export const WithAudioAndVideoOptions: Story = { { label: { type: "name", name: "Microphone 1" }, id: "1" }, { 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: [ { label: { type: "name", name: "Camera 1" }, id: "1" }, { label: { type: "name", name: "Camera 2" }, id: "2" }, ], selectedAudio: "2", + selectedAudioOutput: "default", selectedVideo: "1", }, }; diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index 4f79f236c..45d346bab 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -99,11 +99,15 @@ export interface FooterState { /** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */ 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 */ videoOptions: MenuOptions[]; selectedAudio: string | undefined; + selectedAudioOutput: string | undefined; selectedVideo: string | undefined; selectAudioButtonOption: ((deviceId: string) => void) | undefined; + selectAudioOutputOption: ((deviceId: string) => void) | undefined; selectVideoButtonOption: ((option: string) => void) | undefined; } @@ -143,6 +147,9 @@ export const CallFooter: FC = ({ const audioOptions = useBehavior(vm.audioOptions$); const selectedAudio = useBehavior(vm.selectedAudio$); 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 toggleBlur = useBehavior(vm.toggleBlur$); const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$); @@ -168,7 +175,6 @@ export const CallFooter: FC = ({ if ((audioOptions?.length ?? 0) > 0) { buttons.push( = ({ options={audioOptions} selectedOption={selectedAudio} onSelect={selectAudioButtonOption} + outputOptions={audioOutputOptions} + selectedOutputOption={selectedAudioOutput} + onSelectOutput={selectAudioOutputOption} />, ); } else { @@ -197,7 +206,6 @@ export const CallFooter: FC = ({ if ((videoOptions?.length ?? 0) > 0) { buttons.push( ({ 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 * createCallFooterViewModel, with all other properties stubbed to @@ -96,6 +101,71 @@ const twoMicsAndOneCamMediaDevices = mockMediaDevices({ }); describe("createCallFooterViewModel", () => { + describe("selectAudioOutputOption", () => { + function buildFooterVm(): ReturnType { + 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([ + ["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()), + 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", () => { function checkEmptyFor(platform: string, layout: Layout): void { platformMock.mockReturnValue(platform); diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx index a2ca6c88e..305b2a434 100644 --- a/src/components/CallFooterViewModel.tsx +++ b/src/components/CallFooterViewModel.tsx @@ -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. */ -import { combineLatest, map, switchMap } from "rxjs"; +import { combineLatest, map, type Observable, switchMap } from "rxjs"; import { supportsBackgroundProcessors } from "@livekit/track-processors"; +import { supportsAudioOutputSelection } from "livekit-client"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; import { type MenuOptions } from "./MediaMuteAndSwitchButton"; @@ -67,49 +68,50 @@ function buildDeviceBehaviors( | "audioOptions$" | "selectedAudio$" | "selectAudioButtonOption$" + | "audioOutputOptions$" + | "selectedAudioOutput$" + | "selectAudioOutputOption$" | "videoOptions$" | "selectedVideo$" | "selectVideoButtonOption$" | "toggleBlur$" | "videoBlurEnabled$" > { - return { - audioOptions$: scope.behavior( - disableSwitcher$.pipe( - switchMap((disable) => - disable - ? constant([] as MenuOptions[]) - : mediaDevices.audioInput.available$.pipe( - map((available) => - [...available.entries()].map(([id, label]) => ({ - id, - label, - })), - ), + const options$ = ( + available$: Behavior>, + ): Observable => + disableSwitcher$.pipe( + switchMap((disable) => + disable + ? constant([] as MenuOptions[]) + : available$.pipe( + map((available) => + [...available.entries()].map(([id, label]) => ({ id, label })), ), - ), + ), ), - ), + ); + + return { + audioOptions$: scope.behavior(options$(mediaDevices.audioInput.available$)), selectedAudio$: scope.behavior( mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)), ), selectAudioButtonOption$: constant(mediaDevices.audioInput.select), - videoOptions$: scope.behavior( - disableSwitcher$.pipe( - switchMap((disable) => - disable - ? constant([] as MenuOptions[]) - : mediaDevices.videoInput.available$.pipe( - map((available) => - [...available.entries()].map(([id, label]) => ({ - id, - label, - })), - ), - ), - ), - ), + audioOutputOptions$: scope.behavior( + options$(mediaDevices.audioOutput.available$), ), + 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( mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)), ), @@ -263,10 +265,13 @@ export function createLobbyFooterViewModel( reactionData: undefined, tileStoreGeneration: undefined, audioOptions: undefined, + audioOutputOptions: undefined, videoOptions: undefined, selectedAudio: undefined, + selectedAudioOutput: undefined, selectedVideo: undefined, selectAudioButtonOption: undefined, + selectAudioOutputOption: undefined, selectVideoButtonOption: undefined, }), ...buildMuteBehaviors(scope, muteStates), diff --git a/src/room/LobbyView.test.tsx b/src/room/LobbyView.test.tsx index 7f03f2d29..a75dc1c79 100644 --- a/src/room/LobbyView.test.tsx +++ b/src/room/LobbyView.test.tsx @@ -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. */ -import { describe, expect, it, vi } from "vitest"; -import { render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { of } from "rxjs"; import { LeaveToHomeProvider } from "../LeaveToHomeContext"; import { TooltipProvider } from "@vector-im/compound-web"; import { type MatrixClient } from "matrix-js-sdk"; @@ -19,6 +21,7 @@ import { import { LobbyView } from "./LobbyView"; import { E2eeType } from "../e2ee/e2eeType"; import { mockMediaDevices, mockMuteStates } from "../utils/test"; +import { type MediaDevices } from "../state/MediaDevices"; import { MediaDevicesContext } from "../MediaDevicesContext"; import { type ProcessorState } from "../livekit/TrackProcessorContext"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; @@ -77,9 +80,10 @@ function renderLobbyView( props: Partial[0]> = {}, withAppBar = false, platform = "android", + devices: Partial = {}, ): ReturnType { platformMock.mockReturnValue(platform); - const mediaDevices = mockMediaDevices({}); + const mediaDevices = mockMediaDevices(devices); const muteStates = mockMuteStates(); const hideHeader = withAppBar ? true : false; const lobbyView = ( @@ -178,3 +182,71 @@ describe("LobbyView", () => { 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); + + // 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(); + }); +}); diff --git a/src/state/MediaDevices.test.ts b/src/state/MediaDevices.test.ts index f65b024e2..739f6d196 100644 --- a/src/state/MediaDevices.test.ts +++ b/src/state/MediaDevices.test.ts @@ -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. */ -import { describe, expect, test, vi } from "vitest"; -import { of } from "rxjs"; +import { afterEach, describe, expect, test, vi } from "vitest"; const getPlatform = vi.hoisted(() => vi.fn(() => "desktop")); vi.mock("../Platform", () => ({ @@ -15,9 +14,23 @@ vi.mock("../Platform", () => ({ }, isFirefox: (): boolean => false, })); -vi.mock("@livekit/components-core", () => ({ - createMediaDeviceObserver: () => of([]), -})); +// One observer per device kind, so a test can add and remove hardware. +const observers = vi.hoisted( + () => new Map 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([]); + observers.set(kind, observer); + } + return observer; + }, + }; +}); import { AudioOutput, MediaDevices } from "./MediaDevices"; import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput"; @@ -57,3 +70,132 @@ describe("MediaDevices audio output", () => { 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"); + }); +});