mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user