Show the microphone level meter in the audio menu

The meter sits at the foot of the microphone group and follows the selected
microphone. Its capture starts when the menu opens and stops when it closes,
muted or not, so the microphone is only held while the user is looking at the
level. A denied permission shows a hint in place of the meter.

Keyboard navigation marks the current menu item with a focus border; pointer
use gets the hover background only, since the menu moves DOM focus to
whatever the pointer is over.

Spec: FEATURES_SPEC/2026-09_Audio_Quick_Menu.md, slice 3 — AC9, AC10, AC12,
AC14, AC23, AC24 (manual), AC26.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-09-10 23:53:36 +02:00
co-authored by Claude Fable 5.1
parent 29a7a50d57
commit 2bda5eb18c
7 changed files with 309 additions and 63 deletions
+27
View File
@@ -63,6 +63,33 @@ test("audio menu leaves participants visible while switching output", async ({
await SpaHelpers.expectVideoTilesCount(page, 2);
});
test("level indicator moves with microphone input", async ({
browser,
browserName,
}) => {
test.skip(
browserName === "firefox",
"Firefox has no audio backend on the CI runner, so the level stays at zero there. It passes against Firefox elsewhere, including CI's own Docker image over plain HTTP.",
);
const context = await browser.newContext({ reducedMotion: "reduce" });
const page = await context.newPage();
await page.goto("/");
await SpaHelpers.createCall(page, "Speaker", "Level meter", true);
await expect(page.getByTestId("videoTile")).toHaveCount(1);
await page.getByRole("button", { name: "Microphone" }).click();
const meter = page.getByRole("menu").getByRole("meter");
await expect(meter).toBeVisible();
// The browser's fake microphone plays a tone, so the meter has to leave its
// resting level while the menu is open.
await expect
.poll(async () => Number(await meter.getAttribute("aria-valuenow")), {
timeout: 15_000,
})
.toBeGreaterThan(0);
});
async function firstUncheckedIndex(
rows: Locator,
count: number,
+1
View File
@@ -189,6 +189,7 @@ export const CallFooter: FC<FooterProps> = ({
outputOptions: audioOutputOptions ?? [],
selectedOutput: selectedAudioOutput,
onSelectOutput: selectAudioOutputOption,
micDeviceId: selectedAudio,
};
if ((audioOptions?.length ?? 0) > 0 || audioControls !== undefined) {
@@ -47,3 +47,33 @@ Please see LICENSE in the repository root for full details.
color: var(--cpd-color-text-primary);
font: var(--cpd-font-body-md-regular);
}
.micSection {
display: flex;
flex-direction: column;
gap: var(--cpd-space-1x);
}
.stickyMeter {
position: sticky;
inset-block-end: 0;
/* Opaque, so the device rows scrolling underneath do not show through. */
background: var(--cpd-color-bg-canvas-default);
padding-block-start: var(--cpd-space-1x);
/* The menu draws its border as an outline inset by one border width. This
row is positioned and opaque, so without the matching inset it paints over
that border at the left and right edges. */
margin-inline: var(--cpd-border-width-1);
}
/* Radix moves DOM focus onto whichever item the pointer is over, so the
browser's own focus ring shows up during mouse use. Keyboard navigation
gets the border; the pointer gets the hover background and nothing else. */
.menu [role^="menuitem"]:focus {
outline: none;
}
.menu [role^="menuitem"]:focus-visible {
outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused);
outline-offset: calc(-1 * var(--cpd-border-width-2));
}
+157 -11
View File
@@ -5,8 +5,14 @@ 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 { act, render, screen, type RenderResult } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
act,
render,
screen,
waitFor,
type RenderResult,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type JSX, useState, type ReactNode } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -363,6 +369,97 @@ describe("MediaMuteAndSwitchButton", () => {
});
describe("audio menu", () => {
test("level indicator responds while muted", async () => {
await openAudioMenu({ enabled: false });
// Muting must not stop the meter: checking the microphone before unmuting
// is the whole point of it.
await waitFor(() =>
expect(getUserMedia).toHaveBeenCalledWith({
audio: { deviceId: { exact: "mic-1" } },
}),
);
expect(screen.getByRole("meter")).toBeInTheDocument();
});
test("audio menu starts capture on open and stops on close", async () => {
const user = userEvent.setup();
renderAudioMenu();
expect(getUserMedia).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Microphone" }));
await waitFor(() => expect(getUserMedia).toHaveBeenCalledTimes(1));
await user.keyboard("[Escape]");
await waitFor(() => expect(stop).toHaveBeenCalled());
});
test("level indicator follows a microphone change", async () => {
const user = userEvent.setup();
function Wrapper(): JSX.Element {
const [mic, setMic] = useState("mic-1");
return (
<MediaMuteAndSwitchButton
title="Audio controls"
iconsAndLabels="audio"
enabled
onMuteClick={vi.fn()}
options={micOptions}
selectedOption={mic}
onSelect={setMic}
audioControls={audioControls({ micDeviceId: mic })}
/>
);
}
renderComponent(<Wrapper />);
await user.click(screen.getByRole("button", { name: "Microphone" }));
await waitFor(() =>
expect(getUserMedia).toHaveBeenLastCalledWith({
audio: { deviceId: { exact: "mic-1" } },
}),
);
await user.click(
screen.getByRole("menuitemradio", { name: "Headset Microphone" }),
);
await waitFor(() =>
expect(getUserMedia).toHaveBeenLastCalledWith({
audio: { deviceId: { exact: "mic-2" } },
}),
);
expect(stop).toHaveBeenCalled();
expect(screen.getByRole("menu")).toBeInTheDocument();
expect(screen.getByRole("meter")).toBeInTheDocument();
});
test("audio menu hints when microphone permission is denied", async () => {
getUserMedia.mockRejectedValue(
Object.assign(new Error("no"), { name: "NotAllowedError" }),
);
await openAudioMenu();
expect(await screen.findByTestId("mic_level_denied")).toHaveTextContent(
/microphone access is blocked/i,
);
expect(screen.queryByRole("meter")).toBe(null);
});
test("level indicator stays with the microphone list rather than the speakers", async () => {
await openAudioMenu();
// The meter reads the microphone, so it belongs to that group and never
// sits among the output controls.
const micSection = screen.getByTestId("audio_menu_mic_section");
expect(micSection).toContainElement(
screen.getByRole("menuitemradio", { name: "Headset Microphone" }),
);
expect(micSection).toContainElement(screen.getByTestId("mic_level_meter"));
expect(micSection).not.toContainElement(
screen.getByRole("menuitemradio", { name: "Headset" }),
);
});
test("audio menu switches microphone and stays open", async () => {
const onSelect = vi.fn();
const user = await openAudioMenu({ onSelect });
@@ -460,6 +557,46 @@ describe("audio menu", () => {
expect(screen.getByRole("menuitemradio", { name })).toBeInTheDocument();
});
const stop = vi.fn();
const getUserMedia = vi.fn();
beforeEach(() => {
stop.mockClear();
getUserMedia.mockReset().mockResolvedValue({
getTracks: () => [{ stop }],
} as unknown as MediaStream);
// Define only mediaDevices: replacing the whole navigator drops the
// prototype getters that user-event relies on.
Object.defineProperty(navigator, "mediaDevices", {
value: { getUserMedia },
configurable: true,
});
// jsdom has no AudioContext; the meter only needs a silent analyser.
vi.stubGlobal(
"AudioContext",
class {
public createAnalyser(): unknown {
return {
fftSize: 1024,
getFloatTimeDomainData: (out: Float32Array): void => {
out.fill(0);
},
};
}
public createMediaStreamSource(): { connect: () => void } {
return { connect: (): void => {} };
}
public async resume(): Promise<void> {}
public async close(): Promise<void> {}
},
);
});
afterEach(() => {
vi.unstubAllGlobals();
Reflect.deleteProperty(navigator, "mediaDevices");
});
const micOptions = [
{
label: { type: "name" as const, name: "Built-in Microphone" },
@@ -482,23 +619,24 @@ describe("audio menu", () => {
],
selectedOutput: "out-1",
onSelectOutput: vi.fn(),
micDeviceId: "mic-1",
...over,
};
}
/** Renders the microphone button with the audio menu and opens the menu. */
async function openAudioMenu(
props: {
audioControls?: AudioControls;
onSelect?: (id: string) => void;
} = {},
): Promise<ReturnType<typeof userEvent.setup>> {
const user = userEvent.setup();
interface AudioMenuProps {
enabled?: boolean;
audioControls?: AudioControls;
onSelect?: (id: string) => void;
}
/** Renders the microphone button with the audio menu, closed. */
function renderAudioMenu(props: AudioMenuProps = {}): void {
renderComponent(
<MediaMuteAndSwitchButton
title="Audio controls"
iconsAndLabels="audio"
enabled
enabled={props.enabled ?? true}
onMuteClick={vi.fn()}
options={micOptions}
selectedOption="mic-1"
@@ -506,6 +644,14 @@ describe("audio menu", () => {
audioControls={props.audioControls ?? audioControls()}
/>,
);
}
/** Renders the microphone button with the audio menu and opens the menu. */
async function openAudioMenu(
props: AudioMenuProps = {},
): Promise<ReturnType<typeof userEvent.setup>> {
const user = userEvent.setup();
renderAudioMenu(props);
await user.click(screen.getByRole("button", { name: "Microphone" }));
await screen.findByRole("menu");
return user;
+82 -52
View File
@@ -37,6 +37,8 @@ import {
type DeviceLabel,
} from "../state/MediaDevices";
import { useMediaDevices } from "../MediaDevicesContext";
import { AudioLevelMeter } from "./AudioLevelMeter";
import { useMicrophoneLevel } from "./useMicrophoneLevel";
export interface MenuOptions {
label: DeviceLabel;
@@ -60,6 +62,8 @@ export interface AudioControls {
outputOptions: OutputMenuOptions[];
selectedOutput: string | undefined;
onSelectOutput: (id: string) => void;
/** The microphone the level meter follows. */
micDeviceId: string | undefined;
}
export interface MediaMuteAndSwitchButtonProps {
@@ -112,6 +116,13 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
}, [menuOpen, devices]);
// The meter's capture is bound to the menu being open, so the microphone is
// only ever held while the user is looking at the level.
const micLevel = useMicrophoneLevel(
audioControls?.micDeviceId,
menuOpen && audioControls !== undefined,
);
let button;
let toggles: { label: string; enabled: boolean; id: string }[] = [];
switch (iconsAndLabels) {
@@ -174,6 +185,59 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
break;
}
const deviceItems = options?.map(({ label, id }) => {
let labelText: string;
switch (label.type) {
case "name":
labelText = label.name;
break;
case "number":
labelText = numberedLabel(label.number);
break;
}
return (
<MenuItem
hideChevron
label={labelText}
Icon={
IconOptions && (
<IconOptions
width={24}
height={24}
className={styles.itemIcon}
aria-hidden
/>
)
}
onSelect={(e) => {
e.preventDefault();
if (id === selectedOption) return;
setPlannedSelection(id);
onSelect?.(id);
}}
key={id}
role="menuitemradio"
aria-checked={selectedOption === id}
>
{selectedOption === id && (
<CheckIcon
width={24}
height={24}
aria-hidden // A label would be redundant to aria-checked above
/>
)}
{selectedOption !== id && plannedSelection === id && (
<SpinnerIcon
width={24}
height={24}
className={styles.rotate}
aria-label={t("settings.devices.activating")}
/>
)}
</MenuItem>
);
});
return (
<div
className={classNames({
@@ -184,6 +248,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
{/* The mute button lives inside */}
{button}
<Menu
className={styles.menu}
title={title}
showTitle={true}
open={menuOpen}
@@ -203,58 +268,23 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
/>
}
>
{options?.map(({ label, id }) => {
let labelText: string;
switch (label.type) {
case "name":
labelText = label.name;
break;
case "number":
labelText = numberedLabel(label.number);
break;
}
return (
<MenuItem
hideChevron
label={labelText}
Icon={
IconOptions && (
<IconOptions
width={24}
height={24}
className={styles.itemIcon}
aria-hidden
/>
)
}
onSelect={(e) => {
e.preventDefault();
if (id === selectedOption) return;
setPlannedSelection(id);
onSelect?.(id);
}}
key={id}
role="menuitemradio"
aria-checked={selectedOption === id}
>
{selectedOption === id && (
<CheckIcon
width={24}
height={24}
aria-hidden // A label would be redundant to aria-checked above
/>
)}
{selectedOption !== id && plannedSelection === id && (
<SpinnerIcon
width={24}
height={24}
className={styles.rotate}
aria-label={t("settings.devices.activating")}
/>
)}
</MenuItem>
);
})}
{audioControls ? (
<div
className={styles.micSection}
data-testid="audio_menu_mic_section"
>
{deviceItems}
{/* The meter reads the microphone, so it travels with the
microphone list rather than sitting among the output controls;
pinned to the foot of the list, it stays on screen for as long
as any microphone is. */}
<div className={styles.stickyMeter}>
<AudioLevelMeter state={micLevel} />
</div>
</div>
) : (
deviceItems
)}
{audioControls && (
<>
<hr />
@@ -154,6 +154,13 @@ describe("useMicrophoneLevel", () => {
);
});
test("level indicator is unavailable where the page has no media devices", () => {
vi.stubGlobal("navigator", {});
const { result } = renderHook(() => useMicrophoneLevel("mic-1", true));
expect(result.current).toEqual({ type: "unavailable" });
});
test("no capture is taken while metering is disabled", () => {
const getUserMedia = vi.fn();
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
+5
View File
@@ -57,6 +57,11 @@ export function useMicrophoneLevel(
setState({ type: "inactive" });
return;
}
// Insecure contexts have no media devices at all; nothing can be metered.
if (!("mediaDevices" in navigator)) {
setState({ type: "unavailable" });
return;
}
// Guards every asynchronous continuation below: the effect can be cleaned
// up while getUserMedia is still in flight, and the stream it eventually