diff --git a/src/state/CallViewModel/localMember/Publisher.test.ts b/src/state/CallViewModel/localMember/Publisher.test.ts index 8e03766ed..14ab4a271 100644 --- a/src/state/CallViewModel/localMember/Publisher.test.ts +++ b/src/state/CallViewModel/localMember/Publisher.test.ts @@ -5,7 +5,16 @@ 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, expect, it, test, vi } from "vitest"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + test, + vi, +} from "vitest"; import { ConnectionState as LivekitConnectionState, LocalParticipant, @@ -186,18 +195,36 @@ beforeEach(() => { }); 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 }); + type SelectedInput = + | { id: string; hardwareDeviceChange$: typeof NEVER } + | undefined; + + function selectedInput(id: string): SelectedInput { + return { id, hardwareDeviceChange$: NEVER }; + } + + /** + * Creates a Publisher whose LiveKit room starts out capturing from + * capturedDeviceId (undefined meaning no deviceId constraint, i.e. the + * browser default) and reports activeDeviceId as its active audio input. + */ + function createDeviceSyncPublisher({ + selected$, + capturedDeviceId, + activeDeviceId, + }: { + selected$: BehaviorSubject; + capturedDeviceId?: string; + activeDeviceId: string; + }): { publisher: Publisher; switchActiveDevice: Mock } { const switchActiveDevice = vi.fn().mockResolvedValue(true); const livekitRoom = mockLivekitRoom({ localParticipant, state: LivekitConnectionState.Connected, // ConnectionFactory leaves the deviceId out for the browser default - options: { audioCaptureDefaults: {} }, + options: { audioCaptureDefaults: { deviceId: capturedDeviceId } }, switchActiveDevice, - getActiveDevice: () => "hardware-id-of-whatever-the-browser-chose", + getActiveDevice: () => activeDeviceId, } as unknown as Partial); const publisher = new Publisher( @@ -210,17 +237,27 @@ describe("Publisher device sync", () => { logger, ); + return { publisher, switchActiveDevice }; + } + + it("does not pin a device for the virtual browser default input", async () => { + const selected$ = new BehaviorSubject(selectedInput("")); + const { publisher, switchActiveDevice } = createDeviceSyncPublisher({ + selected$, + activeDeviceId: "hardware-id-of-whatever-the-browser-chose", + }); + // 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 }); + selected$.next(selectedInput("headset")); expect(switchActiveDevice).toHaveBeenLastCalledWith( "audioinput", "headset", ); - selected$.next({ id: "", hardwareDeviceChange$: NEVER }); + selected$.next(selectedInput("")); expect(switchActiveDevice).toHaveBeenLastCalledWith( "audioinput", "default", @@ -230,6 +267,47 @@ describe("Publisher device sync", () => { await publisher.destroy(); }); + + it("pins the device the browser default resolved to when picked explicitly", async () => { + const selected$ = new BehaviorSubject(selectedInput("")); + const { publisher, switchActiveDevice } = createDeviceSyncPublisher({ + selected$, + // The browser default happens to capture from the built-in microphone + activeDeviceId: "built-in-mic", + }); + + expect(switchActiveDevice).not.toHaveBeenCalled(); + + // Picking that same microphone has to replace the loose constraint with a + // pin, or the capture would keep following the OS default + selected$.next(selectedInput("built-in-mic")); + expect(switchActiveDevice).toHaveBeenCalledTimes(1); + expect(switchActiveDevice).toHaveBeenLastCalledWith( + "audioinput", + "built-in-mic", + ); + + // Once pinned, re-emitting the same selection is a no-op again + selected$.next(selectedInput("built-in-mic")); + expect(switchActiveDevice).toHaveBeenCalledTimes(1); + + await publisher.destroy(); + }); + + it("does not switch when the room already captures from the selected device", async () => { + const selected$ = new BehaviorSubject( + selectedInput("headset"), + ); + const { publisher, switchActiveDevice } = createDeviceSyncPublisher({ + selected$, + capturedDeviceId: "headset", + activeDeviceId: "headset", + }); + + expect(switchActiveDevice).not.toHaveBeenCalled(); + + await publisher.destroy(); + }); }); describe("Publisher", () => { diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index 092fa094c..3a0ed33c4 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -376,9 +376,20 @@ export class Publisher { ); if (device === undefined) return; const browserDefaultInput = device.id === "" && kind !== "audiooutput"; + // While we capture from the browser default, LiveKit reports the + // physical device it resolved to, which can be any of the devices the + // user could also pick explicitly. getActiveDevice therefore can't + // tell an explicit pin apart from the default resolving to the same + // device, and only requestedId can: without this, picking the device + // the default happens to use would leave the loose constraint in + // place, and the capture would keep following the OS default. + const capturingBrowserDefault = requestedId === ""; if (browserDefaultInput) { - if (requestedId === "") return; - } else if (lkRoom.getActiveDevice(kind) === device.id) { + if (capturingBrowserDefault) return; + } else if ( + !capturingBrowserDefault && + lkRoom.getActiveDevice(kind) === device.id + ) { return; } requestedId = device.id;