diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index 3e344dd3a..275417a4c 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -25,11 +25,11 @@ import { useTranslation } from "react-i18next"; import styles from "./MediaMuteAndSwitchButton.module.css"; import { MicButton, VideoButton } from "../button"; -import { type DeviceLabel } from "../state/MediaDevices"; +import { type AudioInputDeviceLabel } from "../state/MediaDevices"; import { useMediaDevices } from "../MediaDevicesContext"; export interface MenuOptions { - label: DeviceLabel; + label: AudioInputDeviceLabel; id: string; } @@ -180,6 +180,9 @@ export const MediaMuteAndSwitchButton: FC = ({ case "number": labelText = numberedLabel(label.number); break; + case "default": + labelText = t("settings.devices.default"); + break; } return ( = ({ const initialAudioOptions = useInitial( () => audioEnabled && { - deviceId: getValue(devices.audioInput.selected$)?.id, + // "" is the virtual browser default: no deviceId constraint + deviceId: getValue(devices.audioInput.selected$)?.id || undefined, }, ); diff --git a/src/state/AudioInput.test.ts b/src/state/AudioInput.test.ts new file mode 100644 index 000000000..c93cb72fc --- /dev/null +++ b/src/state/AudioInput.test.ts @@ -0,0 +1,110 @@ +/* +Copyright 2026 Element Corp. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { afterEach, beforeEach, describe, vi, it, expect } from "vitest"; +import * as ComponentsCore from "@livekit/components-core"; + +import { ObservableScope } from "./ObservableScope"; +import { MediaDevices } from "./MediaDevices"; +import { audioInput as audioInputSetting } from "../settings/settings"; +import { withTestScheduler } from "../utils/test"; + +const BUILT_IN_MIC = { + deviceId: "b3d4cb5e0c8e1d7a2f9b6c4e5d8a7f1c3e2b9d6a5c4f8e7d1b2a3c4d5e6f7a8b", + kind: "audioinput", + label: "iPhone Microphone", + groupId: "1", +} as unknown as MediaDeviceInfo; + +const HEADSET_MIC = { + deviceId: "c4e5d6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5", + kind: "audioinput", + label: "Headset Microphone", + groupId: "2", +} as unknown as MediaDeviceInfo; + +// Chrome exposes a synthetic "default" device that follows the OS default. +const CHROME_DEFAULT = { + ...HEADSET_MIC, + deviceId: "default", + label: `Default - ${HEADSET_MIC.label}`, +} as unknown as MediaDeviceInfo; + +vi.mock("@livekit/components-core", () => ({ + createMediaDeviceObserver: vi.fn(), +})); + +describe("AudioInput", () => { + let testScope: ObservableScope; + + beforeEach(() => { + testScope = new ObservableScope(); + // Device preferences persist in localStorage across tests + audioInputSetting.setValue(undefined); + }); + + afterEach(() => { + testScope.end(); + }); + + it("uses the browser default input when there is no default pseudo-device", () => { + withTestScheduler(({ behavior, cold, expectObservable }) => { + vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue( + // iOS / Firefox: physical devices only, built-in first + cold("a", { a: [BUILT_IN_MIC, HEADSET_MIC] }), + ); + + const { audioInput } = new MediaDevices(testScope); + + expectObservable(audioInput.available$).toBe("a", { + a: new Map([ + ["", { type: "default", name: null }], + [BUILT_IN_MIC.deviceId, { type: "name", name: BUILT_IN_MIC.label }], + [HEADSET_MIC.deviceId, { type: "name", name: HEADSET_MIC.label }], + ]), + }); + expectObservable(audioInput.selected$.pipe()).toBe("a", { + a: expect.objectContaining({ id: "" }), + }); + }); + }); + + it("keeps using Chrome's default device", () => { + withTestScheduler(({ behavior, cold, expectObservable }) => { + vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue( + cold("a", { a: [CHROME_DEFAULT, BUILT_IN_MIC, HEADSET_MIC] }), + ); + + const { audioInput } = new MediaDevices(testScope); + + expectObservable(audioInput.selected$).toBe("a", { + a: expect.objectContaining({ id: "default" }), + }); + }); + }); + + it("honours an explicit choice and falls back to the browser default when it disappears", () => { + withTestScheduler(({ behavior, cold, schedule, expectObservable }) => { + vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue( + cold("a---b", { + a: [BUILT_IN_MIC, HEADSET_MIC], + b: [BUILT_IN_MIC], + }), + ); + + const { audioInput } = new MediaDevices(testScope); + + schedule("--a", { a: () => audioInput.select(HEADSET_MIC.deviceId) }); + + expectObservable(audioInput.selected$).toBe("a-b-c", { + a: expect.objectContaining({ id: "" }), + b: expect.objectContaining({ id: HEADSET_MIC.deviceId }), + c: expect.objectContaining({ id: "" }), + }); + }); + }); +}); diff --git a/src/state/CallViewModel/localMember/Publisher.test.ts b/src/state/CallViewModel/localMember/Publisher.test.ts index 21775c58d..8e03766ed 100644 --- a/src/state/CallViewModel/localMember/Publisher.test.ts +++ b/src/state/CallViewModel/localMember/Publisher.test.ts @@ -12,9 +12,10 @@ import { type LocalTrack, type LocalTrackPublication, ParticipantEvent, + type Room as LivekitRoom, Track, } from "livekit-client"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, NEVER } from "rxjs"; import { logger } from "matrix-js-sdk/lib/logger"; import { ObservableScope } from "../../ObservableScope"; @@ -23,10 +24,12 @@ import { flushPromises, mockLivekitRoom, mockMediaDevices, + deviceStub, } from "../../../utils/test"; import { Publisher } from "./Publisher"; import { type Connection } from "../remoteMembers/Connection"; import { type MuteStates } from "../../MuteStates"; +import { type MediaDevices } from "../../MediaDevices"; let scope: ObservableScope; @@ -182,6 +185,53 @@ beforeEach(() => { } as unknown as Connection; }); +describe("Publisher device sync", () => { + it("does not pin a device for the virtual browser default input", async () => { + const selected$ = new BehaviorSubject< + { id: string; hardwareDeviceChange$: typeof NEVER } | undefined + >({ id: "", hardwareDeviceChange$: NEVER }); + const switchActiveDevice = vi.fn().mockResolvedValue(true); + const livekitRoom = mockLivekitRoom({ + localParticipant, + state: LivekitConnectionState.Connected, + // ConnectionFactory leaves the deviceId out for the browser default + options: { audioCaptureDefaults: {} }, + switchActiveDevice, + getActiveDevice: () => "hardware-id-of-whatever-the-browser-chose", + } as unknown as Partial); + + const publisher = new Publisher( + { ...connection, livekitRoom }, + mockMediaDevices({ + audioInput: { ...deviceStub, selected$ }, + } as unknown as Partial), + muteStates, + constant({ supported: false, processor: undefined }), + logger, + ); + + // Already capturing from the browser default: nothing to switch, even + // though LiveKit reports the physical device it ended up with. + expect(switchActiveDevice).not.toHaveBeenCalled(); + + selected$.next({ id: "headset", hardwareDeviceChange$: NEVER }); + expect(switchActiveDevice).toHaveBeenLastCalledWith( + "audioinput", + "headset", + ); + + selected$.next({ id: "", hardwareDeviceChange$: NEVER }); + expect(switchActiveDevice).toHaveBeenLastCalledWith( + "audioinput", + "default", + false, + ); + expect(switchActiveDevice).toHaveBeenCalledTimes(2); + + await publisher.destroy(); + }); +}); + describe("Publisher", () => { let publisher: Publisher; diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index 0d5f263a6..092fa094c 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -353,8 +353,19 @@ export class Publisher { const syncDevice = ( kind: MediaDeviceKind, selected$: Observable, - ): Subscription => - selected$.pipe(scope.bind()).subscribe((device) => { + ): Subscription => { + // The ID we last asked LiveKit for. Needed for the virtual browser + // default input ("") because LiveKit reports the physical device it + // ended up capturing from, which never equals "". + // ConnectionFactory already applied the selection when the room was + // created; a browser-default input is represented there by an absent + // deviceId. + let requestedId: string | undefined = + kind === "audioinput" && + lkRoom.options.audioCaptureDefaults?.deviceId === undefined + ? "" + : undefined; + return selected$.pipe(scope.bind()).subscribe((device) => { if (lkRoom.state != LivekitConnectionState.Connected) return; // if (this.connectionState$.value !== ConnectionState.Connected) return; this.logger.info( @@ -363,20 +374,25 @@ export class Publisher { " !== ", device?.id, ); - if ( - device !== undefined && - lkRoom.getActiveDevice(kind) !== device.id - ) { - lkRoom - .switchActiveDevice(kind, device.id) - .catch((e: Error) => - this.logger.error( - `Failed to sync ${kind} device with LiveKit`, - e, - ), - ); + if (device === undefined) return; + const browserDefaultInput = device.id === "" && kind !== "audiooutput"; + if (browserDefaultInput) { + if (requestedId === "") return; + } else if (lkRoom.getActiveDevice(kind) === device.id) { + return; } + requestedId = device.id; + // For the browser default input, ask for "default" as a non-exact + // constraint: browsers that have no such device ignore it and capture + // from the OS default input. + (browserDefaultInput + ? lkRoom.switchActiveDevice(kind, "default", false) + : lkRoom.switchActiveDevice(kind, device.id) + ).catch((e: Error) => + this.logger.error(`Failed to sync ${kind} device with LiveKit`, e), + ); }); + }; syncDevice("audioinput", devices.audioInput.selected$); if (!controlledAudioDevices) diff --git a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts index 30ff37f3e..cbdf83cc1 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts @@ -174,7 +174,9 @@ function generateRoomOption({ publishDefaults, audioCaptureDefaults: { ...liveKitOptions.audioCaptureDefaults, - deviceId: devices.audioInput.selected$.value?.id, + // "" is the virtual browser default: leave the constraint out so the + // browser captures from the OS default input. + deviceId: devices.audioInput.selected$.value?.id || undefined, echoCancellation: echoCancellationSetting.getValue(), noiseSuppression: noiseSuppressionSetting.getValue(), autoGainControl: autoGainControlSetting.getValue(), diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index 70a676cf5..737bd5438 100644 --- a/src/state/MediaDevices.ts +++ b/src/state/MediaDevices.ts @@ -36,12 +36,15 @@ export type DeviceLabel = | { type: "name"; name: string } | { type: "number"; number: number }; -export type AudioOutputDeviceLabel = +export type AudioInputDeviceLabel = | DeviceLabel - | { type: "speaker" } - | { type: "earpiece" } | { type: "default"; name: string | null }; +export type AudioOutputDeviceLabel = + | AudioInputDeviceLabel + | { type: "speaker" } + | { type: "earpiece" }; + /** * Base selected-device value shared by all media kinds. * @@ -191,7 +194,10 @@ function selectDevice$