Files
element-call-Github/src/state/MediaDevices.test.ts
T
fkwpandClaude Opus 5 b7bd7d18b2 [Feature] Quick audio menu (#4275)
* Give the microphone level its own observable
* Add a microphone level meter
* Show speakers and microphones in the quick audio menu
* Wire speaker selection through the call footer
* Add stories for the meter and the device menu
* Add end-to-end specs for the quick audio menu
* Keep the footer while a menu opened from it is open
* Use the shared audio capture stub in the lobby test
---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 09:52:02 +00:00

194 lines
6.4 KiB
TypeScript

/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, describe, expect, test, vi } from "vitest";
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({
get platform(): string {
return getPlatform();
},
isFirefox: (): boolean => false,
}));
const observers = vi.hoisted(
() => new Map<string, { next: (devices: unknown[]) => void }>(),
);
vi.mock("@livekit/components-core", async () => {
const { BehaviorSubject: Subject } = await import("rxjs");
return {
createMediaDeviceObserver: (kind: string) => {
let observer = observers.get(kind);
if (observer === undefined) {
observer = new Subject<unknown[]>([]);
observers.set(kind, observer);
}
return observer;
},
};
});
import { AudioOutput, MediaDevices } from "./MediaDevices";
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput";
import { IOSControlledAudioOutput } from "./IOSControlledAudioOutput";
import { ObservableScope } from "./ObservableScope";
// Which audio output implementation is used is decided by what the app hosting
// Element Call told it, rather than being discovered from the environment.
describe("MediaDevices audio output", () => {
test("uses the browser's own output when nobody else is controlling it", () => {
const devices = new MediaDevices(new ObservableScope(), {
controlledAudioDevices: false,
});
expect(devices.audioOutput).toBeInstanceOf(AudioOutput);
});
test("hands control to the host on Android", () => {
getPlatform.mockReturnValue("android");
const devices = new MediaDevices(new ObservableScope(), {
controlledAudioDevices: true,
callIntent: "audio",
});
expect(devices.audioOutput).toBeInstanceOf(AndroidControlledAudioOutput);
});
test("hands control to the host elsewhere too", () => {
getPlatform.mockReturnValue("ios");
const devices = new MediaDevices(new ObservableScope(), {
controlledAudioDevices: true,
callIntent: "video",
});
expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput);
});
});
function device(deviceId: string, label: string, groupId = deviceId): object {
return { deviceId, label, groupId, kind: "audioinput" };
}
/** Replaces the devices of one kind, as the browser reports them. */
function setDevices(kind: string, devices: object[]): void {
const observer = observers.get(kind);
if (observer === undefined) throw new Error(`nothing observing ${kind}`);
observer.next(devices);
}
function newMediaDevices(): MediaDevices {
return new MediaDevices(new ObservableScope(), {
controlledAudioDevices: false,
});
}
describe("MediaDevices selection", () => {
afterEach(() => {
localStorage.clear();
for (const kind of observers.keys()) setDevices(kind, []);
});
test("persists the selected device across sessions", () => {
const devices = newMediaDevices();
setDevices("audioinput", [
device("mic1", "Microphone 1"),
device("mic2", "Microphone 2"),
]);
devices.audioInput.select("mic2");
// A later call reads the same stored preference.
expect(newMediaDevices().audioInput.selected$.value?.id).toBe("mic2");
});
test("updates the available devices when hardware changes", () => {
const devices = newMediaDevices();
setDevices("audioinput", [device("mic1", "Microphone 1")]);
expect([...devices.audioInput.available$.value.keys()]).toEqual(["mic1"]);
setDevices("audioinput", [
device("mic1", "Microphone 1"),
device("mic2", "Headset"),
]);
expect([...devices.audioInput.available$.value.keys()]).toEqual([
"mic1",
"mic2",
]);
setDevices("audioinput", [device("mic1", "Microphone 1")]);
expect([...devices.audioInput.available$.value.keys()]).toEqual(["mic1"]);
});
test("falls back to the default device when the selected device disappears", () => {
const devices = newMediaDevices();
setDevices("audioinput", [
device("mic1", "Microphone 1"),
device("mic2", "Headset"),
]);
devices.audioInput.select("mic2");
expect(devices.audioInput.selected$.value?.id).toBe("mic2");
setDevices("audioinput", [device("mic1", "Microphone 1")]);
expect(devices.audioInput.selected$.value?.id).toBe("mic1");
});
test("falls back when the remembered device is absent", () => {
const devices = newMediaDevices();
setDevices("audioinput", [device("mic1", "Microphone 1")]);
devices.audioInput.select("a-device-from-last-time");
expect(devices.audioInput.selected$.value?.id).toBe("mic1");
});
test("falls back to numbered labels when labels are unavailable", () => {
const devices = newMediaDevices();
// Names are withheld until permission is granted.
setDevices("audioinput", [device("mic1", ""), device("mic2", "")]);
expect([...devices.audioInput.available$.value.values()]).toEqual([
{ type: "number", number: 1 },
{ type: "number", number: 2 },
]);
});
test("lists Default as a distinct entry", () => {
const devices = newMediaDevices();
setDevices("audiooutput", [device("spk1", "Speakers")]);
const available = devices.audioOutput.available$.value;
// Default is its own entry, unnamed: which device it resolves to isn't knowable.
expect(available.get("spk1")).toEqual({ type: "name", name: "Speakers" });
expect(available.get("")).toEqual({ type: "default", name: null });
});
test("selecting one device kind leaves the others unchanged", () => {
const devices = newMediaDevices();
setDevices("audioinput", [
device("mic1", "Microphone 1"),
device("mic2", "Headset"),
]);
setDevices("audiooutput", [
device("spk1", "Speakers"),
device("spk2", "Headset"),
]);
setDevices("videoinput", [device("cam1", "Camera 1")]);
devices.audioOutput.select("spk2");
const audioInputBefore = devices.audioInput.selected$.value?.id;
const videoInputBefore = devices.videoInput.selected$.value?.id;
devices.audioInput.select("mic2");
expect(devices.audioOutput.selected$.value?.id).toBe("spk2");
expect(devices.videoInput.selected$.value?.id).toBe(videoInputBefore);
expect(audioInputBefore).not.toBe("mic2");
expect(devices.audioInput.selected$.value?.id).toBe("mic2");
});
});