From 2bda5eb18cb41ab9d89d1444961f8da65d672331 Mon Sep 17 00:00:00 2001 From: fkwp Date: Thu, 10 Sep 2026 14:25:13 +0200 Subject: [PATCH] Show the microphone level meter in the audio menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- playwright/audio-menu.spec.ts | 27 +++ src/components/CallFooter.tsx | 1 + .../MediaMuteAndSwitchButton.module.css | 30 ++++ .../MediaMuteAndSwitchButton.test.tsx | 168 ++++++++++++++++-- src/components/MediaMuteAndSwitchButton.tsx | 134 ++++++++------ src/components/useMicrophoneLevel.test.tsx | 7 + src/components/useMicrophoneLevel.ts | 5 + 7 files changed, 309 insertions(+), 63 deletions(-) diff --git a/playwright/audio-menu.spec.ts b/playwright/audio-menu.spec.ts index 297e7c435..6c83fc1c6 100644 --- a/playwright/audio-menu.spec.ts +++ b/playwright/audio-menu.spec.ts @@ -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, diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index b9a259ec3..bfcee0317 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -189,6 +189,7 @@ export const CallFooter: FC = ({ outputOptions: audioOutputOptions ?? [], selectedOutput: selectedAudioOutput, onSelectOutput: selectAudioOutputOption, + micDeviceId: selectedAudio, }; if ((audioOptions?.length ?? 0) > 0 || audioControls !== undefined) { diff --git a/src/components/MediaMuteAndSwitchButton.module.css b/src/components/MediaMuteAndSwitchButton.module.css index 408edf024..2d2c39d67 100644 --- a/src/components/MediaMuteAndSwitchButton.module.css +++ b/src/components/MediaMuteAndSwitchButton.module.css @@ -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)); +} diff --git a/src/components/MediaMuteAndSwitchButton.test.tsx b/src/components/MediaMuteAndSwitchButton.test.tsx index c6c05ebc1..29cdf1dec 100644 --- a/src/components/MediaMuteAndSwitchButton.test.tsx +++ b/src/components/MediaMuteAndSwitchButton.test.tsx @@ -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 ( + + ); + } + renderComponent(); + 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 {} + public async close(): Promise {} + }, + ); + }); + + 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> { - 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( { audioControls={props.audioControls ?? audioControls()} />, ); + } + + /** Renders the microphone button with the audio menu and opens the menu. */ + async function openAudioMenu( + props: AudioMenuProps = {}, + ): Promise> { + const user = userEvent.setup(); + renderAudioMenu(props); await user.click(screen.getByRole("button", { name: "Microphone" })); await screen.findByRole("menu"); return user; diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index b0bba7811..74fb96010 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -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 = ({ 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 = ({ 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 ( + + ) + } + onSelect={(e) => { + e.preventDefault(); + if (id === selectedOption) return; + setPlannedSelection(id); + onSelect?.(id); + }} + key={id} + role="menuitemradio" + aria-checked={selectedOption === id} + > + {selectedOption === id && ( + + )} + {selectedOption !== id && plannedSelection === id && ( + + )} + + ); + }); + return (
= ({ {/* The mute button lives inside */} {button} = ({ /> } > - {options?.map(({ label, id }) => { - let labelText: string; - switch (label.type) { - case "name": - labelText = label.name; - break; - case "number": - labelText = numberedLabel(label.number); - break; - } - return ( - - ) - } - onSelect={(e) => { - e.preventDefault(); - if (id === selectedOption) return; - setPlannedSelection(id); - onSelect?.(id); - }} - key={id} - role="menuitemradio" - aria-checked={selectedOption === id} - > - {selectedOption === id && ( - - )} - {selectedOption !== id && plannedSelection === id && ( - - )} - - ); - })} + {audioControls ? ( +
+ {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. */} +
+ +
+
+ ) : ( + deviceItems + )} {audioControls && ( <>
diff --git a/src/components/useMicrophoneLevel.test.tsx b/src/components/useMicrophoneLevel.test.tsx index 9b40ce4cf..a44a0262b 100644 --- a/src/components/useMicrophoneLevel.test.tsx +++ b/src/components/useMicrophoneLevel.test.tsx @@ -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 } }); diff --git a/src/components/useMicrophoneLevel.ts b/src/components/useMicrophoneLevel.ts index 9e0968d00..0c53454c3 100644 --- a/src/components/useMicrophoneLevel.ts +++ b/src/components/useMicrophoneLevel.ts @@ -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