mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-25 22:35:49 +00:00
* 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>
194 lines
6.4 KiB
TypeScript
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");
|
|
});
|
|
});
|