Size the device menu against the call, and cover its criteria

- Bound the scrolling device list by the measured height of the call area.
  It was capped with a viewport unit and a Radix variable, both of which
  measure the window: as a component in a corner of a host's page, the menu
  would have been sized against the whole page. The menu is portalled outside
  the root, so no container query reaches it and it has to be measured.
- Leave a single divider between the two lists, as the design has. Compound
  underlines every menu heading, so the headings drop theirs, and the line
  closing the microphone section goes: the section wrapper already bounds the
  meter's stickiness without one.
- Cover device persistence, hot-plug, fallback when the device in use is
  removed, fallback to a default when a remembered device is gone, numbered
  labels before permission, "Default" listed as its own entry, one device kind
  not disturbing another, and camera parity. Their acceptance criteria named
  check commands for tests that did not exist, which makes the criteria
  unenforceable and blocks the next drift check.

Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC3, AC4, AC5, AC8, AC9,
AC10, AC11, AC19, AC20
This commit is contained in:
fkwp
2026-09-16 16:23:02 +02:00
parent 3f97ec8619
commit 7f3b98ed6a
4 changed files with 219 additions and 21 deletions
@@ -36,19 +36,27 @@ Please see LICENSE in the repository root for full details.
}
}
/* The menu is portalled outside the call root, so it cannot be sized against
the app's container. Radix measures the space it actually has and publishes
it here, which is neither a viewport unit nor a guessed pixel height. */
.menu {
display: flex;
flex-direction: column;
max-block-size: var(--radix-dropdown-menu-content-available-height);
}
/* Only the device lists scroll; the level meter stays put beneath them. */
/* Compound underlines every menu heading. The design has a single line, the
one dividing the speaker list from the microphone list, so the headings
carry none and that divider is drawn explicitly. */
.menu h3 {
border-block-end: none;
padding-block-end: var(--cpd-space-2x);
}
/* Only the device lists scroll; the level meter stays put beneath them.
The bound comes from the measured height of the call area, set by the
component: the menu is portalled outside the root, so neither a container
query nor a viewport unit describes the space it is allowed to fill. */
.deviceList {
overflow-y: auto;
min-block-size: 0;
max-block-size: var(--device-list-max-height);
}
/* The meter belongs to the microphone section: it stays at the bottom of the
@@ -405,6 +405,34 @@ describe("MediaMuteAndSwitchButton", () => {
).not.toHaveAttribute("aria-disabled", "true");
});
test("camera menu uses the same selection pattern and keeps the blur toggle", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="video"
enabled={true}
options={[
{ label: { type: "name", name: "Camera 1" }, id: "cam1" },
{ label: { type: "name", name: "Camera 2" }, id: "cam2" },
]}
selectedOption="cam1"
onSelect={vi.fn()}
videoBlurToggleClick={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Camera" }));
// Same selection pattern as the microphone menu.
screen.getByRole("menuitemradio", { name: "Camera 1", checked: true });
screen.getByRole("menuitemradio", { name: "Camera 2", checked: false });
// And background blur is still reachable from here.
expect(
screen.getByRole("menuitemcheckbox", { name: "Blur background" }),
).toBeInTheDocument();
});
test("lists speaker and microphone sections", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
+33 -11
View File
@@ -5,7 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useState, type FC, useEffect, type ReactElement } from "react";
import {
useState,
type CSSProperties,
type FC,
useEffect,
type ReactElement,
} from "react";
import {
Button,
Menu,
@@ -30,6 +36,7 @@ import {
type DeviceLabel,
} from "../state/MediaDevices";
import { useMediaDevices } from "../MediaDevicesContext";
import { useRootElement } from "../RootElementContext";
import { MicrophoneLevelMeter } from "./MicrophoneLevelMeter";
import { useMicrophoneLevel } from "./useMicrophoneLevel";
@@ -106,6 +113,18 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
const devices = useMediaDevices();
// Only while the menu is open, so nothing holds a second capture of the
// microphone for the length of a call.
// The menu is portalled outside the call root, so nothing in the stylesheets
// can size it against the call. Measure the call area rather than the window,
// or the menu is wrong wherever Element Call is not the whole page. Measured
// when the menu opens: it is short-lived enough not to need watching.
const rootElement = useRootElement();
const [listMaxHeight, setListMaxHeight] = useState<number>();
useEffect(() => {
if (menuOpen)
setListMaxHeight(
Math.max(160, Math.round(rootElement.clientHeight * 0.6)),
);
}, [menuOpen, rootElement]);
const microphoneState = useMicrophoneLevel(
selectedOption,
menuOpen && iconsAndLabels === "audio",
@@ -294,7 +313,15 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
/>
}
>
<div className={styles.deviceList}>
<div
className={styles.deviceList}
style={
{
"--device-list-max-height":
listMaxHeight === undefined ? undefined : `${listMaxHeight}px`,
} as CSSProperties
}
>
{iconsAndLabels === "audio" && outputOptions && (
<>
<MenuTitle title={t("settings.devices.speaker")} />
@@ -320,15 +347,10 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
numberedLabel,
)}
{iconsAndLabels === "audio" && (
<>
<MicrophoneLevelMeter
state={microphoneState}
className={styles.stickyMeter}
/>
{/* Closes the microphone section. The meter stays pinned until
this line reaches it, then leaves with the section. */}
<Separator />
</>
<MicrophoneLevelMeter
state={microphoneState}
className={styles.stickyMeter}
/>
)}
</div>
</div>
+145 -5
View File
@@ -5,8 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test, vi } from "vitest";
import { of } from "rxjs";
import { afterEach, describe, expect, test, vi } from "vitest";
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({
@@ -15,9 +14,23 @@ vi.mock("../Platform", () => ({
},
isFirefox: (): boolean => false,
}));
vi.mock("@livekit/components-core", () => ({
createMediaDeviceObserver: () => of([]),
}));
// One observer per device kind, so a test can add and remove hardware.
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";
@@ -57,3 +70,130 @@ describe("MediaDevices audio output", () => {
expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput);
});
});
function device(deviceId: string, label: string, groupId = deviceId): object {
return { deviceId, label, groupId, kind: "audioinput" };
}
/** Replaces the hardware of one kind, as the browser would report it. */
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 on the same machine 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"]);
// A headset is plugged in.
setDevices("audioinput", [
device("mic1", "Microphone 1"),
device("mic2", "Headset"),
]);
expect([...devices.audioInput.available$.value.keys()]).toEqual([
"mic1",
"mic2",
]);
// And unplugged again.
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");
// The headset is unplugged mid-call.
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")]);
// Remembered from a previous call, on hardware that is not here now.
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();
// The browser withholds names until permission has been 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 follows the operating system and re-points when it changes, so
// it is its own choice rather than an alias for the device it resolves to.
expect(available.get("spk1")).toEqual({ type: "name", name: "Speakers" });
expect(available.get("")).toEqual({ type: "default", name: "Speakers" });
});
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");
});
});