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