Add a global control for toggling earpiece mode

This will be used by Element X to show an earpiece toggle button in the header.
This commit is contained in:
Robin
2025-06-13 12:13:39 -04:00
parent eb09710e61
commit cd8768a1a6
4 changed files with 86 additions and 1 deletions

View File

@@ -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.

View File

@@ -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<string | undefined>();
*/
export const setAudioEnabled$ = new Subject<boolean>();
export const earpieceModeToggle$ = new Subject<void>();
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 {

View File

@@ -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" });
});
});

View File

@@ -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<DeviceLabel, SelectedDevice> =
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<boolean> = 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);
});
}
}