From 49c9f5e7699895393d30ca5f90458ff2efa5c580 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 13 Jun 2025 12:13:39 -0400 Subject: [PATCH] Add a global control for toggling earpiece mode This will be used by Element X to show an earpiece toggle button in the header. --- src/controls.ts | 2 ++ src/state/MediaDevices.test.ts | 50 ++++++++++++++++++++++++++++++++++ src/state/MediaDevices.ts | 30 +++++++++++++++++++- 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 src/state/MediaDevices.test.ts diff --git a/src/controls.ts b/src/controls.ts index a2f67dfc..9e5ad68d 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -61,6 +61,8 @@ export const outputDevice$ = new Subject(); */ export const setAudioEnabled$ = new Subject(); +export const earpieceModeToggle$ = new Subject(); + let playbackStartedEmitted = false; export const setPlaybackStarted = (): void => { if (!playbackStartedEmitted) { diff --git a/src/state/MediaDevices.test.ts b/src/state/MediaDevices.test.ts new file mode 100644 index 00000000..3f0a11d6 --- /dev/null +++ b/src/state/MediaDevices.test.ts @@ -0,0 +1,50 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { vi, test, onTestFinished } from "vitest"; +import { createMediaDeviceObserver } from "@livekit/components-core"; +import { map, of } from "rxjs"; + +import { withTestScheduler } from "../utils/test"; +import { MediaDevices } from "./MediaDevices"; +import { ObservableScope } from "./ObservableScope"; + +const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); +vi.mock("../UrlParams", () => ({ getUrlParams })); + +vi.mock("@livekit/components-core"); + +test("audio output changes when toggling earpiece mode", () => { + withTestScheduler(({ schedule, expectObservable }) => { + getUrlParams.mockReturnValue({ controlledAudioDevices: true }); + vi.mocked(createMediaDeviceObserver).mockReturnValue(of([])); + + const scope = new ObservableScope(); + onTestFinished(() => scope.end()); + const devices = new MediaDevices(scope); + + window.controls.setAvailableAudioDevices([ + { id: "speaker", name: "Speaker", isSpeaker: true }, + { id: "earpiece", name: "Earpiece", isEarpiece: true }, + ]); + + const toggleInputMarbles = " -aa"; + const expectedEarpieceModeMarbles = " nyn"; + const expectedSelectedOutputMarbles = "ses"; + + schedule(toggleInputMarbles, { + a: () => window.controls.toggleEarpieceMode(), + }); + expectObservable(devices.earpieceMode$).toBe(expectedEarpieceModeMarbles, { + n: false, + y: true, + }); + expectObservable( + devices.audioOutput.selected$.pipe(map((d) => d?.id)), + ).toBe(expectedSelectedOutputMarbles, { s: "speaker", e: "earpiece" }); + }); +}); diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index 873cc1fc..7604ff77 100644 --- a/src/state/MediaDevices.ts +++ b/src/state/MediaDevices.ts @@ -15,6 +15,7 @@ import { startWith, Subject, switchMap, + withLatestFrom, type Observable, } from "rxjs"; import { createMediaDeviceObserver } from "@livekit/components-core"; @@ -30,6 +31,7 @@ import { type ObservableScope } from "./ObservableScope"; import { outputDevice$ as controlledOutputSelection$, availableOutputDevices$ as controlledAvailableOutputDevices$, + earpieceModeToggle$, } from "../controls"; import { getUrlParams } from "../UrlParams"; @@ -362,5 +364,31 @@ export class MediaDevices { public readonly videoInput: MediaDevice = new VideoInput(this.usingNames$, this.scope); - public constructor(private readonly scope: ObservableScope) {} + /** + * Whether audio is currently being output through the earpiece. + */ + public readonly earpieceMode$: Observable = combineLatest( + [this.audioOutput.available$, this.audioOutput.selected$], + (available, selected) => + selected !== undefined && available.get(selected.id)?.type === "earpiece", + ).pipe(this.scope.state()); + + public constructor(private readonly scope: ObservableScope) { + earpieceModeToggle$ + .pipe( + withLatestFrom( + this.audioOutput.available$, + this.earpieceMode$, + (_toggle, available, earpieceMode) => + // Determine the new device ID to switch to + [...available].find( + ([, d]) => (d.type === "earpiece") !== earpieceMode, + )?.[0], + ), + this.scope.bind(), + ) + .subscribe((newSelection) => { + if (newSelection !== undefined) this.audioOutput.select(newSelection); + }); + } }