[Feature] Quick audio menu (#4275)

* Give the microphone level its own observable
* Add a microphone level meter
* Show speakers and microphones in the quick audio menu
* Wire speaker selection through the call footer
* Add stories for the meter and the device menu
* Add end-to-end specs for the quick audio menu
* Keep the footer while a menu opened from it is open
* Use the shared audio capture stub in the lobby test
---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-09-24 09:52:02 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent b4b72432b9
commit b7bd7d18b2
24 changed files with 3126 additions and 197 deletions
+9
View File
@@ -163,6 +163,12 @@
"login_auth_links_prompt": "Not registered yet?", "login_auth_links_prompt": "Not registered yet?",
"login_subheading": "To continue to Element", "login_subheading": "To continue to Element",
"login_title": "Login", "login_title": "Login",
"microphone_level": {
"label": "Microphone level",
"no_device": "No microphone found. Connect one, then try again.",
"permission_denied": "Microphone access is blocked. Allow it, then try again.",
"value": "{{level}} of {{max}}"
},
"microphone_off": "Microphone off", "microphone_off": "Microphone off",
"microphone_on": "Microphone on", "microphone_on": "Microphone on",
"mute_microphone_button_label": "Mute microphone", "mute_microphone_button_label": "Mute microphone",
@@ -215,11 +221,14 @@
"activating": "Activating…", "activating": "Activating…",
"camera": "Camera", "camera": "Camera",
"camera_numbered": "Camera {{n}}", "camera_numbered": "Camera {{n}}",
"camera_source": "Camera Source",
"change_device_button": "Change audio device", "change_device_button": "Change audio device",
"default": "Default", "default": "Default",
"default_named": "Default <2>({{name}})</2>", "default_named": "Default <2>({{name}})</2>",
"default_named_plain": "Default ({{name}})",
"handset": "Handset", "handset": "Handset",
"loudspeaker": "Loudspeaker", "loudspeaker": "Loudspeaker",
"mic_source": "Mic Source",
"microphone": "Microphone", "microphone": "Microphone",
"microphone_numbered": "Microphone {{n}}", "microphone_numbered": "Microphone {{n}}",
"speaker": "Speaker", "speaker": "Speaker",
+229
View File
@@ -0,0 +1,229 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test, type Locator, type Page } from "@playwright/test";
import { SpaHelpers } from "./spa-helpers.ts";
import { installFakeDevices } from "./utils/fake-devices.ts";
test.describe("the quick audio menu", () => {
test("lists speakers and microphones with a live level meter", async ({
page,
}) => {
await installFakeDevices(page, { microphones: 3, speakers: 3 });
await joinACall(page, "Menu user", "Audio menu");
await openAudioMenu(page);
await expect(page.getByRole("group", { name: "Speaker" })).toBeVisible();
await expect(page.getByRole("group", { name: "Microphone" })).toBeVisible();
// By name, not count: the browser adds its own fake output and a Default.
for (const n of [1, 2, 3])
await expect(
page
.getByRole("group", { name: "Speaker" })
.getByRole("menuitemradio", { name: `Fake Speaker ${n}` }),
).toBeVisible();
await expect(
page.getByRole("menuitemradio", { checked: true }),
).toHaveCount(2);
const meter = page.getByRole("meter", { name: "Microphone level" });
await expect(meter).toBeVisible();
await expect(meter).toHaveAttribute("aria-valuenow", /\d+/);
await expect(meter).toHaveAttribute("aria-valuetext", /\d+ of \d+/);
// Both browsers here can route audio; the platform that can't is a unit check.
await expect(
page
.getByRole("group", { name: "Speaker" })
.getByRole("menuitemradio")
.first(),
).toHaveAttribute("aria-disabled", "false");
});
test("moves the microphone and the speaker without disturbing the call", async ({
browser,
}) => {
// Two browsers and a real call.
test.slow();
const hostContext = await browser.newContext({ reducedMotion: "reduce" });
const host = await hostContext.newPage();
await installFakeDevices(host, { microphones: 3, speakers: 3 });
await joinACall(host, "Host", "Device switch");
const inviteLink = await SpaHelpers.getCallInviteLink(host);
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
const guest = await guestContext.newPage();
await SpaHelpers.joinCallFromInviteLink(guest, inviteLink, "Guest");
await SpaHelpers.expectVideoTilesCount(guest, 2);
await openAudioMenu(host);
await selectDevice(host, "Microphone", "Fake Microphone 2");
await openAudioMenu(host);
await selectDevice(host, "Speaker", "Fake Speaker 2");
// Not a rejoin: neither side drops, and the guest still has both tiles.
await expect(
host.getByRole("dialog", { name: "Reconnecting…" }),
).not.toBeVisible();
await expect(
guest.getByRole("dialog", { name: "Reconnecting…" }),
).not.toBeVisible();
await SpaHelpers.expectVideoTilesCount(guest, 2);
await expect(guest.getByText("Waiting for media...")).not.toBeVisible();
await hostContext.close();
await guestContext.close();
});
test("keeps the meter moving while muted, and sends nothing", async ({
browser,
}) => {
// Two browsers and a real call.
test.slow();
const hostContext = await browser.newContext({ reducedMotion: "reduce" });
const host = await hostContext.newPage();
await installFakeDevices(host);
await joinACall(host, "Muted host", "Muted meter");
const inviteLink = await SpaHelpers.getCallInviteLink(host);
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
const guest = await guestContext.newPage();
await SpaHelpers.joinCallFromInviteLink(guest, inviteLink, "Listener");
await SpaHelpers.expectVideoTilesCount(guest, 2);
const mute = host.getByTestId("incall_mute");
await mute.click();
await expect(mute).toHaveAttribute("aria-checked", "false");
await openAudioMenu(host);
const meter = host.getByRole("meter", { name: "Microphone level" });
await expect(meter).toBeVisible();
// By test id: the modal menu hides the rest of the call from the a11y tree.
await expect(mute).toHaveAttribute("aria-checked", "false");
await expect(mute).toBeVisible();
// And the guest is shown the mute.
await expect(
guest.getByTestId("videoTile").filter({ hasText: "Muted host" }),
).toBeVisible();
await hostContext.close();
await guestContext.close();
});
test("keeps the meter at the foot of the list while it scrolls", async ({
page,
}) => {
await installFakeDevices(page, { microphones: 20, speakers: 4 });
await joinACall(page, "Scroller", "Long device list");
await openAudioMenu(page);
const meter = page.getByRole("meter", { name: "Microphone level" });
await expect(meter).toBeVisible();
// Scrolled so the microphones start at the top: only there does a pinned
// meter differ from one that is simply last.
const list = page.locator("[role='menu'] div[role='none']").first();
await list.evaluate((element) => {
const group = element.querySelector("[role='group'][aria-label*='icro']");
element.scrollTop +=
group!.getBoundingClientRect().top -
element.getBoundingClientRect().top;
});
await expect(meter).toBeInViewport();
await expectPinnedInside(meter, list);
await expect(
page.getByRole("menuitemradio", { name: "Fake Microphone 20" }),
).toBeVisible();
});
test("shows the focus ring only when the keyboard moved the focus", async ({
page,
browserName,
}) => {
test.skip(
browserName === "firefox",
"Headless Firefox does not deliver synthetic key presses reliably; see reconnect.spec.ts",
);
await installFakeDevices(page);
await joinACall(page, "Keyboard user", "Focus ring");
await openAudioMenu(page);
const first = page.getByRole("menuitemradio").first();
// Opened by pointer: no ring, though Radix has moved focus into the menu.
await expect.poll(async () => outlineWidth(first)).toBe(0);
await page.keyboard.press("ArrowDown");
const focused = page.locator("[role='menuitemradio']:focus");
await expect.poll(async () => outlineWidth(focused)).toBeGreaterThan(0);
// And the pointer takes it away again.
await first.hover();
await expect.poll(async () => outlineWidth(focused)).toBe(0);
});
});
/** Creates a call and joins it, leaving the page in the call. */
async function joinACall(
page: Page,
userName: string,
callName: string,
): Promise<void> {
await page.goto("/");
await SpaHelpers.createCall(page, userName, callName, true);
await expect(page.getByTestId("name_tag")).toContainText(userName);
// The media controls stay disabled until devices have enumerated.
await expect(page.getByTestId("incall_mute")).toBeEnabled({
timeout: 10_000,
});
}
async function openAudioMenu(page: Page): Promise<void> {
await page.getByRole("button", { name: "Microphone" }).click();
await expect(page.getByRole("menu")).toBeVisible();
}
async function selectDevice(
page: Page,
section: "Speaker" | "Microphone",
name: string,
): Promise<void> {
const item = page
.getByRole("group", { name: section })
.getByRole("menuitemradio", { name });
await item.click();
// Selecting doesn't close the menu, so dismiss it.
await page.keyboard.press("Escape");
await expect(page.getByRole("menu")).not.toBeVisible();
}
/** Asserts the meter sits within the scrollport and inside the menu's frame. */
async function expectPinnedInside(
meter: Locator,
list: Locator,
): Promise<void> {
const meterBox = (await meter.boundingBox())!;
const listBox = (await list.boundingBox())!;
const frame = (await meter.page().getByRole("menu").boundingBox())!;
expect(meterBox.y + meterBox.height).toBeLessThanOrEqual(
listBox.y + listBox.height + 1,
);
expect(meterBox.y).toBeGreaterThanOrEqual(listBox.y - 1);
expect(meterBox.x).toBeGreaterThan(frame.x);
expect(meterBox.x + meterBox.width).toBeLessThan(frame.x + frame.width);
}
/** Painted outline width, in px. */
async function outlineWidth(item: Locator): Promise<number> {
if ((await item.count()) === 0) return 0;
return item.first().evaluate((element) => {
const { outlineStyle, outlineWidth } = getComputedStyle(element);
return outlineStyle === "none" ? 0 : Number.parseFloat(outlineWidth) || 0;
});
}
+135
View File
@@ -0,0 +1,135 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, type Locator, type Page, test } from "@playwright/test";
import { createUserAndRoom, resizeContainer, startHarness } from "./harness.ts";
import { installFakeDevices } from "../utils/fake-devices.ts";
/**
* The device menu with Element Call as a component in a host page, where the
* portalled menu must be sized against the call, not the window. Driven from
* the lobby: it builds the same menu, without two connections' worth of flake.
*/
// Sign-in, crypto setup and sync happen twice before anything shows.
test.describe.configure({ timeout: 180_000 });
test("sizes the device list against the call, not the window", async ({
page,
}) => {
await installFakeDevices(page, { microphones: 20, speakers: 4 });
const { username, roomId } = await createUserAndRoom("menusize");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
// A short call in a much taller page.
const container = pane.getByTestId("call-container");
await resizeContainer(container, { width: 900, height: 400 });
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000,
});
const list = await openDeviceList(page, pane);
const callHeight = (await container.boundingBox())!.height;
const windowHeight = page.viewportSize()!.height;
const listHeight = (await list.boundingBox())!.height;
expect(callHeight).toBeLessThan(windowHeight * 0.75);
expect(listHeight).toBeLessThanOrEqual(callHeight);
expect(listHeight).toBeLessThan(windowHeight * 0.6);
});
test("follows the call area when the host resizes it", async ({ page }) => {
await installFakeDevices(page, { microphones: 20, speakers: 4 });
const { username, roomId } = await createUserAndRoom("menuresize");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
const container = pane.getByTestId("call-container");
await resizeContainer(container, { width: 900, height: 360 });
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000,
});
const list = await openDeviceList(page, pane);
const whenShort = (await list.boundingBox())!.height;
// The host grows the call while the menu is open.
await resizeContainer(container, { width: 900, height: 700 });
await expect
.poll(async () => (await list.boundingBox())!.height)
.toBeGreaterThan(whenShort);
});
test("keeps every device reachable in a small container", async ({ page }) => {
await installFakeDevices(page, { microphones: 20, speakers: 4 });
const { username, roomId } = await createUserAndRoom("menureach");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
const container = pane.getByTestId("call-container");
await resizeContainer(container, { width: 400, height: 360 });
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000,
});
const list = await openDeviceList(page, pane);
// More devices than fit, so the list must scroll.
expect(await list.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(
true,
);
const last = page.getByRole("menuitemradio", { name: "Fake Microphone 20" });
await last.scrollIntoViewIfNeeded();
await expect(last).toBeInViewport();
// Reachable means clickable: the menu may be drawn outside the call area.
await last.click();
await expect(last).toHaveAttribute("aria-checked", "true");
});
test("tracks the focus source of its own call, not the page", async ({
page,
}) => {
await installFakeDevices(page);
const { username, roomId } = await createUserAndRoom("menufocus");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
const other = panes.nth(1);
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000,
});
await openDeviceList(page, pane);
const menu = page.getByRole("menu");
// Asserted on the attribute: the component build's scoped stylesheet doesn't
// reach the portalled menu, so its ring can't be read here.
await expect(menu).toHaveAttribute("data-focus-source", "pointer");
// A key pressed in the other call says nothing about this menu.
await other.evaluate((element) =>
element.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
),
);
await expect(menu).toHaveAttribute("data-focus-source", "pointer");
await page.keyboard.press("ArrowDown");
await expect(menu).toHaveAttribute("data-focus-source", "keyboard");
});
/** Opens a component's microphone menu and returns its device list. */
async function openDeviceList(page: Page, pane: Locator): Promise<Locator> {
await pane
.getByRole("button", { name: "Microphone" })
.click({ timeout: 60_000 });
await expect(page.getByRole("menu")).toBeVisible();
const list = page.locator("[role='menu'] div[role='none']").first();
await expect(list).toBeVisible();
return list;
}
+85
View File
@@ -0,0 +1,85 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Page } from "@playwright/test";
/**
* Adds synthetic devices beside the browser's one fake microphone and speaker,
* so the menu has a choice to show. They share one capture and `setSinkId` is
* accepted but not honoured: routing still needs hardware and a manual check.
* Call before the page navigates.
*/
export async function installFakeDevices(
page: Page,
{ microphones = 2, speakers = 2 } = {},
): Promise<void> {
await page.addInitScript(
({ microphones, speakers }) => {
const synthetic = (
kind: MediaDeviceKind,
count: number,
name: string,
): MediaDeviceInfo[] =>
Array.from({ length: count }, (_, i) => {
const info = {
deviceId: `${kind}-${i + 1}`,
groupId: `${kind}-group-${i + 1}`,
kind,
label: `${name} ${i + 1}`,
};
return { ...info, toJSON: () => info } as MediaDeviceInfo;
});
const ids = new Set(
[
...synthetic("audioinput", microphones, ""),
...synthetic("audiooutput", speakers, ""),
].map((d) => d.deviceId),
);
const devices = navigator.mediaDevices;
const enumerate = devices.enumerateDevices.bind(devices);
devices.enumerateDevices = async (): Promise<MediaDeviceInfo[]> => [
...(await enumerate()),
...synthetic("audioinput", microphones, "Fake Microphone"),
...synthetic("audiooutput", speakers, "Fake Speaker"),
];
// Our ids name no hardware, so drop the exact-device constraint.
const getUserMedia = devices.getUserMedia.bind(devices);
devices.getUserMedia = async (
constraints?: MediaStreamConstraints,
): Promise<MediaStream> => {
const audio = constraints?.audio;
if (typeof audio === "object") {
const requested = audio.deviceId;
const id =
typeof requested === "object" && requested !== null
? ((requested as ConstrainDOMStringParameters).exact as string)
: (requested as string | undefined);
if (id !== undefined && ids.has(id))
return getUserMedia({ ...constraints, audio: true });
}
return getUserMedia(constraints);
};
// Accept routing to our ids on both media elements and the AudioContext,
// which the app also routes.
for (const proto of [
HTMLMediaElement.prototype,
AudioContext.prototype,
]) {
const sink = proto as { setSinkId?: (id: string) => Promise<void> };
const setSinkId = sink.setSinkId;
if (setSinkId === undefined) continue;
sink.setSinkId = async function (id: string): Promise<void> {
if (!ids.has(id)) await setSinkId.call(this, id);
};
}
},
{ microphones, speakers },
);
}
+4 -1
View File
@@ -43,7 +43,10 @@ Please see LICENSE in the repository root for full details.
inset-inline: 0; inset-inline: 0;
} }
.footer.overlay:has(:focus-visible) { /* Keep the footer while it is in use: focus inside it, or a menu opened from
it. The menu is portalled out, so the trigger's aria-expanded tracks it. */
.footer.overlay:has(:focus-visible),
.footer.overlay:has([aria-expanded="true"]) {
opacity: 1; opacity: 1;
pointer-events: initial; pointer-events: initial;
} }
+35
View File
@@ -13,6 +13,7 @@ import { Link } from "@vector-im/compound-web";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { CallFooter, type FooterSnapshot } from "./CallFooter"; import { CallFooter, type FooterSnapshot } from "./CallFooter";
import inCallViewStyles from "../room/InCallView.module.css"; import inCallViewStyles from "../room/InCallView.module.css";
import styles from "./CallFooter.module.css";
import { useStaticViewModel } from "../state/ViewModel"; import { useStaticViewModel } from "../state/ViewModel";
import { ReactionsSenderContext } from "../reactions/useReactionsSender"; import { ReactionsSenderContext } from "../reactions/useReactionsSender";
import { type ReactionOption } from "../reactions"; import { type ReactionOption } from "../reactions";
@@ -137,10 +138,13 @@ export const Default: Story = {
debugTileLayout: false, debugTileLayout: false,
tileStoreGeneration: undefined, tileStoreGeneration: undefined,
audioOptions: [], audioOptions: [],
audioOutputOptions: [],
videoOptions: [], videoOptions: [],
selectedAudio: undefined, selectedAudio: undefined,
selectedAudioOutput: undefined,
selectedVideo: undefined, selectedVideo: undefined,
selectAudioButtonOption: undefined, selectAudioButtonOption: undefined,
selectAudioOutputOption: undefined,
selectVideoButtonOption: undefined, selectVideoButtonOption: undefined,
}, },
parameters: { parameters: {
@@ -158,15 +162,46 @@ export const WithAudioAndVideoOptions: Story = {
{ label: { type: "name", name: "Microphone 1" }, id: "1" }, { label: { type: "name", name: "Microphone 1" }, id: "1" },
{ label: { type: "name", name: "Microphone 2" }, id: "2" }, { label: { type: "name", name: "Microphone 2" }, id: "2" },
], ],
audioOutputOptions: [
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
{ label: { type: "name", name: "Headset" }, id: "2" },
],
videoOptions: [ videoOptions: [
{ label: { type: "name", name: "Camera 1" }, id: "1" }, { label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" }, { label: { type: "name", name: "Camera 2" }, id: "2" },
], ],
selectedAudio: "2", selectedAudio: "2",
selectedAudioOutput: "default",
selectedVideo: "1", selectedVideo: "1",
}, },
}; };
export const StaysWhileAMenuIsOpen: Story = {
...Default,
args: {
...WithAudioAndVideoOptions.args,
// In a short window the footer overlays the call and hides itself.
asOverlay: true,
showFooter: true,
},
play: async ({ canvasElement }): Promise<void> => {
const footer = canvasElement.querySelector<HTMLElement>(
'[data-testid="footer-container"]',
)!;
await userEvent.click(
within(canvasElement).getByRole("button", { name: "Microphone" }),
);
await expect(document.body.querySelector('[role="menu"]')).not.toBeNull();
// The call hides the footer with this class.
footer.classList.add(styles.hidden);
// Read after the 0.15s fade, not mid-transition.
await new Promise((settled) => setTimeout(settled, 400));
await expect(getComputedStyle(footer).opacity).toBe("1");
},
};
export const AudioBusy: Story = { export const AudioBusy: Story = {
...Default, ...Default,
args: { args: {
+10 -2
View File
@@ -99,11 +99,15 @@ export interface FooterState {
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */ /** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
audioOptions: MenuOptions[]; audioOptions: MenuOptions[];
/** Output (speaker) devices. */
audioOutputOptions: MenuOptions[];
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */ /** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
videoOptions: MenuOptions[]; videoOptions: MenuOptions[];
selectedAudio: string | undefined; selectedAudio: string | undefined;
selectedAudioOutput: string | undefined;
selectedVideo: string | undefined; selectedVideo: string | undefined;
selectAudioButtonOption: ((deviceId: string) => void) | undefined; selectAudioButtonOption: ((deviceId: string) => void) | undefined;
selectAudioOutputOption: ((deviceId: string) => void) | undefined;
selectVideoButtonOption: ((option: string) => void) | undefined; selectVideoButtonOption: ((option: string) => void) | undefined;
} }
@@ -143,6 +147,9 @@ export const CallFooter: FC<FooterProps> = ({
const audioOptions = useBehavior(vm.audioOptions$); const audioOptions = useBehavior(vm.audioOptions$);
const selectedAudio = useBehavior(vm.selectedAudio$); const selectedAudio = useBehavior(vm.selectedAudio$);
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$); const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
const audioOutputOptions = useBehavior(vm.audioOutputOptions$);
const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$);
const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$);
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$); const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
const toggleBlur = useBehavior(vm.toggleBlur$); const toggleBlur = useBehavior(vm.toggleBlur$);
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$); const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
@@ -168,7 +175,6 @@ export const CallFooter: FC<FooterProps> = ({
if ((audioOptions?.length ?? 0) > 0) { if ((audioOptions?.length ?? 0) > 0) {
buttons.push( buttons.push(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title={"Mic Source"}
key="audio" key="audio"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={audioEnabled ?? false} enabled={audioEnabled ?? false}
@@ -178,6 +184,9 @@ export const CallFooter: FC<FooterProps> = ({
options={audioOptions} options={audioOptions}
selectedOption={selectedAudio} selectedOption={selectedAudio}
onSelect={selectAudioButtonOption} onSelect={selectAudioButtonOption}
outputOptions={audioOutputOptions}
selectedOutputOption={selectedAudioOutput}
onSelectOutput={selectAudioOutputOption}
/>, />,
); );
} else { } else {
@@ -197,7 +206,6 @@ export const CallFooter: FC<FooterProps> = ({
if ((videoOptions?.length ?? 0) > 0) { if ((videoOptions?.length ?? 0) > 0) {
buttons.push( buttons.push(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title={"Camera Source"}
key="video" key="video"
iconsAndLabels="video" iconsAndLabels="video"
enabled={videoEnabled ?? false} enabled={videoEnabled ?? false}
@@ -30,6 +30,11 @@ vi.mock("@livekit/track-processors", () => ({
supportsBackgroundProcessors: (): boolean => false, supportsBackgroundProcessors: (): boolean => false,
})); }));
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
vi.mock("livekit-client", () => ({
supportsAudioOutputSelection: (): boolean => outputSelectionMock(),
}));
/** /**
* Returns the minimum set of CallViewModel fields required by * Returns the minimum set of CallViewModel fields required by
* createCallFooterViewModel, with all other properties stubbed to * createCallFooterViewModel, with all other properties stubbed to
@@ -96,6 +101,65 @@ const twoMicsAndOneCamMediaDevices = mockMediaDevices({
}); });
describe("createCallFooterViewModel", () => { describe("createCallFooterViewModel", () => {
describe("selectAudioOutputOption", () => {
function buildFooterVm(): ReturnType<typeof createCallFooterViewModel> {
platformMock.mockReturnValue("desktop");
return createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
}
it("is withheld where the platform cannot route audio to a chosen device", () => {
outputSelectionMock.mockReturnValue(false);
expect(buildFooterVm().selectAudioOutputOption$.value).toBeUndefined();
});
it("is offered where the platform can route audio to a chosen device", () => {
outputSelectionMock.mockReturnValue(true);
expect(buildFooterVm().selectAudioOutputOption$.value).toBeDefined();
});
});
describe("audioOutputOptions", () => {
it("is an empty list, not absent, where the platform enumerates no outputs", () => {
platformMock.mockReturnValue("desktop");
outputSelectionMock.mockReturnValue(true);
const vm = createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
mockMediaDevices({
audioInput: {
available$: constant(
new Map<string, DeviceLabel>([
["mic1", { type: "name", name: "Microphone 1" }],
]),
),
selected$: constant(undefined),
select: vi.fn(),
},
// As Safari: no outputs listed.
audioOutput: {
available$: constant(new Map<string, DeviceLabel>()),
selected$: constant(undefined),
select: vi.fn(),
},
}),
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
// Empty, not undefined: undefined would hide the section.
expect(vm.audioOutputOptions$.value).toEqual([]);
});
});
describe("audioOptions and videoOptions", () => { describe("audioOptions and videoOptions", () => {
function checkEmptyFor(platform: string, layout: Layout): void { function checkEmptyFor(platform: string, layout: Layout): void {
platformMock.mockReturnValue(platform); platformMock.mockReturnValue(platform);
+36 -31
View File
@@ -5,8 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { combineLatest, map, switchMap } from "rxjs"; import { combineLatest, map, type Observable, switchMap } from "rxjs";
import { supportsBackgroundProcessors } from "@livekit/track-processors"; import { supportsBackgroundProcessors } from "@livekit/track-processors";
import { supportsAudioOutputSelection } from "livekit-client";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { type MenuOptions } from "./MediaMuteAndSwitchButton"; import { type MenuOptions } from "./MediaMuteAndSwitchButton";
@@ -67,49 +68,50 @@ function buildDeviceBehaviors(
| "audioOptions$" | "audioOptions$"
| "selectedAudio$" | "selectedAudio$"
| "selectAudioButtonOption$" | "selectAudioButtonOption$"
| "audioOutputOptions$"
| "selectedAudioOutput$"
| "selectAudioOutputOption$"
| "videoOptions$" | "videoOptions$"
| "selectedVideo$" | "selectedVideo$"
| "selectVideoButtonOption$" | "selectVideoButtonOption$"
| "toggleBlur$" | "toggleBlur$"
| "videoBlurEnabled$" | "videoBlurEnabled$"
> { > {
return { const options$ = (
audioOptions$: scope.behavior( available$: Behavior<Map<string, MenuOptions["label"]>>,
disableSwitcher$.pipe( ): Observable<MenuOptions[]> =>
switchMap((disable) => disableSwitcher$.pipe(
disable switchMap((disable) =>
? constant([] as MenuOptions[]) disable
: mediaDevices.audioInput.available$.pipe( ? constant([] as MenuOptions[])
map((available) => : available$.pipe(
[...available.entries()].map(([id, label]) => ({ map((available) =>
id, [...available.entries()].map(([id, label]) => ({ id, label })),
label,
})),
),
), ),
), ),
), ),
), );
return {
audioOptions$: scope.behavior(options$(mediaDevices.audioInput.available$)),
selectedAudio$: scope.behavior( selectedAudio$: scope.behavior(
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)), mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
), ),
selectAudioButtonOption$: constant(mediaDevices.audioInput.select), selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
videoOptions$: scope.behavior( audioOutputOptions$: scope.behavior(
disableSwitcher$.pipe( options$(mediaDevices.audioOutput.available$),
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: mediaDevices.videoInput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label,
})),
),
),
),
),
), ),
selectedAudioOutput$: scope.behavior(
mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)),
),
// Withheld where the platform can't route audio to a chosen device, which
// disables the speaker section.
selectAudioOutputOption$: constant(
supportsAudioOutputSelection()
? mediaDevices.audioOutput.select
: undefined,
),
videoOptions$: scope.behavior(options$(mediaDevices.videoInput.available$)),
selectedVideo$: scope.behavior( selectedVideo$: scope.behavior(
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)), mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
), ),
@@ -263,10 +265,13 @@ export function createLobbyFooterViewModel(
reactionData: undefined, reactionData: undefined,
tileStoreGeneration: undefined, tileStoreGeneration: undefined,
audioOptions: undefined, audioOptions: undefined,
audioOutputOptions: undefined,
videoOptions: undefined, videoOptions: undefined,
selectedAudio: undefined, selectedAudio: undefined,
selectedAudioOutput: undefined,
selectedVideo: undefined, selectedVideo: undefined,
selectAudioButtonOption: undefined, selectAudioButtonOption: undefined,
selectAudioOutputOption: undefined,
selectVideoButtonOption: undefined, selectVideoButtonOption: undefined,
}), }),
...buildMuteBehaviors(scope, muteStates), ...buildMuteBehaviors(scope, muteStates),
@@ -35,3 +35,60 @@ Please see LICENSE in the repository root for full details.
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
.menu {
display: flex;
flex-direction: column;
}
/* Only the device lists scroll, bounded by the measured call area: the menu is
portalled out of the root, so CSS can't size it against the call. */
.deviceList {
overflow-y: auto;
min-block-size: 0;
max-block-size: var(--device-list-max-height);
/* Keeps a row reached by keyboard clear of the sticky heading and meter. */
scroll-padding-block: var(--device-list-scroll-padding-start, 0)
var(--device-list-scroll-padding-end, 0);
}
/* Compound's heading rule heads each section; the design adds space below it. */
.menu h3 {
margin-block-end: var(--cpd-space-5x);
}
/* And more above a heading that follows another section. */
.deviceList [role="group"] + [role="group"] h3 {
margin-block-start: var(--cpd-space-7x);
}
/* Sticky within its own section. Opaque and inset a border width so it doesn't
paint over the menu's outline. */
.sectionHeading {
position: sticky;
inset-block-start: 0;
background: var(--cpd-color-bg-canvas-default);
margin-inline: var(--cpd-border-width-1);
margin-block-start: var(--cpd-border-width-1);
}
/* Sticky at the foot of the microphone section; opaque and inset as above. */
.stickyMeter {
position: sticky;
inset-block-end: 0;
background: var(--cpd-color-bg-canvas-default);
margin-inline: var(--cpd-border-width-1);
margin-block-end: var(--cpd-border-width-1);
}
/* Radix focuses whatever the pointer is over, so the browser's ring would
follow the mouse. Replaced by one shown only for keyboard focus. */
.menu [role^="menuitem"]:focus,
.menu [role^="menuitem"]:focus-visible {
outline: none;
}
.menu[data-focus-source="keyboard"] [role^="menuitem"]:focus {
outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused);
outline-offset: calc(-1 * var(--cpd-border-width-2));
}
@@ -5,12 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { fn, userEvent, within, expect } from "storybook/test"; import { fn, userEvent, waitFor, within, expect } from "storybook/test";
import { type JSX } from "react"; import { useEffect, useState, type FC, type JSX, type ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton"; import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
import styles from "./MediaMuteAndSwitchButton.module.css";
import meterStyles from "./MicrophoneLevelMeter.module.css";
import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevicesContext } from "../MediaDevicesContext";
import { RootElementProvider } from "../RootElementContext";
import { MediaDevices } from "../state/MediaDevices"; import { MediaDevices } from "../state/MediaDevices";
import { globalScope } from "../state/ObservableScope"; import { globalScope } from "../state/ObservableScope";
@@ -18,12 +21,74 @@ const mediaDevices = new MediaDevices(globalScope, {
controlledAudioDevices: false, controlledAudioDevices: false,
}); });
/** Supplies a microphone, a wavering tone: a story's invented device ids match no hardware. */
const WithAMicrophone: FC<{ children: ReactNode }> = ({ children }) => {
useEffect(() => {
const context = new AudioContext();
const microphone = context.createMediaStreamDestination();
const tone = context.createOscillator();
const loudness = context.createGain();
// Wavers between a third and two thirds of full scale, so it reads as live.
const swing = context.createOscillator();
const depth = context.createGain();
loudness.gain.value = 0.25;
depth.gain.value = 0.2;
swing.frequency.value = 0.6;
tone.frequency.value = 220;
swing.connect(depth).connect(loudness.gain);
tone.connect(loudness).connect(microphone);
tone.start();
swing.start();
const devices = navigator.mediaDevices;
const openedForReal = devices.getUserMedia.bind(devices);
const opened = Promise.resolve(microphone.stream);
// A fresh clone each time, so a caller stopping its tracks doesn't end the next.
devices.getUserMedia = async (): Promise<MediaStream> =>
(await opened).clone();
return (): void => {
devices.getUserMedia = openedForReal;
tone.stop();
swing.stop();
void context.close();
};
}, []);
return <>{children}</>;
};
/** Supplies a call-sized root. Without one the list is bounded by the whole Storybook frame. */
const WithACallArea: FC<{ children: ReactNode }> = ({ children }) => {
const [callArea, setCallArea] = useState<HTMLElement | null>(null);
return (
<div
ref={setCallArea}
style={{
// A call's size: anything smaller makes a short device list scroll.
blockSize: 720,
display: "flex",
alignItems: "flex-end",
justifyContent: "center",
}}
>
{callArea !== null && (
<RootElementProvider value={callArea}>{children}</RootElementProvider>
)}
</div>
);
};
const meta = { const meta = {
component: MediaMuteAndSwitchButton, component: MediaMuteAndSwitchButton,
decorators: [ decorators: [
(Story): JSX.Element => ( (Story): JSX.Element => (
<MediaDevicesContext value={mediaDevices}> <MediaDevicesContext value={mediaDevices}>
<Story /> <WithACallArea>
<WithAMicrophone>
<Story />
</WithAMicrophone>
</WithACallArea>
</MediaDevicesContext> </MediaDevicesContext>
), ),
], ],
@@ -34,7 +99,6 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = { export const Default: Story = {
args: { args: {
title: "SomeMenu",
iconsAndLabels: "audio", iconsAndLabels: "audio",
enabled: true, enabled: true,
options: [ options: [
@@ -42,6 +106,13 @@ export const Default: Story = {
{ label: { type: "name", name: "Option 2" }, id: "2" }, { label: { type: "name", name: "Option 2" }, id: "2" },
], ],
selectedOption: "1", selectedOption: "1",
// The footer always passes an output list to the audio menu.
outputOptions: [
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
],
selectedOutputOption: "default",
onSelectOutput: fn(),
onMuteClick: fn(), onMuteClick: fn(),
onSelect: fn(), onSelect: fn(),
}, },
@@ -50,7 +121,6 @@ export const Default: Story = {
export const AudioMute: Story = { export const AudioMute: Story = {
args: { args: {
...Default.args, ...Default.args,
title: "Microphone",
iconsAndLabels: "audio", iconsAndLabels: "audio",
enabled: false, enabled: false,
options: [ options: [
@@ -73,21 +143,19 @@ export const AudioMute: Story = {
export const AudioUnmute: Story = { export const AudioUnmute: Story = {
args: { args: {
title: "Microphone", ...Default.args,
iconsAndLabels: "audio", iconsAndLabels: "audio",
enabled: true, enabled: true,
options: [ options: [
{ label: { type: "name", name: "Microphone 1" }, id: "1" }, { label: { type: "name", name: "Microphone 1" }, id: "1" },
{ label: { type: "name", name: "Microphone 2" }, id: "2" }, { label: { type: "name", name: "Microphone 2" }, id: "2" },
], ],
selectedOption: "2", selectedOption: "2",
}, },
}; };
export const VideoMute: Story = { export const VideoMute: Story = {
args: { args: {
title: "Camera",
iconsAndLabels: "video", iconsAndLabels: "video",
enabled: false, enabled: false,
options: [ options: [
@@ -101,7 +169,6 @@ export const VideoMute: Story = {
export const VideoUnmute: Story = { export const VideoUnmute: Story = {
args: { args: {
title: "Camera",
iconsAndLabels: "video", iconsAndLabels: "video",
enabled: true, enabled: true,
options: [ options: [
@@ -113,3 +180,479 @@ export const VideoUnmute: Story = {
selectedOption: "2", selectedOption: "2",
}, },
}; };
export const SpeakerAndMicrophoneSections: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
],
selectedOption: "mic1",
outputOptions: [
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
],
selectedOutputOption: "default",
onSelectOutput: fn(),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const headset = await within(document.body).findByRole("menuitemradio", {
name: "Headset",
});
await userEvent.click(headset);
await expect(args.onSelectOutput).toHaveBeenCalledWith("spk2");
// A few devices fit, so the list doesn't scroll.
const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`,
)!;
await expect(list.scrollHeight).toBe(list.clientHeight);
// Each section is headed by its own edge-to-edge rule, with no separator.
const menu = document.body.querySelector("[role='menu']")!;
await expect(
document.body.querySelectorAll("[role='separator']"),
).toHaveLength(0);
const headings = document.body.querySelectorAll<HTMLElement>(
`.${styles.sectionHeading}`,
);
await expect(headings).toHaveLength(2);
const frame = menu.getBoundingClientRect();
for (const heading of headings) {
const rule = heading.querySelector("h3")!;
await expect(
Number.parseFloat(getComputedStyle(rule).borderBottomWidth),
).toBeGreaterThan(0);
// Edge to edge, within the frame.
const box = rule.getBoundingClientRect();
await expect(box.left - frame.left).toBeLessThanOrEqual(2);
await expect(frame.right - box.right).toBeLessThanOrEqual(2);
}
// The first device sits further below the rule than from the menu's edge.
// Asserted as a relationship, not pixels: the asymmetry is the design.
const control = document.body.querySelector("input[type='radio']")!;
const ruleBottom = headings[0]
.querySelector("h3")!
.getBoundingClientRect().bottom;
const box = control.getBoundingClientRect();
await expect(box.top - ruleBottom).toBeGreaterThan(box.left - frame.left);
// And a section stands further from the one above than from its own first
// device.
const groups = document.body.querySelectorAll("[role='group']");
const speakers = groups[0].querySelectorAll("input[type='radio']");
const lastSpeaker = speakers[speakers.length - 1].getBoundingClientRect();
const nextHeading = groups[1]!.querySelector("h3")!.getBoundingClientRect();
await expect(nextHeading.top - lastSpeaker.bottom).toBeGreaterThan(
box.top - ruleBottom,
);
},
};
export const OutputCannotBeChosen: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
],
selectedOption: "mic1",
outputOptions: [
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
],
selectedOutputOption: "spk1",
// No callback: the speakers are listed, but none can be picked.
onSelectOutput: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const speakers = await within(document.body).findByRole("menuitemradio", {
name: "Speakers",
});
await expect(speakers).toHaveAttribute("aria-disabled", "true");
},
};
export const OnlyOneDevice: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: [{ label: { type: "name", name: "Microphone 1" }, id: "mic1" }],
selectedOption: "mic1",
outputOptions: [{ label: { type: "name", name: "Speakers" }, id: "spk1" }],
selectedOutputOption: "spk1",
onSelectOutput: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
// Shown, disabled, rather than hidden.
const only = await within(document.body).findByRole("menuitemradio", {
name: "Microphone 1",
});
await expect(only).toHaveAttribute("aria-disabled", "true");
},
};
/** A requested device hasn't arrived: nothing in either section can be picked. */
export const SelectionSettling: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
],
selectedOption: "mic1",
outputOptions: [
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
],
selectedOutputOption: "spk1",
onSelectOutput: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const menu = within(document.body);
// selectedOption never changes, so the request stays in flight.
await userEvent.click(
await menu.findByRole("menuitemradio", { name: "Microphone 2" }),
);
await expect(await menu.findByLabelText("Activating…")).toBeVisible();
for (const item of menu.getAllByRole("menuitemradio"))
await expect(item).toHaveAttribute("aria-disabled", "true");
},
};
/**
* The ring shows for keyboard focus only. Asserted on the painted outline:
* asserting data-focus-source would still pass with the CSS rule deleted.
*/
export const KeyboardFocusRing: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
],
selectedOption: "mic1",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const menu = within(document.body);
const first = await menu.findByRole("menuitemradio", {
name: "Microphone 1",
});
// Opened by pointer: no ring, even though Radix has moved focus.
await expect(outlineWidth(first)).toBe(0);
await userEvent.keyboard("{ArrowDown}");
const focused = document.activeElement as HTMLElement;
await expect(focused).toHaveRole("menuitemradio");
await expect(outlineWidth(focused)).toBeGreaterThan(0);
// And the pointer takes it away again.
await userEvent.hover(first);
await expect(outlineWidth(document.activeElement as HTMLElement)).toBe(0);
},
};
/** The list scrolls; the meter stays at the foot of the microphone section. */
export const ManyDevices: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: Array.from({ length: 20 }, (_, i) => ({
label: { type: "name" as const, name: `Microphone ${i + 1}` },
id: `mic${i + 1}`,
})),
selectedOption: "mic1",
outputOptions: Array.from({ length: 6 }, (_, i) => ({
label: { type: "name" as const, name: `Speaker ${i + 1}` },
id: `spk${i + 1}`,
})),
selectedOutputOption: "spk1",
onSelectOutput: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const menu = within(document.body);
// The sticky wrapper rather than the meter: without a fake microphone (as on
// WebKit) the meter shows a message instead of a level.
const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`,
)!;
const sticky = await waitFor(() => {
const element = document.body.querySelector<HTMLElement>(
`.${styles.stickyMeter}`,
);
if (element === null) throw new Error("the meter has not rendered yet");
return element;
});
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
// Scrolled so the microphone section starts at the top: only there does a
// pinned meter differ from one that is simply last.
const group = await menu.findByRole("group", { name: "Microphone" });
list.scrollTop +=
group.getBoundingClientRect().top - list.getBoundingClientRect().top;
await expect(list.scrollTop + list.clientHeight).toBeLessThan(
list.scrollHeight,
);
const scrollport = list.getBoundingClientRect();
const pinned = sticky.getBoundingClientRect();
await expect(pinned.bottom).toBeLessThanOrEqual(scrollport.bottom + 1);
await expect(pinned.top).toBeGreaterThanOrEqual(scrollport.top - 1);
// The whole menu is on screen, not bounded by the document.
const frame = document.body
.querySelector("[role='menu']")!
.getBoundingClientRect();
await expect(frame.top).toBeGreaterThanOrEqual(0);
await expect(frame.bottom).toBeLessThanOrEqual(window.innerHeight + 1);
// The meter is the menu's one opaque part, so it must stay inside the frame.
await expect(pinned.left).toBeGreaterThan(frame.left);
await expect(pinned.right).toBeLessThan(frame.right);
},
};
/** Painted outline width, in px. */
function outlineWidth(element: HTMLElement): number {
const { outlineStyle, outlineWidth } = getComputedStyle(element);
if (outlineStyle === "none") return 0;
return Number.parseFloat(outlineWidth) || 0;
}
/** Safari lists no outputs: a disabled, selected Default stands in. */
export const OutputNotEnumerated: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
],
selectedOption: "mic1",
outputOptions: [],
selectedOutputOption: undefined,
onSelectOutput: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const speakers = await within(document.body).findByRole("menuitemradio", {
name: "Default",
});
await expect(speakers).toHaveAttribute("aria-disabled", "true");
},
};
/** The meter's icon shares the radio controls' centre line; only a real browser lays this out. */
export const MeterAlignsWithTheDeviceRows: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
],
selectedOption: "mic1",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const menu = document.body.querySelector("[role='menu']")!;
const radio = menu.querySelector("input[type='radio']")!;
const icon = await waitFor(() => {
const element = document.body.querySelector(`.${meterStyles.icon}`);
if (element === null) throw new Error("the meter has not rendered yet");
return element;
});
// A pixel of slack, for subpixel layout.
await expect(Math.abs(centre(icon) - centre(radio))).toBeLessThanOrEqual(1);
},
};
/** Inline-axis centre of an element. */
function centre(element: Element): number {
const box = element.getBoundingClientRect();
return box.left + box.width / 2;
}
/** Every row the keyboard reaches is fully visible, not under a heading or the meter. */
export const KeyboardReachesEveryDevice: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
// Enough to scroll both ways, so either end can hide a row.
options: Array.from({ length: 20 }, (_, i) => ({
label: { type: "name" as const, name: `Microphone ${i + 1}` },
id: `mic${i + 1}`,
})),
selectedOption: "mic1",
outputOptions: Array.from({ length: 4 }, (_, i) => ({
label: { type: "name" as const, name: `Speaker ${i + 1}` },
id: `spk${i + 1}`,
})),
selectedOutputOption: "spk1",
onSelectOutput: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const meter = await waitFor(() => {
const element = document.body.querySelector<HTMLElement>(
`.${styles.stickyMeter}`,
);
if (element === null) throw new Error("the meter has not rendered yet");
return element;
});
// Drawn over the list: the sticky headings and the meter.
const overlays = [
meter,
...document.body.querySelectorAll<HTMLElement>(
`.${styles.sectionHeading}`,
),
];
const items = within(document.body).getAllByRole("menuitemradio");
// Down to the last device and back up.
for (const key of ["{ArrowDown}", "{ArrowUp}"])
for (let i = 0; i < items.length; i++) {
await userEvent.keyboard(key);
const focused = document.activeElement as HTMLElement;
await expect(focused).toHaveRole("menuitemradio");
// Checked as overlap: a heading only covers rows while its section is on
// screen.
await expect(overlapping(focused, overlays)).toBeLessThanOrEqual(1);
}
},
};
/** A section's heading stays at the top while the section is in view, and leaves with it. */
export const HeadingsStayWhileScrolling: Story = {
args: {
...Default.args,
iconsAndLabels: "audio",
enabled: true,
options: Array.from({ length: 20 }, (_, i) => ({
label: { type: "name" as const, name: `Microphone ${i + 1}` },
id: `mic${i + 1}`,
})),
selectedOption: "mic1",
outputOptions: Array.from({ length: 4 }, (_, i) => ({
label: { type: "name" as const, name: `Speaker ${i + 1}` },
id: `spk${i + 1}`,
})),
selectedOutputOption: "spk1",
onSelectOutput: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const menu = within(document.body);
const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`,
)!;
const group = await menu.findByRole("group", { name: "Microphone" });
// By class: the heading is aria-hidden, and its group carries the name.
const heading = group.querySelector<HTMLElement>(
`.${styles.sectionHeading}`,
)!;
// Far enough that the heading is only on screen if it is stuck there.
list.scrollTop +=
group.getBoundingClientRect().top - list.getBoundingClientRect().top + 80;
const scrollport = list.getBoundingClientRect();
await expect(heading.getBoundingClientRect().top).toBeLessThanOrEqual(
scrollport.top + 2,
);
await expect(heading.getBoundingClientRect().bottom).toBeGreaterThan(
scrollport.top,
);
// Clear of the menu's frame.
const frame = document.body
.querySelector("[role='menu']")!
.getBoundingClientRect();
await expect(heading.getBoundingClientRect().left).toBeGreaterThan(
frame.left,
);
},
};
/** How far the most overlapping of `overlays` covers `element`, in px. */
function overlapping(element: Element, overlays: Element[]): number {
const box = element.getBoundingClientRect();
return overlays.reduce((worst, overlay) => {
const over = overlay.getBoundingClientRect();
const shared =
Math.min(box.bottom, over.bottom) - Math.max(box.top, over.top);
return Math.max(worst, shared);
}, 0);
}
/** The blur toggle gets the keyboard ring too: it is the menu's child, not the list's. */
export const FocusRingCoversTheBlurToggle: Story = {
args: {
...VideoUnmute.args,
iconsAndLabels: "video",
videoBlurEnabled: false,
videoBlurToggleClick: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const toggle = await within(document.body).findByRole("menuitemcheckbox", {
name: /Blur background/,
});
// Arrow down past the cameras to the toggle.
for (let i = 0; i < 6 && document.activeElement !== toggle; i++)
await userEvent.keyboard("{ArrowDown}");
await expect(document.activeElement).toBe(toggle);
// The same ring the device rows get.
await expect(outlineWidth(toggle)).toBeGreaterThan(0);
// And the pointer takes it away, with the toggle still focused.
await userEvent.hover(toggle);
await expect(document.activeElement).toBe(toggle);
await expect(outlineWidth(toggle)).toBe(0);
},
};
+516 -39
View File
@@ -5,40 +5,61 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { act, render, screen, type RenderResult } from "@testing-library/react"; import { axe } from "vitest-axe";
import {
act,
render,
screen,
within,
type RenderResult,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { type JSX, useState, type ReactNode } from "react"; import { Profiler, type JSX, useState, type ReactNode } from "react";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton"; import {
MediaMuteAndSwitchButton,
type MenuOptions,
} from "./MediaMuteAndSwitchButton";
import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevicesContext } from "../MediaDevicesContext";
import { type MediaDevices } from "../state/MediaDevices"; import { type MediaDevices } from "../state/MediaDevices";
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
interface RenderOptions { interface RenderOptions {
requestDeviceNames: () => void; requestDeviceNames: () => void;
} }
function renderComponent( function withProviders(
component: ReactNode, component: ReactNode,
{ requestDeviceNames = (): void => {} }: Partial<RenderOptions> = {}, { requestDeviceNames = (): void => {} }: Partial<RenderOptions> = {},
): RenderResult { ): JSX.Element {
return render( return (
<TooltipProvider> <TooltipProvider>
<MediaDevicesContext <MediaDevicesContext
value={{ requestDeviceNames } as unknown as MediaDevices} value={{ requestDeviceNames } as unknown as MediaDevices}
> >
{component} {component}
</MediaDevicesContext> </MediaDevicesContext>
</TooltipProvider>, </TooltipProvider>
); );
} }
function renderComponent(
component: ReactNode,
options: Partial<RenderOptions> = {},
): RenderResult {
return render(withProviders(component, options));
}
describe("MediaMuteAndSwitchButton", () => { describe("MediaMuteAndSwitchButton", () => {
// Only one test stubs the capture; don't let it leak into the rest.
afterEach(restoreAudioCapture);
test("renders", () => { test("renders", () => {
const { container } = renderComponent( const { container } = renderComponent(
<TooltipProvider> <TooltipProvider>
<MediaMuteAndSwitchButton title={"Switcher"} iconsAndLabels={"audio"} /> <MediaMuteAndSwitchButton iconsAndLabels={"audio"} />
</TooltipProvider>, </TooltipProvider>,
); );
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
@@ -50,11 +71,7 @@ describe("MediaMuteAndSwitchButton", () => {
enabled: boolean, enabled: boolean,
): RenderResult => { ): RenderResult => {
return renderComponent( return renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton iconsAndLabels={type} enabled={enabled} />,
title={"Switcher"}
iconsAndLabels={type}
enabled={enabled}
/>,
); );
}; };
const renderAudioEndabled = renderLabels("audio", true); const renderAudioEndabled = renderLabels("audio", true);
@@ -81,7 +98,6 @@ describe("MediaMuteAndSwitchButton", () => {
const onMute = vi.fn(); const onMute = vi.fn();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute} onMuteClick={onMute}
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
@@ -98,7 +114,6 @@ describe("MediaMuteAndSwitchButton", () => {
const onMute = vi.fn(); const onMute = vi.fn();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute} onMuteClick={onMute}
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
@@ -119,7 +134,6 @@ describe("MediaMuteAndSwitchButton", () => {
const onMute = vi.fn(); const onMute = vi.fn();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute} onMuteClick={onMute}
iconsAndLabels="video" iconsAndLabels="video"
enabled={true} enabled={true}
@@ -139,11 +153,7 @@ describe("MediaMuteAndSwitchButton", () => {
const user = userEvent.setup(); const user = userEvent.setup();
const requestDeviceNames = vi.fn(); const requestDeviceNames = vi.fn();
renderComponent( renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton iconsAndLabels="audio" enabled />,
title="Switcher"
iconsAndLabels="audio"
enabled
/>,
{ requestDeviceNames }, { requestDeviceNames },
); );
@@ -157,7 +167,6 @@ describe("MediaMuteAndSwitchButton", () => {
renderComponent( renderComponent(
<> <>
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled enabled
options={[ options={[
@@ -167,7 +176,6 @@ describe("MediaMuteAndSwitchButton", () => {
selectedOption="mic1" selectedOption="mic1"
/> />
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="video" iconsAndLabels="video"
enabled enabled
options={[ options={[
@@ -193,7 +201,6 @@ describe("MediaMuteAndSwitchButton", () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -217,7 +224,6 @@ describe("MediaMuteAndSwitchButton", () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -246,7 +252,6 @@ describe("MediaMuteAndSwitchButton", () => {
const [selectedOption, setSelectedOption] = useState("mic1"); const [selectedOption, setSelectedOption] = useState("mic1");
return ( return (
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -306,7 +311,6 @@ describe("MediaMuteAndSwitchButton", () => {
const onVideoBlurToggle = vi.fn(); const onVideoBlurToggle = vi.fn();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="video" iconsAndLabels="video"
enabled={true} enabled={true}
videoBlurToggleClick={onVideoBlurToggle} videoBlurToggleClick={onVideoBlurToggle}
@@ -327,11 +331,10 @@ describe("MediaMuteAndSwitchButton", () => {
expect(onVideoBlurToggle).toHaveBeenCalled(); expect(onVideoBlurToggle).toHaveBeenCalled();
}); });
test("renders check icon to mark the selected menu item", async () => { test("marks the selected menu item as checked", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
options={[ options={[
@@ -342,19 +345,493 @@ describe("MediaMuteAndSwitchButton", () => {
/>, />,
); );
// open menu
await user.click(getByRole("button", { name: "Microphone" })); await user.click(getByRole("button", { name: "Microphone" }));
// The selected item (mic2) renders both an IconOptions SVG and a CheckIcon SVG screen.getByRole("menuitemradio", { name: "Microphone 2", checked: true });
const mic1Item = screen.getByRole("menuitemradio", { screen.getByRole("menuitemradio", { name: "Microphone 1", checked: false });
name: "Microphone 2", });
});
expect(mic1Item.querySelectorAll("svg").length).toBe(2);
// The unselected item (mic1) only renders its IconOptions SVG test("disables every device while a selection is settling", async () => {
const mic2Item = screen.getByRole("menuitemradio", { const user = userEvent.setup();
const { promise, resolve } = Promise.withResolvers<void>();
function Wrapper(): JSX.Element {
const [selectedOption, setSelectedOption] = useState("mic1");
return (
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption={selectedOption}
onSelect={(id) => {
void promise.then(() => setSelectedOption(id));
}}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={vi.fn()}
/>
);
}
const { getByRole } = renderComponent(<Wrapper />);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
);
// In flight: nothing else can be picked, in either section.
for (const name of ["Microphone 1", "Speakers", "Headset"]) {
expect(screen.getByRole("menuitemradio", { name })).toHaveAttribute(
"aria-disabled",
"true",
);
}
await act(async () => {
resolve();
await promise;
});
// Settled: selectable again.
expect(
screen.getByRole("menuitemradio", { name: "Microphone 1" }),
).not.toHaveAttribute("aria-disabled", "true");
expect(
screen.getByRole("menuitemradio", { name: "Headset" }),
).not.toHaveAttribute("aria-disabled", "true");
});
test("lets go of a device switch that never arrives", async () => {
const user = userEvent.setup();
// onSelect never reports back, as when a device is removed mid-switch.
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
);
await user.keyboard("{Escape}");
await user.click(getByRole("button", { name: "Microphone" }));
for (const name of ["Microphone 1", "Speakers", "Headset"]) {
expect(screen.getByRole("menuitemradio", { name })).not.toHaveAttribute(
"aria-disabled",
"true",
);
}
});
test("lets go of a device switch whose device is unplugged", async () => {
const user = userEvent.setup();
const mics: MenuOptions[] = [
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
];
const menu = (options: MenuOptions[]): JSX.Element => (
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={options}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={vi.fn()}
/>
);
const { getByRole, rerender } = renderComponent(menu(mics));
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
);
// The second microphone is unplugged before the switch lands.
rerender(withProviders(menu(mics.slice(0, 1))));
// Selectable again without closing the menu.
expect(
screen.getByRole("menuitemradio", { name: "Headset" }),
).not.toHaveAttribute("aria-disabled", "true");
});
test("moves the level without re-rendering anything", async () => {
// The level is drawn into the DOM, so a moving level commits nothing.
const capture = stubAudioCapture();
const user = userEvent.setup();
let commits = 0;
const { getByRole } = renderComponent(
<Profiler
id="menu"
onRender={(): void => {
commits++;
}}
>
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={vi.fn()}
/>
</Profiler>,
);
await user.click(getByRole("button", { name: "Microphone" }));
capture.grant();
await vi.waitFor(() => expect(capture.contexts).toHaveLength(1));
const meter = await screen.findByRole("meter");
// The capture's arrival is one render: the idle level swapped for its own.
await act(async () => {});
const settled = commits;
// The meter smooths by elapsed time, so hand-driven frames need a clock.
let elapsed = performance.now();
const clock = vi
.spyOn(performance, "now")
.mockImplementation(() => (elapsed += 16));
// One frame per task, as a browser delivers them: one act() would batch them.
for (let step = 1; step <= 8; step++) {
capture.speak(step / 8);
await act(async () => {
capture.drawFrames(1);
await Promise.resolve();
});
}
clock.mockRestore();
expect(meter.getAttribute("aria-valuenow")).not.toBe("0");
expect(commits - settled).toBe(0);
});
test("camera menu uses the same selection pattern and keeps the blur toggle", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
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" }));
screen.getByRole("menuitemradio", { name: "Camera 1", checked: true });
screen.getByRole("menuitemradio", { name: "Camera 2", checked: false });
expect(
screen.getByRole("menuitemcheckbox", { name: "Blur background" }),
).toBeInTheDocument();
});
test("marks focus as keyboard-driven only when the keyboard moved it", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
const list = screen
.getByRole("menuitemradio", { name: "Microphone 1" })
.closest("[data-focus-source]");
// The menu focuses whatever the pointer is over, so focus alone says nothing.
expect(list).toHaveAttribute("data-focus-source", "pointer");
await user.keyboard("{ArrowDown}");
expect(list).toHaveAttribute("data-focus-source", "keyboard");
await user.pointer({
target: screen.getByRole("menuitemradio", { name: "Microphone 2" }),
coords: { clientX: 10, clientY: 10 },
});
expect(list).toHaveAttribute("data-focus-source", "pointer");
});
test("marks the selected device with the accent fill", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic2"
onSelect={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
const selected = screen
.getByRole("menuitemradio", { name: "Microphone 2" })
.querySelector("input[type=radio]");
expect(selected).toBeChecked();
// readOnly would paint the selected radio muted.
expect(selected).not.toHaveAttribute("readonly");
});
test("the open menu has no accessibility violations", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
const menu = document.querySelector('[role="menu"]');
expect(await axe(menu as HTMLElement)).toHaveNoViolations();
});
test("puts the speaker section above the microphone section", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
const speakers = screen.getByRole("menuitemradio", { name: "Speakers" });
const microphone = screen.getByRole("menuitemradio", {
name: "Microphone 1", name: "Microphone 1",
}); });
expect(mic2Item.querySelectorAll("svg").length).toBe(1); expect(
speakers.compareDocumentPosition(microphone) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
test("lists speaker and microphone sections", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{
label: { type: "default", name: "Built-in Output" },
id: "default",
},
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="default"
onSelectOutput={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
screen.getByRole("menuitemradio", {
name: "Default (Built-in Output)",
checked: true,
});
screen.getByRole("menuitemradio", { name: "Headset", checked: false });
screen.getByRole("menuitemradio", { name: "Microphone 1", checked: true });
screen.getByRole("menuitemradio", { name: "Microphone 2", checked: false });
});
test("calls the output select callback on speaker click", async () => {
const user = userEvent.setup();
const onSelectOutput = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={onSelectOutput}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(screen.getByRole("menuitemradio", { name: "Headset" }));
expect(onSelectOutput).toHaveBeenCalledWith("spk2");
});
test("shows a single device entry disabled", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
]}
selectedOption="mic1"
onSelect={onSelect}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
// Shown, but not selectable.
const only = screen.getByRole("menuitemradio", { name: "Microphone 1" });
expect(only).toHaveAttribute("aria-disabled", "true");
});
test("shows a default speaker where the platform lists none", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
// As Safari: no outputs listed.
outputOptions={[]}
selectedOutputOption={undefined}
onSelectOutput={undefined}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
const speakers = screen
.getAllByRole("group")
.find((group) => group.getAttribute("aria-label") === "Speaker")!;
const entries = within(speakers).getAllByRole("menuitemradio");
expect(entries).toHaveLength(1);
expect(entries[0]).toHaveAccessibleName("Default");
expect(entries[0]).toHaveAttribute("aria-disabled", "true");
expect(entries[0]).toHaveAttribute("aria-checked", "true");
expect(
within(entries[0]).getByRole("radio", { hidden: true }),
).toBeChecked();
});
test("shows the speaker section disabled when output selection is unsupported", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={undefined}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
expect(
screen.getByRole("menuitemradio", { name: "Speakers" }),
).toHaveAttribute("aria-disabled", "true");
expect(
screen.getByRole("menuitemradio", { name: "Headset" }),
).toHaveAttribute("aria-disabled", "true");
expect(
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
).not.toHaveAttribute("aria-disabled", "true");
}); });
}); });
+304 -106
View File
@@ -5,37 +5,48 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type ComponentType, useState, type FC, useEffect } from "react"; import {
useCallback,
useState,
type CSSProperties,
type FC,
useEffect,
type ReactElement,
} from "react";
import { import {
Button, Button,
Menu, Menu,
MenuItem, MenuItem,
MenuTitle,
RadioInput,
ToggleMenuItem, ToggleMenuItem,
} from "@vector-im/compound-web"; } from "@vector-im/compound-web";
import { import {
CheckIcon,
ChevronUpIcon, ChevronUpIcon,
ChevronDownIcon, ChevronDownIcon,
MicOnIcon,
SpinnerIcon, SpinnerIcon,
VideoCallIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons"; } from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { distinctUntilChanged, map } from "rxjs";
import styles from "./MediaMuteAndSwitchButton.module.css"; import styles from "./MediaMuteAndSwitchButton.module.css";
import { MicButton, VideoButton } from "../button"; import { MicButton, VideoButton } from "../button";
import { type DeviceLabel } from "../state/MediaDevices"; import {
type AudioOutputDeviceLabel,
type DeviceLabel,
} from "../state/MediaDevices";
import { useMediaDevices } from "../MediaDevicesContext"; import { useMediaDevices } from "../MediaDevicesContext";
import { useRootElement } from "../RootElementContext";
import { observeElementSize$ } from "../utils/elementSize";
import { LiveMicrophoneLevelMeter } from "./MicrophoneLevelMeter";
export interface MenuOptions { export interface MenuOptions {
label: DeviceLabel; label: DeviceLabel | AudioOutputDeviceLabel;
id: string; id: string;
} }
export interface MediaMuteAndSwitchButtonProps { export interface MediaMuteAndSwitchButtonProps {
/** The title used in the Switcher modal. */
title: string;
/** If the Mute button is enabled */ /** If the Mute button is enabled */
enabled?: boolean; enabled?: boolean;
/** Callback if the mute button is clicked */ /** Callback if the mute button is clicked */
@@ -47,6 +58,12 @@ export interface MediaMuteAndSwitchButtonProps {
options?: MenuOptions[]; options?: MenuOptions[];
/** The option that will currently be rendered as the selected option */ /** The option that will currently be rendered as the selected option */
selectedOption?: string; selectedOption?: string;
/** Output (speaker) devices. Audio menu only. */
outputOptions?: MenuOptions[];
/** The output option currently rendered as selected */
selectedOutputOption?: string;
/** Picks an output device. Undefined disables the speaker section. */
onSelectOutput?: (id: string) => void;
videoBlurToggleClick?: () => void; videoBlurToggleClick?: () => void;
videoBlurEnabled?: boolean; videoBlurEnabled?: boolean;
/** /**
@@ -58,90 +75,240 @@ export interface MediaMuteAndSwitchButtonProps {
const BLUR_ID = "blur"; const BLUR_ID = "blur";
/** Id of the placeholder "Default" row, shown when the platform lists no outputs. */
const DEFAULT_OUTPUT_ID = "default";
/** Largest share of the call area's height the device list may take. */
const LIST_SHARE_OF_CALL = 0.6;
/** Smallest device list height in px, so a short call still shows more than one device. */
const MIN_LIST_HEIGHT = 160;
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
title,
enabled, enabled,
busy, busy,
onMuteClick, onMuteClick,
iconsAndLabels, iconsAndLabels,
options, options,
selectedOption, selectedOption,
outputOptions,
selectedOutputOption,
onSelectOutput,
videoBlurEnabled, videoBlurEnabled,
videoBlurToggleClick, videoBlurToggleClick,
onSelect, onSelect,
}) => { }) => {
const [plannedSelection, setPlannedSelection] = useState<string | null>(null); // Requested but not yet selected. Keyed by kind too, since Chrome uses
// "default" for both an input and an output.
const [plannedSelection, setPlannedSelection] = useState<{
kind: "input" | "output";
id: string;
} | null>(null);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const onOpenChange = useCallback((open: boolean): void => {
setMenuOpen(open);
// Drop a request that never arrived.
if (!open) setPlannedSelection(null);
}, []);
const isBusy = busy ?? false; const isBusy = busy ?? false;
const { t } = useTranslation(); const { t } = useTranslation();
const devices = useMediaDevices(); const devices = useMediaDevices();
/**
* Records on the menu whether the keyboard or the pointer moved focus, for
* the focus ring. `:focus-visible` can't tell: Radix focuses whatever the
* pointer is over. Listened for on the menu, not the document, so a second
* Element Call on the page doesn't answer for this one.
*/
const trackFocusSource = useCallback(
(list: HTMLDivElement | null): (() => void) | undefined => {
const menu = list?.closest<HTMLElement>('[role="menu"]');
if (menu === null || menu === undefined) return;
const record = (source: "keyboard" | "pointer"): void => {
menu.dataset.focusSource = source;
};
record("pointer");
const usedKeyboard = (): void => record("keyboard");
const usedPointer = (): void => record("pointer");
menu.addEventListener("keydown", usedKeyboard, true);
menu.addEventListener("pointermove", usedPointer, true);
return (): void => {
menu.removeEventListener("keydown", usedKeyboard, true);
menu.removeEventListener("pointermove", usedPointer, true);
};
},
[],
);
// Measured on the call area: CSS can't size the portalled menu against it.
const rootElement = useRootElement();
const [listMaxHeight, setListMaxHeight] = useState<number>();
useEffect(() => {
if (!menuOpen) return;
// Followed, since a host can resize the call while the menu is open.
const subscription = observeElementSize$(rootElement)
.pipe(
map(({ height }) =>
Math.max(MIN_LIST_HEIGHT, Math.round(height * LIST_SHARE_OF_CALL)),
),
distinctUntilChanged(),
)
.subscribe(setListMaxHeight);
return (): void => subscription.unsubscribe();
}, [menuOpen, rootElement]);
// Kept clear at the list's foot, so a row reached by keyboard isn't under
// the meter.
const [meterHeight, meter] = useMeasuredHeight();
// Likewise at its head, for the sticky headings.
const [headingHeight, heading] = useMeasuredHeight();
useEffect(() => { useEffect(() => {
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
}, [menuOpen, devices]); }, [menuOpen, devices]);
let button; const MuteButton = iconsAndLabels === "audio" ? MicButton : VideoButton;
let toggles: { label: string; enabled: boolean; id: string }[] = []; const button = (
switch (iconsAndLabels) { <MuteButton
case "video": enabled={enabled ?? false}
button = ( busy={isBusy}
<VideoButton onClick={(e) => {
enabled={enabled ?? false} onMuteClick?.();
busy={isBusy} e.preventDefault();
onClick={(e) => { e.stopPropagation();
onMuteClick?.(); }}
e.preventDefault(); disabled={isBusy || onMuteClick === undefined}
e.stopPropagation(); data-testid={
}} iconsAndLabels === "audio" ? "incall_mute" : "incall_videomute"
disabled={isBusy || onMuteClick === undefined} }
data-testid="incall_videomute" />
/> );
);
if (videoBlurToggleClick !== undefined) { const toggles =
toggles = [ iconsAndLabels === "video" && videoBlurToggleClick !== undefined
? [
{ {
label: t("action.blur_background"), label: t("action.blur_background"),
enabled: videoBlurEnabled ?? false, enabled: videoBlurEnabled ?? false,
id: BLUR_ID, id: BLUR_ID,
}, },
]; ]
} : [];
break;
case "audio":
button = (
<MicButton
enabled={enabled ?? false}
busy={isBusy}
onClick={(e) => {
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
disabled={isBusy || onMuteClick === undefined}
data-testid="incall_mute"
/>
);
break;
}
let IconOptions: ComponentType<React.SVGAttributes<SVGElement>> | undefined;
let optionsButtonLabel: string; let optionsButtonLabel: string;
let menuTitle: string;
let numberedLabel: (number: number) => string; let numberedLabel: (number: number) => string;
switch (iconsAndLabels) { switch (iconsAndLabels) {
case "video": case "video":
IconOptions = VideoCallIcon;
optionsButtonLabel = t("settings.devices.camera"); optionsButtonLabel = t("settings.devices.camera");
menuTitle = t("settings.devices.camera_source");
numberedLabel = (n): string => numberedLabel = (n): string =>
t("settings.devices.camera_numbered", { n }); t("settings.devices.camera_numbered", { n });
break; break;
case "audio": case "audio":
IconOptions = MicOnIcon;
optionsButtonLabel = t("settings.devices.microphone"); optionsButtonLabel = t("settings.devices.microphone");
menuTitle = t("settings.devices.mic_source");
numberedLabel = (n): string => numberedLabel = (n): string =>
t("settings.devices.microphone_numbered", { n }); t("settings.devices.microphone_numbered", { n });
break; break;
} }
const labelText = (
label: MenuOptions["label"],
numbered: (n: number) => string,
): string => {
switch (label.type) {
case "name":
return label.name;
case "number":
return numbered(label.number);
case "default":
return label.name === null
? t("settings.devices.default")
: t("settings.devices.default_named_plain", { name: label.name });
case "speaker":
return t("settings.devices.loudspeaker");
case "earpiece":
return t("settings.devices.handset");
}
};
// Nothing can be picked while a requested device is on offer but not yet
// selected, so a second request can't overtake it.
const plannedOutput = plannedSelection?.kind === "output";
const selectedOfPlannedKind = plannedOutput
? selectedOutputOption
: selectedOption;
const offeredOfPlannedKind = plannedOutput ? outputOptions : options;
const settling =
plannedSelection !== null &&
plannedSelection.id !== selectedOfPlannedKind &&
offeredOfPlannedKind?.some(({ id }) => id === plannedSelection.id) === true;
// Safari lists no outputs: show a disabled, selected Default rather than an
// empty section.
const noOutputsListed = outputOptions?.length === 0;
const speakerOptions: MenuOptions[] | undefined = noOutputsListed
? [{ id: DEFAULT_OUTPUT_ID, label: { type: "default", name: null } }]
: outputOptions;
const selectedSpeaker = noOutputsListed
? DEFAULT_OUTPUT_ID
: selectedOutputOption;
const deviceItems = (
kind: "input" | "output",
items: MenuOptions[] | undefined,
selected: string | undefined,
select: ((id: string) => void) | undefined,
numbered: (n: number) => string,
): ReactElement[] => {
const list = items ?? [];
// Disabled rather than hidden, so the menu keeps its shape.
const disabled = select === undefined || list.length <= 1 || settling;
return list.map(({ label, id }) => (
<MenuItem
// A radio input may not sit inside a button.
as="div"
hideChevron
disabled={disabled}
label={labelText(label, numbered)}
Icon={
// Inert, not aria-hidden: aria-hidden alone leaves it focusable.
<span inert>
<RadioInput
checked={selected === id}
disabled={disabled}
// Not readOnly, which mutes the selected fill. The item handles
// the click.
onChange={(): void => {}}
/>
</span>
}
onSelect={(e) => {
e.preventDefault();
if (id === selected) return;
setPlannedSelection({ kind, id });
select?.(id);
}}
key={id}
role="menuitemradio"
aria-checked={selected === id}
>
{selected !== id &&
plannedSelection?.kind === kind &&
plannedSelection.id === id && (
<SpinnerIcon
width={24}
height={24}
className={styles.rotate}
aria-label={t("settings.devices.activating")}
/>
)}
</MenuItem>
));
};
return ( return (
<div <div
className={classNames({ className={classNames({
@@ -152,10 +319,12 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
{/* The mute button lives inside */} {/* The mute button lives inside */}
{button} {button}
<Menu <Menu
title={title} className={styles.menu}
showTitle={true} // Named for screen readers only: each section has its own heading.
title={menuTitle}
showTitle={false}
open={menuOpen} open={menuOpen}
onOpenChange={setMenuOpen} onOpenChange={onOpenChange}
side="top" side="top"
trigger={ trigger={
<Button <Button
@@ -171,67 +340,74 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
/> />
} }
> >
{options?.map(({ label, id }) => { <div
let labelText: string; ref={trackFocusSource}
switch (label.type) { // Keeps the items the menu's own children for assistive tech.
case "name": role="none"
labelText = label.name; className={styles.deviceList}
break; style={
case "number": {
labelText = numberedLabel(label.number); "--device-list-max-height":
break; listMaxHeight === undefined ? undefined : `${listMaxHeight}px`,
"--device-list-scroll-padding-end":
meterHeight === undefined ? undefined : `${meterHeight}px`,
"--device-list-scroll-padding-start":
headingHeight === undefined ? undefined : `${headingHeight}px`,
} as CSSProperties
} }
return ( >
<MenuItem {iconsAndLabels === "audio" && speakerOptions && (
hideChevron <>
label={labelText} {/* A menu may only contain items, separators and groups, so each
Icon={ heading is a hidden part of a named group. */}
IconOptions && ( <div role="group" aria-label={t("settings.devices.speaker")}>
<IconOptions <div aria-hidden className={styles.sectionHeading}>
width={24} <MenuTitle title={t("settings.devices.speaker")} />
height={24} </div>
className={styles.itemIcon} {deviceItems(
aria-hidden "output",
/> speakerOptions,
) selectedSpeaker,
} onSelectOutput,
onSelect={(e) => { (n) => t("settings.devices.speaker_numbered", { n }),
e.preventDefault(); )}
if (id === selectedOption) return; </div>
setPlannedSelection(id); </>
onSelect?.(id); )}
}} <div role="group" aria-label={optionsButtonLabel}>
key={id} <div ref={heading} aria-hidden className={styles.sectionHeading}>
role="menuitemradio" <MenuTitle title={optionsButtonLabel} />
aria-checked={selectedOption === id} </div>
> {/* Apart from the heading, so the sticky meter can't ride over it. */}
{selectedOption === id && ( <div role="none">
<CheckIcon {deviceItems(
width={24} "input",
height={24} options,
aria-hidden // A label would be redundant to aria-checked above selectedOption,
onSelect,
numberedLabel,
)}
{iconsAndLabels === "audio" && (
<LiveMicrophoneLevelMeter
ref={meter}
deviceId={selectedOption}
// Only while open, so the microphone isn't held all call.
active={menuOpen}
className={styles.stickyMeter}
/> />
)} )}
{selectedOption !== id && plannedSelection === id && ( </div>
<SpinnerIcon </div>
width={24} </div>
height={24} {toggles.length > 0 && <hr />}
className={styles.rotate} {toggles.map((toggle) => (
aria-label={t("settings.devices.activating")}
/>
)}
</MenuItem>
);
})}
{(toggles?.length ?? 0) > 0 && <hr />}
{toggles?.map((toggle) => (
<ToggleMenuItem <ToggleMenuItem
label={toggle.label} label={toggle.label}
onSelect={(e) => { onSelect={(e) => {
videoBlurToggleClick?.(); videoBlurToggleClick?.();
e.preventDefault(); e.preventDefault();
}} }}
checked={toggle.enabled ?? false} checked={toggle.enabled}
key={toggle.id} key={toggle.id}
/> />
))} ))}
@@ -239,3 +415,25 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
</div> </div>
); );
}; };
/** Follows an element's height. */
function useMeasuredHeight(): [
number | undefined,
(element: HTMLElement | null) => (() => void) | undefined,
] {
const [height, setHeight] = useState<number>();
const ref = useCallback(
(element: HTMLElement | null): (() => void) | undefined => {
if (element === null) return;
const subscription = observeElementSize$(element)
.pipe(
map((size) => size.height),
distinctUntilChanged(),
)
.subscribe(setHeight);
return (): void => subscription.unsubscribe();
},
[],
);
return [height, ref];
}
@@ -0,0 +1,65 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
.meter {
/* A device row's icon column, so the bars start where the names do. */
--meter-icon-size: 24px;
/* Compound's radio size, whose centre line the icon shares. */
--device-control-size: 20px;
display: flex;
align-items: center;
gap: var(--cpd-space-3x);
padding-block: var(--cpd-space-2x);
/* Centres the icon on the radio column: less the row's inset border and half
the icon's extra width. Checked in the MeterAlignsWithTheDeviceRows story. */
padding-inline-start: calc(
var(--cpd-space-4x) - var(--cpd-border-width-1) -
(var(--meter-icon-size) - var(--device-control-size)) / 2
);
padding-inline-end: calc(var(--cpd-space-4x) * 2 + var(--cpd-space-2x));
}
.icon {
color: var(--cpd-color-icon-secondary);
flex-shrink: 0;
inline-size: var(--meter-icon-size);
block-size: var(--meter-icon-size);
}
.segments {
/* Bars keep one size and the width decides how many; the component reads the
sizes back from here. */
flex: 1;
/* So the bars never set the menu's width. */
contain: inline-size;
min-inline-size: 0;
display: flex;
align-items: center;
/* Half again a bar's width, per the design. */
gap: var(--cpd-space-1-5x);
}
.segment {
flex: none;
inline-size: var(--cpd-space-1x);
block-size: var(--cpd-space-4x);
border-radius: var(--cpd-space-1x);
background: var(--cpd-color-bg-subtle-primary);
}
/* The count of lit bars carries the level as well as their colour. */
.segmentLit {
background: var(--cpd-color-bg-accent-rest);
}
.message {
color: var(--cpd-color-text-secondary);
/* A paragraph's margin would lift the text off centre. */
margin-block: 0;
}
@@ -0,0 +1,161 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, within } from "storybook/test";
import { type JSX } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
MicrophoneLevelMeter,
type MicrophoneLevelMeterProps,
} from "./MicrophoneLevelMeter";
import styles from "./MicrophoneLevelMeter.module.css";
import { LEVEL_SCALE } from "../state/MicrophoneLevel";
import { constant } from "../state/Behavior";
/** Roughly the menu's width. It only decides how many bars fit. */
const STORY_WIDTH = 256;
const meta = {
component: MicrophoneLevelMeter,
decorators: [
(Story): JSX.Element => (
<div style={{ inlineSize: STORY_WIDTH }}>
<Story />
</div>
),
],
argTypes: {
state: {
description:
"What the selected microphone can say about itself: a level, or a reason there is none.",
},
},
} satisfies Meta<typeof MicrophoneLevelMeter>;
export default meta;
type Story = StoryObj<typeof meta>;
/** A quiet room: hiss below the noise floor lights nothing. */
export const Silent: Story = {
args: { state: { type: "level", level$: constant(0) } },
play: async ({ canvasElement }) => {
await expect(litSegments(canvasElement)).toBe(0);
},
};
export const QuietSpeech: Story = {
args: { state: { type: "level", level$: constant(5) } },
};
export const NormalSpeech: Story = {
args: { state: { type: "level", level$: constant(12) } },
play: async ({ canvasElement }) => {
// A floor, not a count: the count follows from the design's bar and gap sizes.
await expect(
canvasElement.getElementsByClassName(styles.segment).length,
).toBeGreaterThanOrEqual(15);
},
};
export const LoudSpeech: Story = {
args: { state: { type: "level", level$: constant(LEVEL_SCALE) } },
};
/** The three volumes differ in how many bars are lit, not only in colour. */
export const VolumesAreDistinguishable: Story = {
args: { state: { type: "level", level$: constant(5) } },
play: async ({ canvasElement, mount }) => {
const lit: number[] = [];
for (const level of [5, 12, LEVEL_SCALE]) {
await mount(
<MicrophoneLevelMeter
state={{ type: "level", level$: constant(level) }}
/>,
);
lit.push(litSegments(canvasElement));
await expect(within(canvasElement).getByRole("meter")).toHaveAttribute(
"aria-valuenow",
String(level),
);
}
await expect(new Set(lit).size).toBe(lit.length);
await expect(lit).toEqual([...lit].sort((a, b) => a - b));
},
};
/** Permission refused: a message with a next action, not a still meter. */
export const PermissionDenied: Story = {
args: { state: { type: "permission-denied" } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByRole("meter")).toBeNull();
await expect(
canvas.getByText(/Microphone access is blocked/),
).toBeVisible();
},
};
/** No input device, told apart from a refusal. */
export const NoDevice: Story = {
args: { state: { type: "no-device" } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByRole("meter")).toBeNull();
await expect(canvas.getByText(/No microphone found/)).toBeVisible();
// The icon sits on the middle of the text, however many lines it runs to.
const middle = (element: Element): number => {
const box = element.getBoundingClientRect();
return box.top + box.height / 2;
};
const icon = canvasElement.getElementsByClassName(styles.icon)[0];
const words = canvasElement.getElementsByClassName(styles.message)[0];
await expect(Math.abs(middle(icon) - middle(words))).toBeLessThanOrEqual(1);
},
};
/** The same meter at two widths: the bars keep their size and only their count changes. */
export const ShapeStaysTheSameAtAnyWidth: Story = {
args: { state: { type: "level", level$: constant(12) } },
play: async ({ mount, args }) => {
const narrow = await measureAt(mount, args, 180);
const wide = await measureAt(mount, args, 400);
await expect(narrow.bar).toBe(wide.bar);
await expect(narrow.gap).toBe(wide.gap);
await expect(narrow.count).toBeLessThan(wide.count);
// The bars are still separate, not one run of colour.
await expect(narrow.gap).toBeGreaterThan(0);
},
};
/** How many bars are lit. */
function litSegments(canvasElement: HTMLElement): number {
return canvasElement.getElementsByClassName(styles.segmentLit).length;
}
/** Renders the meter at one width and reports its bars' shape. */
async function measureAt(
mount: (ui: JSX.Element) => Promise<unknown>,
args: MicrophoneLevelMeterProps,
width: number,
): Promise<{ count: number; bar: number; gap: number }> {
await mount(
<div style={{ inlineSize: width }}>
<MicrophoneLevelMeter {...args} />
</div>,
);
const bars = Array.from(document.body.getElementsByClassName(styles.segment));
const first = bars[0].getBoundingClientRect();
const second = bars[1].getBoundingClientRect();
return {
count: bars.length,
bar: first.width,
gap: second.left - first.right,
};
}
@@ -0,0 +1,42 @@
/*
Copyright 2026 Element Creations Ltd.
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 } from "vitest";
import { render, screen } from "@testing-library/react";
import { MicrophoneLevelMeter } from "./MicrophoneLevelMeter";
import { LEVEL_SCALE } from "../state/MicrophoneLevel";
import { constant } from "../state/Behavior";
describe("MicrophoneLevelMeter", () => {
test("announces the level rather than relying on hue", () => {
render(
<MicrophoneLevelMeter state={{ type: "level", level$: constant(6) }} />,
);
const meter = screen.getByRole("meter", { name: "Microphone level" });
expect(meter).toHaveAttribute("aria-valuenow", "6");
expect(meter).toHaveAttribute("aria-valuemax", String(LEVEL_SCALE));
expect(meter).toHaveAttribute("aria-valuetext", `6 of ${LEVEL_SCALE}`);
});
test("shows distinct messages for denied permission and no input device", () => {
const denied = render(
<MicrophoneLevelMeter state={{ type: "permission-denied" }} />,
);
expect(
denied.getByText(/Microphone access is blocked/),
).toBeInTheDocument();
expect(denied.queryByRole("meter")).toBeNull();
const missing = render(
<MicrophoneLevelMeter state={{ type: "no-device" }} />,
);
expect(missing.getByText(/No microphone found/)).toBeInTheDocument();
expect(missing.queryByRole("meter")).toBeNull();
});
});
+146
View File
@@ -0,0 +1,146 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
useCallback,
useLayoutEffect,
useRef,
useState,
type FC,
type Ref,
} from "react";
import { Text } from "@vector-im/compound-web";
import { MicOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { distinctUntilChanged, map } from "rxjs";
import styles from "./MicrophoneLevelMeter.module.css";
import { LEVEL_SCALE, type MicrophoneState } from "../state/MicrophoneLevel";
import { observeElementSize$ } from "../utils/elementSize";
import { useMicrophoneLevel } from "./useMicrophoneLevel";
export interface LiveMicrophoneLevelMeterProps {
/** The microphone to listen to. */
deviceId: string | undefined;
/** Whether to hold a capture at all. */
active: boolean;
className?: string;
ref?: Ref<HTMLDivElement>;
}
/**
* The meter, wired to a live capture. Its own component so a changing level
* re-renders the meter, not its parent.
*/
export const LiveMicrophoneLevelMeter: FC<LiveMicrophoneLevelMeterProps> = ({
deviceId,
active,
className,
ref,
}) => {
const state = useMicrophoneLevel(deviceId, active);
return <MicrophoneLevelMeter state={state} className={className} ref={ref} />;
};
export interface MicrophoneLevelMeterProps {
state: MicrophoneState;
className?: string;
ref?: Ref<HTMLDivElement>;
}
/** The input level of a microphone, or why there is none. */
export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
state,
className,
ref,
}) => {
const { t } = useTranslation();
// Bars keep one size, so their count follows the width. Starts full so the
// first paint, and jsdom, draw a whole meter.
const [barCount, setBarCount] = useState(LEVEL_SCALE);
const segments = useRef<HTMLDivElement | null>(null);
const track = useCallback(
(element: HTMLDivElement | null): (() => void) | undefined => {
if (element === null) return;
segments.current = element;
const subscription = observeElementSize$(element)
.pipe(
map(({ width }) => barsThatFit(element, width)),
distinctUntilChanged(),
)
.subscribe(setBarCount);
return (): void => {
subscription.unsubscribe();
segments.current = null;
};
},
[],
);
// Drawn straight into the DOM: the level changes many times a second, and
// re-rendering for each change is what this avoids.
useLayoutEffect(() => {
const element = segments.current;
if (state.type !== "level" || element === null) return;
const subscription = state.level$.subscribe((level) => {
element.setAttribute("aria-valuenow", String(level));
element.setAttribute(
"aria-valuetext",
t("microphone_level.value", { level, max: LEVEL_SCALE }),
);
// The level is a share of the scale, not a bar count.
const lit = Math.round((level / LEVEL_SCALE) * barCount);
Array.from(element.children).forEach((bar, i) =>
bar.classList.toggle(styles.segmentLit, i < lit),
);
});
return (): void => subscription.unsubscribe();
}, [state, barCount, t]);
return (
<div ref={ref} className={classNames(styles.meter, className)}>
<MicOnIcon className={styles.icon} aria-hidden />
{state.type !== "level" ? (
<Text size="sm" className={styles.message}>
{state.type === "permission-denied"
? t("microphone_level.permission_denied")
: t("microphone_level.no_device")}
</Text>
) : (
<div
ref={track}
className={styles.segments}
role="meter"
aria-label={t("microphone_level.label")}
aria-valuemin={0}
aria-valuemax={LEVEL_SCALE}
// The first paint's value; the effect above keeps it current.
aria-valuenow={state.level$.value}
aria-valuetext={t("microphone_level.value", {
level: state.level$.value,
max: LEVEL_SCALE,
})}
>
{Array.from({ length: barCount }, (_, i) => (
<span key={i} aria-hidden className={styles.segment} />
))}
</div>
)}
</div>
);
};
/** How many bars fit across `width`, measured so the stylesheet alone sets their size. */
function barsThatFit(track: HTMLElement, width: number): number {
const gap = Number.parseFloat(getComputedStyle(track).columnGap);
const bar = track.firstElementChild?.getBoundingClientRect().width ?? 0;
// No layout (jsdom): keep the full count.
if (!(bar > 0) || !(gap >= 0)) return LEVEL_SCALE;
return Math.max(1, Math.floor((width + gap) / (bar + gap)));
}
@@ -0,0 +1,41 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, describe, expect, test } from "vitest";
import { renderHook } from "@testing-library/react";
import { useMicrophoneLevel } from "./useMicrophoneLevel";
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
// Capture and release are covered in MicrophoneLevel.test.ts; this covers the bridge.
describe("useMicrophoneLevel", () => {
afterEach(restoreAudioCapture);
test("holds no capture while the meter is not shown", () => {
const capture = stubAudioCapture();
renderHook(() => useMicrophoneLevel("mic1", false));
expect(capture.getUserMedia).not.toHaveBeenCalled();
});
test("starts from nothing rather than the previous device's level", () => {
const capture = stubAudioCapture();
const { result, rerender } = renderHook(
({ id }: { id: string }) => useMicrophoneLevel(id, true),
{ initialProps: { id: "mic1" } },
);
capture.grant();
rerender({ id: "mic2" });
// No level carried over from the previous device.
const state = result.current;
expect(state.type === "level" ? state.level$.value : state.type).toBe(0);
});
});
+34
View File
@@ -0,0 +1,34 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useEffect, useState } from "react";
import {
type MicrophoneState,
observeMicrophoneState$,
} from "../state/MicrophoneLevel";
import { constant } from "../state/Behavior";
const IDLE: MicrophoneState = { type: "level", level$: constant(0) };
/** The live level of a microphone, captured only while `active`. */
export function useMicrophoneLevel(
deviceId: string | undefined,
active: boolean,
): MicrophoneState {
const [state, setState] = useState<MicrophoneState>(IDLE);
useEffect(() => {
if (!active) return;
// Idle first, so a new device doesn't start from the previous level.
setState(IDLE);
const subscription = observeMicrophoneState$(deviceId).subscribe(setState);
return (): void => subscription.unsubscribe();
}, [deviceId, active]);
return state;
}
+40 -4
View File
@@ -5,8 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { of } from "rxjs";
import { LeaveToHomeProvider } from "../LeaveToHomeContext"; import { LeaveToHomeProvider } from "../LeaveToHomeContext";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
@@ -18,7 +20,13 @@ import {
import { LobbyView } from "./LobbyView"; import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { mockMediaDevices, mockMuteStates } from "../utils/test"; import {
mockMediaDevices,
mockMuteStates,
restoreAudioCapture,
stubAudioCapture,
} from "../utils/test";
import { type MediaDevices } from "../state/MediaDevices";
import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevicesContext } from "../MediaDevicesContext";
import { type ProcessorState } from "../livekit/TrackProcessorContext"; import { type ProcessorState } from "../livekit/TrackProcessorContext";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
@@ -77,9 +85,10 @@ function renderLobbyView(
props: Partial<Parameters<typeof LobbyView>[0]> = {}, props: Partial<Parameters<typeof LobbyView>[0]> = {},
withAppBar = false, withAppBar = false,
platform = "android", platform = "android",
devices: Partial<MediaDevices> = {},
): ReturnType<typeof render> { ): ReturnType<typeof render> {
platformMock.mockReturnValue(platform); platformMock.mockReturnValue(platform);
const mediaDevices = mockMediaDevices({}); const mediaDevices = mockMediaDevices(devices);
const muteStates = mockMuteStates(); const muteStates = mockMuteStates();
const hideHeader = withAppBar ? true : false; const hideHeader = withAppBar ? true : false;
const lobbyView = ( const lobbyView = (
@@ -178,3 +187,30 @@ describe("LobbyView", () => {
expect(await axe(container)).toHaveNoViolations(); expect(await axe(container)).toHaveNoViolations();
}); });
}); });
describe("LobbyView microphone level", () => {
afterEach(restoreAudioCapture);
it("shows the microphone level meter", async () => {
const capture = stubAudioCapture();
capture.grant();
const user = userEvent.setup();
const { getByRole } = renderLobbyView({}, false, "desktop", {
requestDeviceNames: (): void => {},
audioInput: {
available$: of(
new Map([["mic1", { type: "name", name: "Microphone 1" }]]),
),
selected$: of({ id: "mic1" }),
select: (): void => {},
},
} as unknown as Partial<MediaDevices>);
// Pre-join reaches the meter through the same chevron as a call.
await user.click(getByRole("button", { name: "Microphone" }));
expect(
await screen.findByRole("meter", { name: "Microphone level" }),
).toBeInTheDocument();
});
});
+139 -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. Please see LICENSE in the repository root for full details.
*/ */
import { describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { of } from "rxjs";
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop")); const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({ vi.mock("../Platform", () => ({
@@ -15,9 +14,22 @@ vi.mock("../Platform", () => ({
}, },
isFirefox: (): boolean => false, isFirefox: (): boolean => false,
})); }));
vi.mock("@livekit/components-core", () => ({ const observers = vi.hoisted(
createMediaDeviceObserver: () => of([]), () => 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 { AudioOutput, MediaDevices } from "./MediaDevices";
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput"; import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput";
@@ -57,3 +69,125 @@ describe("MediaDevices audio output", () => {
expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput); expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput);
}); });
}); });
function device(deviceId: string, label: string, groupId = deviceId): object {
return { deviceId, label, groupId, kind: "audioinput" };
}
/** Replaces the devices of one kind, as the browser reports them. */
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 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"]);
setDevices("audioinput", [
device("mic1", "Microphone 1"),
device("mic2", "Headset"),
]);
expect([...devices.audioInput.available$.value.keys()]).toEqual([
"mic1",
"mic2",
]);
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");
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")]);
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();
// Names are withheld until permission is 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 is its own entry, unnamed: which device it resolves to isn't knowable.
expect(available.get("spk1")).toEqual({ type: "name", name: "Speakers" });
expect(available.get("")).toEqual({ type: "default", name: null });
});
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");
});
});
+183
View File
@@ -0,0 +1,183 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, describe, expect, test, vi } from "vitest";
import {
ATTACK_MS,
LEVEL_SCALE,
observeMicrophoneState$,
RELEASE_MS,
segmentsForVolume,
smoothVolume,
} from "./MicrophoneLevel";
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
describe("segmentsForVolume", () => {
test("shows nothing for silence", () => {
expect(segmentsForVolume(0)).toBe(0);
});
test("shows nothing for the hiss of a quiet room", () => {
expect(segmentsForVolume(0.005)).toBe(0);
expect(segmentsForVolume(0.015)).toBe(0);
});
test("distinguishes quiet, normal and loud speech", () => {
const quiet = segmentsForVolume(0.06);
const normal = segmentsForVolume(0.2);
const loud = segmentsForVolume(0.8);
expect(quiet).toBeGreaterThan(0);
expect(normal).toBeGreaterThan(quiet);
expect(loud).toBeGreaterThan(normal);
});
test("moves the meter visibly for normal speech", () => {
// Ordinary speech reaches the middle of the meter.
expect(segmentsForVolume(0.2)).toBeGreaterThanOrEqual(LEVEL_SCALE / 4);
});
test("never exceeds the meter", () => {
expect(segmentsForVolume(1)).toBe(LEVEL_SCALE);
expect(segmentsForVolume(4)).toBe(LEVEL_SCALE);
});
test("treats a missing reading as silence", () => {
expect(segmentsForVolume(NaN)).toBe(0);
expect(segmentsForVolume(-1)).toBe(0);
});
});
describe("smoothVolume", () => {
test("rises faster than it falls", () => {
const rise = smoothVolume(0, 1, 50);
const fall = 1 - smoothVolume(1, 0, 50);
expect(rise).toBeGreaterThan(fall);
});
test("registers a syllable as it starts", () => {
// Most of the way within one attack time constant.
expect(smoothVolume(0, 1, ATTACK_MS)).toBeGreaterThan(0.6);
});
test("rides over the gaps between words", () => {
// A short pause doesn't collapse the meter...
expect(smoothVolume(1, 0, 30)).toBeGreaterThan(0.7);
// ...but a real silence brings it down.
expect(smoothVolume(1, 0, RELEASE_MS * 3)).toBeLessThan(0.1);
});
test("behaves the same whatever the frame rate", () => {
const oneStep = smoothVolume(0, 1, 32);
let twoSteps = smoothVolume(0, 1, 16);
twoSteps = smoothVolume(twoSteps, 1, 16);
expect(twoSteps).toBeCloseTo(oneStep, 5);
});
test("holds still when no time has passed", () => {
expect(smoothVolume(0.5, 1, 0)).toBe(0.5);
});
});
describe("observeMicrophoneState$", () => {
afterEach(restoreAudioCapture);
test("releases a capture the browser grants after nobody is watching", async () => {
const capture = stubAudioCapture();
const subscription = observeMicrophoneState$("mic1").subscribe();
// The menu closes before the browser hands the microphone over.
subscription.unsubscribe();
capture.grant();
await vi.waitFor(() => expect(capture.track.stop).toHaveBeenCalled());
});
test("releases the capture and the audio context when the subscription ends", async () => {
const capture = stubAudioCapture();
const subscription = observeMicrophoneState$("mic1").subscribe();
capture.grant();
await vi.waitFor(() => expect(capture.contexts).toHaveLength(1));
subscription.unsubscribe();
expect(capture.track.stop).toHaveBeenCalled();
expect(capture.contexts[0].close).toHaveBeenCalled();
});
test("gives the microphone back when the audio graph fails to build", async () => {
const capture = stubAudioCapture();
// Fails only after getUserMedia has granted the device.
vi.stubGlobal(
"AudioContext",
class {
public constructor() {
throw new Error("no audio context for you");
}
},
);
const seen: string[] = [];
const subscription = observeMicrophoneState$("mic1").subscribe((state) =>
seen.push(state.type),
);
capture.grant();
await vi.waitFor(() => expect(seen).toContain("no-device"));
expect(capture.track.stop).toHaveBeenCalled();
subscription.unsubscribe();
});
test("tells denied permission and a missing device apart", async () => {
for (const [name, expected] of [
["NotAllowedError", "permission-denied"],
["NotFoundError", "no-device"],
] as const) {
const capture = stubAudioCapture();
capture.getUserMedia.mockRejectedValue(named(new Error(name), name));
const seen: string[] = [];
const subscription = observeMicrophoneState$("mic1").subscribe((state) =>
seen.push(state.type),
);
await vi.waitFor(() => expect(seen).toContain(expected));
subscription.unsubscribe();
restoreAudioCapture();
}
});
test("says nothing on a frame that did not change the level", async () => {
const capture = stubAudioCapture();
const states: string[] = [];
let levels = 0;
const subscription = observeMicrophoneState$("mic1").subscribe((state) => {
states.push(state.type);
if (state.type === "level") state.level$.subscribe(() => levels++);
});
capture.grant();
await vi.waitFor(() => expect(levels).toBe(1));
// Read every frame, but a steady signal emits once, and the state itself
// arrives only once.
capture.drawFrames(20);
expect(levels).toBe(1);
expect(states).toEqual(["level"]);
subscription.unsubscribe();
});
});
/** An error carrying the browser's `name`. */
function named(error: Error, name: string): Error {
error.name = name;
return error;
}
+143
View File
@@ -0,0 +1,143 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { BehaviorSubject, Observable } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "./Behavior";
/** What the microphone picks up, or why it can't be read. */
export type MicrophoneState =
// A Behavior, so a changing level can be drawn without re-rendering.
| { type: "level"; level$: Behavior<number> }
| { type: "permission-denied" }
| { type: "no-device" };
/** Scale a level is reported on. Fixed rather than the bar count, so a level reads the same at any width. */
export const LEVEL_SCALE = 24;
/**
* The level of a microphone, captured while subscribed. Its own capture rather
* than the call's track, because pre-join freezes that track to the device
* selected at mount.
*/
export function observeMicrophoneState$(
deviceId: string | undefined,
): Observable<MicrophoneState> {
return new Observable<MicrophoneState>((subscriber) => {
let stream: MediaStream | undefined;
let context: AudioContext | undefined;
let frame: number | undefined;
let level: BehaviorSubject<number> | undefined;
// Idempotent: teardown and start can both call it.
const release = (): void => {
if (frame !== undefined) cancelAnimationFrame(frame);
stream?.getTracks().forEach((track) => track.stop());
void context?.close();
frame = undefined;
stream = undefined;
context = undefined;
level?.complete();
level = undefined;
};
const start = async (): Promise<void> => {
stream = await navigator.mediaDevices.getUserMedia({
audio:
deviceId === undefined ? true : { deviceId: { exact: deviceId } },
});
// Unsubscribed while the permission prompt was open.
if (subscriber.closed) return release();
context = new AudioContext();
// Starts suspended outside a user gesture, which would read as silence.
if (context.state === "suspended") await context.resume();
if (subscriber.closed) return release();
const analyser = context.createAnalyser();
analyser.fftSize = 1024;
context.createMediaStreamSource(stream).connect(analyser);
const samples = new Uint8Array(analyser.fftSize);
let displayed = 0;
let previousFrame = performance.now();
const current = new BehaviorSubject(0);
level = current;
subscriber.next({ type: "level", level$: current });
const read = (): void => {
analyser.getByteTimeDomainData(samples);
// RMS: perceived loudness rather than the peak.
let sum = 0;
for (const sample of samples) {
const centred = (sample - 128) / 128;
sum += centred * centred;
}
const now = performance.now();
displayed = smoothVolume(
displayed,
Math.sqrt(sum / samples.length),
now - previousFrame,
);
previousFrame = now;
// Frames that don't move the quantised level say nothing.
const next = segmentsForVolume(displayed);
if (next !== current.value) current.next(next);
frame = requestAnimationFrame(read);
};
read();
};
start().catch((e: unknown) => {
// Building the graph can fail after the device was granted.
release();
subscriber.next(stateForFailure(e));
});
return release;
});
}
/** What a failure to open the microphone means for the person using it. */
function stateForFailure(e: unknown): MicrophoneState {
const name = e instanceof Error ? e.name : "";
if (name === "NotAllowedError" || name === "SecurityError")
return { type: "permission-denied" };
if (name === "NotFoundError" || name === "OverconstrainedError")
return { type: "no-device" };
logger.error("Could not read the microphone level", e);
return { type: "no-device" };
}
/** Below this counts as silence, so a quiet room's hiss doesn't light the first bars. */
const NOISE_FLOOR = 0.02;
/** Quantises a 0..1 volume onto {@link LEVEL_SCALE}. */
export function segmentsForVolume(volume: number): number {
if (!Number.isFinite(volume) || volume <= NOISE_FLOOR) return 0;
// Square root, so ordinary speech reaches the middle of the scale.
const aboveFloor = (Math.min(volume, 1) - NOISE_FLOOR) / (1 - NOISE_FLOOR);
return Math.min(LEVEL_SCALE, Math.ceil(Math.sqrt(aboveFloor) * LEVEL_SCALE));
}
/** Rise time constant: short, so a syllable registers as it starts. */
export const ATTACK_MS = 50;
/** Fall time constant: longer, so the gaps between words don't flicker. */
export const RELEASE_MS = 120;
/** Eases towards a reading, by elapsed time so the frame rate doesn't matter. */
export function smoothVolume(
displayed: number,
reading: number,
elapsedMs: number,
): number {
if (elapsedMs <= 0) return displayed;
const timeConstant = reading > displayed ? ATTACK_MS : RELEASE_MS;
const towards = 1 - Math.exp(-elapsedMs / timeConstant);
return displayed + (reading - displayed) * towards;
}
+96
View File
@@ -8,6 +8,7 @@ import { map, type Observable, of, type SchedulerLike } from "rxjs";
import { type RunHelpers, TestScheduler } from "rxjs/testing"; import { type RunHelpers, TestScheduler } from "rxjs/testing";
import { import {
expect, expect,
type Mock,
type MockedObject, type MockedObject,
type MockInstance, type MockInstance,
onTestFinished, onTestFinished,
@@ -590,3 +591,98 @@ export class MockConnection extends Connection {
public async start(): Promise<void> {} public async start(): Promise<void> {}
public async stop(): Promise<void> {} public async stop(): Promise<void> {}
} }
export interface StubbedCapture {
getUserMedia: Mock;
/** Grants the microphone, as the browser does once permission is given. */
grant: () => void;
track: { stop: Mock };
contexts: { close: Mock }[];
/** Runs the pending animation frames, in order. */
drawFrames: (count: number) => void;
/** Sets the microphone's loudness from the next frame, 0 to 1. */
speak: (amplitude: number) => void;
}
/**
* Stubs the capture and Web Audio APIs, with the grant and the animation
* frames driven by the test. Undo with {@link restoreAudioCapture}.
*/
export function stubAudioCapture(): StubbedCapture {
const track = { stop: vi.fn() };
const contexts: { close: Mock }[] = [];
let grant = (): void => {};
const granted = new Promise<MediaStream>((resolve) => {
grant = (): void =>
resolve({ getTracks: () => [track] } as unknown as MediaStream);
});
const frames: FrameRequestCallback[] = [];
let amplitude = 0;
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
frames.push(callback);
return frames.length;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
vi.stubGlobal(
"AudioContext",
class {
public readonly state = "running";
public readonly close = vi.fn();
public constructor() {
contexts.push(this);
}
public createAnalyser(): object {
return {
fftSize: 1024,
getByteTimeDomainData: (samples: Uint8Array): void => {
// Silence is the midpoint of the range; a zeroed buffer reads as full scale.
if (amplitude <= 0) {
samples.fill(128);
return;
}
const peak = Math.round(Math.min(1, amplitude) * 127);
for (let i = 0; i < samples.length; i++)
samples[i] = 128 + (i % 2 ? peak : -peak);
},
};
}
public createMediaStreamSource(): object {
return { connect: (): void => {} };
}
},
);
// Only this property: replacing navigator loses getters like userAgent.
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getUserMedia: vi.fn().mockReturnValue(granted) },
});
return {
getUserMedia: navigator.mediaDevices.getUserMedia as unknown as Mock,
grant: () => grant(),
track,
contexts,
drawFrames: (count): void => {
for (let i = 0; i < count; i++) frames.shift()?.(i);
},
speak: (next): void => {
amplitude = next;
},
};
}
const realMediaDevices = Object.getOwnPropertyDescriptor(
navigator,
"mediaDevices",
);
/** Undoes {@link stubAudioCapture}. */
export function restoreAudioCapture(): void {
vi.unstubAllGlobals();
if (realMediaDevices === undefined) {
Reflect.deleteProperty(navigator, "mediaDevices");
} else {
Object.defineProperty(navigator, "mediaDevices", realMediaDevices);
}
}