diff --git a/src/state/CallViewModel/localMember/Publisher.test.ts b/src/state/CallViewModel/localMember/Publisher.test.ts index 14ab4a271..b020434f1 100644 --- a/src/state/CallViewModel/localMember/Publisher.test.ts +++ b/src/state/CallViewModel/localMember/Publisher.test.ts @@ -212,12 +212,13 @@ describe("Publisher device sync", () => { selected$, capturedDeviceId, activeDeviceId, + switchActiveDevice = vi.fn().mockResolvedValue(true), }: { selected$: BehaviorSubject; capturedDeviceId?: string; activeDeviceId: string; + switchActiveDevice?: Mock; }): { publisher: Publisher; switchActiveDevice: Mock } { - const switchActiveDevice = vi.fn().mockResolvedValue(true); const livekitRoom = mockLivekitRoom({ localParticipant, state: LivekitConnectionState.Connected, @@ -294,6 +295,41 @@ describe("Publisher device sync", () => { await publisher.destroy(); }); + it("retries the same selection after a failed switch", async () => { + const selected$ = new BehaviorSubject(selectedInput("")); + const switchActiveDevice = vi + .fn() + .mockRejectedValueOnce(new Error("could not open the device")) + .mockResolvedValue(true); + const { publisher } = createDeviceSyncPublisher({ + selected$, + // Pinned to the headset, so moving to the browser default is a switch + capturedDeviceId: "headset", + activeDeviceId: "headset", + switchActiveDevice, + }); + + expect(switchActiveDevice).toHaveBeenCalledTimes(1); + await flushPromises(); + + // The failed switch left us on the headset, so selecting the browser + // default again has to try once more rather than count as already done + selected$.next(selectedInput("")); + expect(switchActiveDevice).toHaveBeenCalledTimes(2); + expect(switchActiveDevice).toHaveBeenLastCalledWith( + "audioinput", + "default", + false, + ); + await flushPromises(); + + // This time it worked, so there is nothing left to do + selected$.next(selectedInput("")); + expect(switchActiveDevice).toHaveBeenCalledTimes(2); + + await publisher.destroy(); + }); + it("does not switch when the room already captures from the selected device", async () => { const selected$ = new BehaviorSubject( selectedInput("headset"), diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index 3a0ed33c4..0a26f6e17 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -392,6 +392,7 @@ export class Publisher { ) { return; } + const previousRequestedId = requestedId; 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 @@ -399,9 +400,13 @@ export class Publisher { (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), - ); + ).catch((e: Error) => { + // LiveKit keeps capturing from the previous device, so record that + // again to leave the next selection of this device something to do. + // A selection made while the switch was in flight wins, though. + if (requestedId === device.id) requestedId = previousRequestedId; + this.logger.error(`Failed to sync ${kind} device with LiveKit`, e); + }); }); };