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
committed by Timo
parent c084b7518b
commit 49c9f5e769
3 changed files with 81 additions and 1 deletions

View File

@@ -61,6 +61,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) {

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 controlledOutputSelection$,
availableOutputDevices$ as controlledAvailableOutputDevices$,
earpieceModeToggle$,
} from "../controls";
import { getUrlParams } from "../UrlParams";
@@ -362,5 +364,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);
});
}
}