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); + }); + } }