From cd8768a1a6f13c909f035b1d0bf1cfa361085b9d 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. --- docs/controls.md | 1 + src/controls.ts | 6 ++++ src/state/MediaDevices.test.ts | 50 ++++++++++++++++++++++++++++++++++ src/state/MediaDevices.ts | 30 +++++++++++++++++++- 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 src/state/MediaDevices.test.ts diff --git a/docs/controls.md b/docs/controls.md index 111c9ed5..3e639ef3 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -23,3 +23,4 @@ On mobile platforms (iOS, Android), web views do not reliably support selecting playing audio in the webview. It can be helpful to do device setup on the native app when the webviews audio is ready. In particular android is using it to setup the output channel so that the call volume can be controlled by the hardware volume rocker. +- `controls.toggleEarpieceMode(): void` Switches audio between the earpiece device and an arbitrary non-earpiece device. diff --git a/src/controls.ts b/src/controls.ts index 1c7da6a3..504fc96a 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -18,6 +18,7 @@ export interface Controls { onAudioPlaybackStarted?: () => void; setAudioEnabled(enabled: boolean): void; showNativeAudioDevicePicker?: () => void; + toggleEarpieceMode(): void; /** @deprecated use setAvailableAudioDevices instead*/ setAvailableOutputDevices(devices: OutputDevice[]): void; @@ -57,6 +58,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) { @@ -91,6 +94,9 @@ window.controls = { ); setAudioEnabled$.next(enabled); }, + toggleEarpieceMode(): void { + earpieceModeToggle$.next(); + }, // wrappers for the deprecated controls fields setOutputEnabled(enabled: boolean): void { 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 8aa279f3..1d6e87d5 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 externalDeviceSelection$, availableOutputDevices$, + earpieceModeToggle$, } from "../controls"; import { getUrlParams } from "../UrlParams"; @@ -365,5 +367,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); + }); + } }