Compare commits

...
Author SHA1 Message Date
fkwpandClaude Opus 5 5fff49670b Expect the default output entry to carry no name
- main stopped labelling the virtual default output with the first device's
  name (426acd22): a pinned sink is not re-routed, and which device the browser
  default resolves to is not knowable from here.
- The exploration still carried the older file, so this check passed locally
  and failed in CI, which tests the branch merged with main.
- AC8 is unchanged — Default is still a distinct entry beside the device it
  resolves to. Only the label it carries has moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:52:08 +02:00
fkwp 0633b24880 Merge remote-tracking branch 'origin/main' into fkwp/explore/quick-audio-menu 2026-09-17 22:50:39 +02:00
fkwpandClaude Opus 5 099950a100 Update the device menu to the latest design
- Each section is headed by its own rule, edge to edge, instead of one inset
  divider between the sections. Compound draws that rule under a menu heading
  already, so this removes our Separator, its spacing rule and its import.
- Our heading padding override goes too: Compound's menu heading is the design
  already — 13px/600 on a 19.5px line, #656D77, 16px either side, 7px from the
  text to the rule. Ours had been moving that 7px above the text.
- Two spacings do differ from Compound, both measured off the mock rather than
  guessed: 20px below the rule before a section's first device (27px rule to
  control, against Compound's 16px), and 28px above a heading that follows
  another section (41px from the last control to the next heading's text,
  against 22px). The first heading keeps Compound's spacing — the menu's own
  padding sits above it.
- Guarded by relationships, not numbers: the first control sits further below
  the rule than from the menu's edge, and a section stands further from the one
  above it than a heading does from its own first device. Both fail if either
  spacing goes back to Compound's 8px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:43:48 +02:00
fkwp 194bfea287 Cover the speaker section sitting above the microphone section
- Covers AC28, which `ux_design_shevron.png` settles.
- Not written in this session: it was uncommitted in the tree beforehand, and
  is committed here so it is not lost.
2026-09-17 11:56:31 +02:00
fkwp 4f8f71e861 Cover the device menu with stories and end-to-end specs
- The stories supply what the app supplies rather than what a harness has
  lying about: a microphone, and a call-sized root element.
- Invented device ids meant the menu asked for hardware that does not exist,
  so the meter reported no microphone. A tone played into a real MediaStream
  fixes it, and the meter runs its own analyser over it.
- Without a root element the list was bounded by Storybook's whole frame and
  the menu ran off the canvas; without a call-sized one, four devices scrolled.
- Standalone specs: the switch that does not drop the call, the meter alive
  while muted, the pinned meter, the focus ring.
- Component specs: the list sized to the call and not the window, resized
  while open, every device reachable, and the modality belonging to one call.
- A headless browser has one microphone and one speaker, so fake-devices.ts
  adds synthetic ones. It is explicit that it does not route audio.
2026-09-17 11:56:31 +02:00
fkwp d7e0444117 Write the mute button once instead of twice per kind
- Both arms of the switch were the same eleven lines of click, disabled, busy
  and enabled wiring; only the component and the test id differed.
- Two copies that had to be kept in sync by hand, for nothing.
- `toggles` becomes an expression rather than a `let` mutated in a switch,
  which makes the optional chaining downstream dead.
- The labels switch stays: i18n extraction needs literal `t("…")` keys, and it
  gives exhaustiveness checking on the union.
2026-09-17 11:56:31 +02:00
fkwp a6e00895a0 Track the focus modality on the menu itself
- Was watched at the document, so a key pressed in another Element Call on the
  same page — or in the host's own page — lit this menu's ring. Element Call
  can be mounted more than once, and the menu is portalled out of the root.
- Watched on the menu instead, which is the element that holds focus.
- A callback ref, not an effect on `menuOpen`: that state is ours and the open
  menu is Radix's, and they do not commit together, so the effect could run
  before there was anything to attach to.
- Recorded on the menu rather than in state, so every item it can focus answers
  to it — the camera menu's blur toggle kept the browser's own ring otherwise,
  which follows the pointer.
- Nothing re-renders when the modality changes now, so the reset folds in with
  the listeners and the second effect on that state goes.
- Comments moved to the code they describe; three unrelated ones had stacked up
  on one declaration.
2026-09-17 11:56:31 +02:00
fkwp 8f3506782a Keep each section's heading in view while the list scrolls
- The heading of the section you are reading stays at the top of the list and
  leaves with its own section, so a long list never leaves you guessing which
  kind of device a row is.
- Opaque and held a border width clear of the sides, like the meter: a
  positioned element paints over the outline the menu draws its frame with.
- Sticky alone hides rows: scrolling a row flush to either edge puts it under
  the heading or under the meter, which is how a row reached by keyboard ends
  up half-readable. scroll-padding-block keeps both heights clear.
- Measured from the real elements, not from tokens — the meter's failure states
  are two lines where a level is one.
2026-09-17 11:56:31 +02:00
fkwp 8508d88714 Size the device list to the call as it changes
- The bound was taken once when the menu opened, so a host resizing the space
  Element Call is drawn in while the menu is open left it describing a call
  area that no longer exists.
- Follow it with observeElementSize$, which was already here and already used
  by useRootSizeMatches, and only while the menu is open.
- Quantised before it reaches React, so a drag-resize re-renders only when the
  bound itself moves.
- 160 and 0.6 are named now, with the reason each exists.
2026-09-17 11:56:31 +02:00
fkwp ace7e947b4 Show a default speaker where the platform lists none
- Safari enumerates no output devices at all, so the list arrived empty — and
  an empty array is truthy, so the Speaker heading rendered with nothing under
  it, which reads as a broken feature.
- Show one entry instead, named as the default, disabled, and marked as the
  selection: audio is going somewhere, and an unchecked lone row reads as
  nothing being chosen.
- D2 already says a row that cannot be chosen is shown disabled, never hidden.
- Covered by its condition rather than by the browser, so Linux CI checks it:
  one test that the view model hands over an empty list rather than none, one
  that the menu then draws the default.
2026-09-17 11:56:31 +02:00
fkwp 6c3012a40b Line the level meter up with the device rows
- Measured: the icon's centre sat 3px right of the radio controls above it.
- Two causes, both off the padding: the row is inset a border width to keep
  the menu's frame visible, and the icon is wider than a radio control.
- Vertically the icon was right and the text was not — a paragraph's bottom
  margin means the margin box gets centred, lifting the words.
- Guarded in a story, not a unit test: jsdom lays nothing out.
2026-09-17 11:56:31 +02:00
fkwp 6c9550da0a Give the level meter one bar size at every width
- A bar and the gap beside it are fixed; how many bars there are follows the
  space. Spread gaps made the meter a different shape everywhere, and closed
  the bars into one block when space ran short.
- Bars that touch cannot be counted, and the count is what carries the level
  without colour.
- `contain: inline-size` and `min-inline-size: 0` so the width decides the
  count and not the reverse — without them the bars set the menu's width.
- Gap is half again the bar width, measured off the design mock: 10px bars
  14px apart, a ratio of 1.4, nearest token pair 4px and 6px.
- Sizes stay in the stylesheet; the component reads them back off a rendered
  bar rather than repeating them.
2026-09-17 11:56:31 +02:00
fkwp d8211c0c61 Own the microphone level in the state layer
- The capture, the analyser loop and the loudness maths were in a component
  hook. Call logic belongs in the state layer; components render.
- observeMicrophoneState$ opens the device when something subscribes and
  releases it when nothing does. The hook is left as a bridge, no domain logic.
- Fixes a leak. A permission prompt outlives the menu that opened it, so the
  browser could hand over a microphone after cleanup had already run — with the
  variable that cleanup would have released still unset. The device stayed
  held, indicator lit, no meter on screen.
- Expressed now as the subscriber being closed, which the observable can always
  answer.
- The hook had no tests at all, which is why this survived. Four now, and the
  capture stub they need is in the shared factories rather than hand-rolled.
- One counts emissions across twenty animation frames and expects one: the
  redraw cost AGENTS.md asks continuous drawing to account for.
2026-09-17 11:56:25 +02:00
fkwp 0802649375 Show the focus ring for the keyboard alone, and fix the radio styling
- Show the focus ring only when the keyboard moved the focus. Radix focuses
  whatever the pointer is over, so the browser cannot answer that question
  here: Chromium treats every focus after a key press as keyboard-driven,
  Firefox treats no programmatic focus as keyboard-driven. The menu records
  which modality arrived, watched at the document while it is open, since the
  first arrow key lands on the menu itself rather than on anything we render.
- Suppress the browser's own ring on menu items, so there is one answer to
  that question rather than two.
- Drop readOnly from the device radios. Compound paints a read-only control
  muted, and that rule comes after the checked rule, so it overrode the accent
  fill marking the selection and the menu stopped matching settings.
- Make the decorative radio inert rather than aria-hidden. A negative tabindex
  inside an interactive control stays reachable to assistive technology even
  when hidden, which axe rejects.
- Give each section a labelled group and mark its heading decorative. A menu
  may contain only items, separators and groups, and the headings were direct
  children of it. The list wrappers are role="none" for the same reason.
- Run axe over the open menu, which is what found both of the above.

Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC23, AC24, AC25
2026-09-16 17:44:55 +02:00
fkwp a76ef6215b more spacing between player and mic section 2026-09-16 16:27:17 +02:00
fkwp 7f3b98ed6a Size the device menu against the call, and cover its criteria
- Bound the scrolling device list by the measured height of the call area.
  It was capped with a viewport unit and a Radix variable, both of which
  measure the window: as a component in a corner of a host's page, the menu
  would have been sized against the whole page. The menu is portalled outside
  the root, so no container query reaches it and it has to be measured.
- Leave a single divider between the two lists, as the design has. Compound
  underlines every menu heading, so the headings drop theirs, and the line
  closing the microphone section goes: the section wrapper already bounds the
  meter's stickiness without one.
- Cover device persistence, hot-plug, fallback when the device in use is
  removed, fallback to a default when a remembered device is gone, numbered
  labels before permission, "Default" listed as its own entry, one device kind
  not disturbing another, and camera parity. Their acceptance criteria named
  check commands for tests that did not exist, which makes the criteria
  unenforceable and blocks the next drift check.

Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC3, AC4, AC5, AC8, AC9,
AC10, AC11, AC19, AC20
2026-09-16 16:23:02 +02:00
fkwp 3f97ec8619 Show a live microphone level in the audio menu
- Draw the level beneath the microphone list as 24 segments, announced
  through role="meter", so it reads by count and not by hue alone.
- Give denied permission and no input device their own message and next
  action, rather than a flat meter that reads as silence.
- Read the selected device, and only while the menu is open, so nothing holds
  a second capture for the length of a call.
- Follow a rise quickly and a fall slowly, in elapsed time rather than frames,
  so the meter does not chase the gaps between syllables.
- Ignore a noise floor, below which a quiet room's hiss would light the first
  segments permanently. Smoothing changes how the meter moves, not where it
  settles, so it does not replace this.
- Scroll only the device lists, and keep the meter pinned to the foot of the
  microphone section while its rows are in view.
- Withhold the output select callback where the platform cannot route audio
  to a chosen device, which is what renders the speaker section disabled.
- Track the in-flight device by kind as well as id: Chrome names both an
  input and an output "default", so the spinner lit the wrong row.
- Disable every device while a selection is settling, so a second request
  cannot overtake the first.

Two CSS choices are load-bearing and look arbitrary: the meter's wrapper is
unpositioned, because a positioned one paints above the menu's outline and
swallows the frame along that section, and the meter is held clear of that
outline, because it is the only opaque thing in the menu.

The meter opens its own short-lived capture rather than tapping the call's
audio track. That lets it follow the picker instantly and avoids the pre-join
track, which is frozen to the device selected when the screen mounted. The
cost is a second capture while the menu is open. See the notes sidecar.

Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1, AC6, AC7, AC11, AC13,
AC14, AC15, AC17, AC18, AC21, AC22
2026-09-16 15:37:05 +02:00
fkwp 9a68de193f Show a live microphone level in the audio menu
- Draw the input level beneath the microphone list as 32 discrete segments,
  so the level reads by count and not by hue alone.
- Expose it through role="meter" with aria-valuenow and aria-valuetext, so a
  screen reader announces it.
- Give denied permission and no input device their own message and next
  action, rather than a flat meter that reads as silence.
- Read the level only while the menu is open, so nothing holds a second
  capture of the microphone for the length of a call.
- Read the device the picker has selected, so the meter re-points as soon as
  the selection changes.
- Ignore anything below a noise floor: a quiet room is never digitally
  silent, and its hiss otherwise lights the first segments permanently, which
  reads as "it can hear me" when nobody is speaking.
- Quantise loudness with a square root above that floor: amplitude puts
  ordinary speech near the bottom of the range, where a linear meter barely
  moves.
- Withhold the output select callback where the platform cannot route audio
  to a chosen device, which is what renders the speaker section disabled.
  Safari and most Firefox builds land here.
- Track the in-flight device by kind as well as id: Chrome names both an
  input and an output "default", so the spinner appeared on the wrong row.
- Disable every device in the menu while a selection is settling, so a second
  request cannot overtake the first.

The meter opens its own short-lived capture rather than tapping the call's
audio track. That is what lets it follow the picker instantly, and it avoids
the pre-join track, which is deliberately frozen to the device selected when
the screen mounted. The cost is a second capture of the same device while the
menu is open. See the spec's notes sidecar.

Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1, AC6, AC7, AC13, AC14,
AC15, AC17, AC18, AC21, AC22
2026-09-16 14:52:59 +02:00
fkwp 664fac104f Use radio controls to mark the selected device in the quick menu
- Mark the selection with a radio control instead of a trailing check icon
  beside a device glyph, matching the design and the settings modal, so both
  device pickers read the same way.
- Take the visual from Compound's RadioInput rather than restyling a span of
  our own. RadioControl is wrapped in a Radix form control and needs a Form
  ancestor, which a dropdown menu has no business providing; RadioInput does
  not.
- Keep rows as menuitemradio with aria-checked, rendered through
  MenuItem as="div" so an input is never nested inside a button.
- Render the radio aria-hidden and not focusable: Radix owns focus inside the
  menu, and aria-checked carries the state.
- Leave the activating spinner unchanged.
- Keep every scope.behavior call inside the function that owns the scope,
  which no-observablescope-leak requires.
- Cover the speaker section, an output that cannot be chosen, and a lone
  device shown disabled, with unit tests and stories for each.

Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1, AC7
2026-09-16 14:02:01 +02:00
fkwp ef2cbbbaea Translate the device menu titles and drop the duplicated heading
Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1
2026-09-16 13:14:23 +02:00
fkwp 27789131ee Add speaker selection to the microphone quick menu
The microphone chevron listed input devices only, so changing the output
device meant leaving the call controls for the settings modal.

Render the audio menu as two sections, Speaker above Microphone, and wire
the output device list, selection and select callback through the footer
view model. Entries are shown but disabled where the platform cannot switch
that kind of device, or where only one exists, so the menu keeps the same
shape everywhere.

Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1, AC6, AC7, AC8
2026-09-16 12:58:32 +02:00
23 changed files with 3353 additions and 160 deletions
+9
View File
@@ -163,6 +163,12 @@
"login_auth_links_prompt": "Not registered yet?",
"login_subheading": "To continue to Element",
"login_title": "Login",
"microphone_level": {
"label": "Microphone level",
"no_device": "No microphone found. Connect one, then try again.",
"permission_denied": "Element Call cannot use your microphone. Allow access in your browser settings.",
"value": "{{level}} of {{max}}"
},
"microphone_off": "Microphone off",
"microphone_on": "Microphone on",
"mute_microphone_button_label": "Mute microphone",
@@ -215,11 +221,14 @@
"activating": "Activating…",
"camera": "Camera",
"camera_numbered": "Camera {{n}}",
"camera_source": "Camera Source",
"change_device_button": "Change audio device",
"default": "Default",
"default_named": "Default <2>({{name}})</2>",
"default_named_plain": "Default ({{name}})",
"handset": "Handset",
"loudspeaker": "Loudspeaker",
"mic_source": "Mic Source",
"microphone": "Microphone",
"microphone_numbered": "Microphone {{n}}",
"speaker": "Speaker",
+253
View File
@@ -0,0 +1,253 @@
/*
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);
// The speaker list is what the settings modal used to be the only home of.
await expect(page.getByRole("group", { name: "Speaker" })).toBeVisible();
await expect(page.getByRole("group", { name: "Microphone" })).toBeVisible();
// Named rather than counted: the browser contributes its own fake output
// and a "Default" entry, so a total would be a fact about the browser.
for (const n of [1, 2, 3])
await expect(
page
.getByRole("group", { name: "Speaker" })
.getByRole("menuitemradio", { name: `Fake Speaker ${n}` }),
).toBeVisible();
// Only one entry of a kind is marked, and the meter reports a number
// rather than a colour.
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 in the matrix can route audio to a chosen output, so the
// section offers a real choice. The case where a platform cannot — Safari,
// and anything without setSinkId — is covered by a unit check, since no
// browser here can reach it.
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, two joins and a real call between them.
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");
// The point of the criterion: the switch is not a rejoin. Neither side
// sees the call drop, and the guest still has both tiles — so the host
// never left and came back.
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, two joins and a real call between them.
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);
// The microphone is held open while muted, so the meter still reports the
// hardware. The mute control is what says nothing is being transmitted.
const meter = host.getByRole("meter", { name: "Microphone level" });
await expect(meter).toBeVisible();
// Queried by test id, not by role: the menu is modal, so Radix takes the
// rest of the call out of the accessibility tree while it is open.
await expect(mute).toHaveAttribute("aria-checked", "false");
await expect(mute).toBeVisible();
// And the listener is told so, rather than being left to guess from silence.
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 of the list and run past its
// bottom: the position that tells a pinned meter from one that merely
// happens to be 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);
// Every entry stays reachable, which is what the scroll is for.
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, so no ring, even though Radix has moved focus into the
// menu already.
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);
// The pointer takes it away again: the menu focuses whatever it is over, so
// a ring that followed focus alone would trail the mouse.
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 the devices have enumerated, and
// every test here drives them.
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 does not close the menu — the component prevents the default so
// the list survives a mis-click — so it is dismissed explicitly.
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.
*
* The meter is the one opaque element in the menu, so it is the one thing that
* can paint over the border. Whether it actually does needs a screenshot; this
* pins the geometry that decides it.
*/
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);
}
/** The painted outline width in pixels, however the stylesheet spells it. */
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;
});
}
+165
View File
@@ -0,0 +1,165 @@
/*
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 where Element Call is a component in a host's page rather
* than the whole of one.
*
* This is the case the stylesheets cannot describe: the menu is portalled to
* the document, so a container query and a viewport unit both measure the wrong
* thing — the first has no container to resolve against out there, the second
* measures a page Element Call does not own. The menu has to be sized against
* the space the call is actually drawn in.
*
* Driven from the lobby rather than a joined call. The footer builds the same
* menu from the same device behaviours in both, and the container is the same
* size either way, so joining would only add two connections' worth of flake.
*/
// Signing in, setting up crypto and syncing happen twice before anything is on
// screen, as in component-call.spec.ts
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: the difference between measuring the
// call and measuring the window.
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;
// Sized against the call. Were it sized against the window the list would be
// half as tall again, and the assertion below would not be able to tell.
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 space Element Call is drawn in while the menu is open —
// a panel opening, a window dragged, a phone turned. A bound taken once on
// opening would still describe the smaller call.
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");
// Narrow as well as short, which is where entries get pushed out of reach.
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 the space allows, so the list has to scroll rather than
// put entries somewhere they cannot be got at.
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 usable, not merely painted: D11 accepts that the menu may
// be drawn outside the call area, so this asserts reach rather than
// containment.
await last.click();
await expect(last).toHaveAttribute("aria-checked", "true");
});
test("tracks the focus modality 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);
// The menu owns the modality, because every item it can focus has to answer
// to it — the device rows and the camera menu's blur toggle alike.
const menu = page.getByRole("menu");
// Asserted on the attribute rather than the painted ring, which cannot be
// read here: the menu is portalled outside the call root, and the component
// build scopes the stylesheet to it, so neither the ring nor the rule that
// suppresses the browser's own reaches this menu. The paint is asserted
// standalone instead — in the story and in audio-menu.spec.ts. What is on
// trial here is which call the tracking answers for.
await expect(menu).toHaveAttribute("data-focus-modality", "pointer");
// A key pressed in the other call on this page — or anywhere in the host's
// own page — says nothing about how this menu is being used.
await other.evaluate((element) =>
element.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
),
);
await expect(menu).toHaveAttribute("data-focus-modality", "pointer");
// A key pressed in this menu does.
await page.keyboard.press("ArrowDown");
await expect(menu).toHaveAttribute("data-focus-modality", "keyboard");
});
/**
* Opens the microphone menu of one component and returns its scrolling device
* list, which lives outside the component: the menu is portalled to the page.
*/
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;
}
+98
View File
@@ -0,0 +1,98 @@
/*
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";
/**
* Gives the browser more fake devices than it ships with.
*
* A headless browser's fake capture offers one microphone and one speaker,
* which is one short of what a device menu is for: with no choice to make, every
* entry renders disabled. These are synthetic entries on top of the real fake
* device, so the menu has a list to show and a selection to move, and the app
* runs its real device pipeline against them.
*
* What they do not do is route audio: every entry is backed by the same capture,
* and `setSinkId` is accepted rather than honoured. A test can prove that
* choosing a device changes the app's state and does not disturb the call. That
* a listener hears the change needs hardware, and stays a manual check.
*
* Must be called 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 an exact-device constraint on one would be
// rejected. Drop it and let the one real fake device answer.
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);
};
// Routing to a device that does not exist would reject, and the app
// treats that as a failed switch. Both sinks are patched: Element Call
// routes its own AudioContext as well as the media elements, and leaving
// that one alone logs a NotFoundError for every switch.
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 },
);
}
+8
View File
@@ -137,10 +137,13 @@ export const Default: Story = {
debugTileLayout: false,
tileStoreGeneration: undefined,
audioOptions: [],
audioOutputOptions: [],
videoOptions: [],
selectedAudio: undefined,
selectedAudioOutput: undefined,
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectAudioOutputOption: undefined,
selectVideoButtonOption: undefined,
},
parameters: {
@@ -158,11 +161,16 @@ export const WithAudioAndVideoOptions: Story = {
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
{ 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: [
{ label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" },
],
selectedAudio: "2",
selectedAudioOutput: "default",
selectedVideo: "1",
},
};
+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 */
audioOptions: MenuOptions[];
/** Output devices shown as their own section in the audio menu. */
audioOutputOptions: MenuOptions[];
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
videoOptions: MenuOptions[];
selectedAudio: string | undefined;
selectedAudioOutput: string | undefined;
selectedVideo: string | undefined;
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
selectAudioOutputOption: ((deviceId: string) => void) | undefined;
selectVideoButtonOption: ((option: string) => void) | undefined;
}
@@ -143,6 +147,9 @@ export const CallFooter: FC<FooterProps> = ({
const audioOptions = useBehavior(vm.audioOptions$);
const selectedAudio = useBehavior(vm.selectedAudio$);
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 toggleBlur = useBehavior(vm.toggleBlur$);
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
@@ -168,7 +175,6 @@ export const CallFooter: FC<FooterProps> = ({
if ((audioOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Mic Source"}
key="audio"
iconsAndLabels="audio"
enabled={audioEnabled ?? false}
@@ -178,6 +184,9 @@ export const CallFooter: FC<FooterProps> = ({
options={audioOptions}
selectedOption={selectedAudio}
onSelect={selectAudioButtonOption}
outputOptions={audioOutputOptions}
selectedOutputOption={selectedAudioOutput}
onSelectOutput={selectAudioOutputOption}
/>,
);
} else {
@@ -197,7 +206,6 @@ export const CallFooter: FC<FooterProps> = ({
if ((videoOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Camera Source"}
key="video"
iconsAndLabels="video"
enabled={videoEnabled ?? false}
@@ -30,6 +30,11 @@ vi.mock("@livekit/track-processors", () => ({
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
* createCallFooterViewModel, with all other properties stubbed to
@@ -96,6 +101,71 @@ const twoMicsAndOneCamMediaDevices = mockMediaDevices({
});
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);
// Undefined is what renders the speaker section disabled.
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(),
},
// Safari enumerates no output devices whatsoever. Reproduced by the
// condition rather than by the browser, so it is checked on the
// Linux CI runners that have no Safari to check it with.
audioOutput: {
available$: constant(new Map<string, DeviceLabel>()),
selected$: constant(undefined),
select: vi.fn(),
},
}),
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
// Empty rather than undefined: undefined means this menu has no notion
// of outputs at all, as the camera menu has none, and hides the section.
// Empty means there are none to list, and the menu still shows the
// section with a default in it, disabled.
expect(vm.audioOutputOptions$.value).toEqual([]);
});
});
describe("audioOptions and videoOptions", () => {
function checkEmptyFor(platform: string, layout: Layout): void {
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.
*/
import { combineLatest, map, switchMap } from "rxjs";
import { combineLatest, map, type Observable, switchMap } from "rxjs";
import { supportsBackgroundProcessors } from "@livekit/track-processors";
import { supportsAudioOutputSelection } from "livekit-client";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
@@ -67,49 +68,50 @@ function buildDeviceBehaviors(
| "audioOptions$"
| "selectedAudio$"
| "selectAudioButtonOption$"
| "audioOutputOptions$"
| "selectedAudioOutput$"
| "selectAudioOutputOption$"
| "videoOptions$"
| "selectedVideo$"
| "selectVideoButtonOption$"
| "toggleBlur$"
| "videoBlurEnabled$"
> {
return {
audioOptions$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: mediaDevices.audioInput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label,
})),
),
const options$ = (
available$: Behavior<Map<string, MenuOptions["label"]>>,
): Observable<MenuOptions[]> =>
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({ id, label })),
),
),
),
),
),
);
return {
audioOptions$: scope.behavior(options$(mediaDevices.audioInput.available$)),
selectedAudio$: scope.behavior(
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
),
selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
videoOptions$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: mediaDevices.videoInput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label,
})),
),
),
),
),
audioOutputOptions$: scope.behavior(
options$(mediaDevices.audioOutput.available$),
),
selectedAudioOutput$: scope.behavior(
mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)),
),
// Safari and most Firefox builds cannot route audio to a chosen device at
// all. Withholding the callback is what renders the section disabled.
selectAudioOutputOption$: constant(
supportsAudioOutputSelection()
? mediaDevices.audioOutput.select
: undefined,
),
videoOptions$: scope.behavior(options$(mediaDevices.videoInput.available$)),
selectedVideo$: scope.behavior(
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
),
@@ -263,10 +265,13 @@ export function createLobbyFooterViewModel(
reactionData: undefined,
tileStoreGeneration: undefined,
audioOptions: undefined,
audioOutputOptions: undefined,
videoOptions: undefined,
selectedAudio: undefined,
selectedAudioOutput: undefined,
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectAudioOutputOption: undefined,
selectVideoButtonOption: undefined,
}),
...buildMuteBehaviors(scope, muteStates),
@@ -35,3 +35,103 @@ Please see LICENSE in the repository root for full details.
transform: rotate(360deg);
}
}
.menu {
display: flex;
flex-direction: column;
}
/* Only the device lists scroll; the level meter stays put beneath them.
The bound comes from the measured height of the call area, set by the
component: the menu is portalled outside the root, so neither a container
query nor a viewport unit describes the space it is allowed to fill. */
.deviceList {
overflow-y: auto;
min-block-size: 0;
max-block-size: var(--device-list-max-height);
/* The heading stands over the head of this list and the level meter over its
foot. Scrolling a row flush to either edge would put it underneath one of
them, which is how a row reached by the keyboard ends up half-readable;
this keeps both their heights clear for anything the browser scrolls to.
Set by the component, which is the only thing that can measure them. */
scroll-padding-block: var(--device-list-scroll-padding-start, 0)
var(--device-list-scroll-padding-end, 0);
}
/* Each section is headed by its own rule, which Compound draws under a menu
heading already. The heading spans the whole menu — the menu has no padding
of its own — so that rule runs edge to edge, stopping only where the frame
is, rather than inset as a separator between the sections would be.
Compound's spacing around it is the design's too, bar one thing: the design
leaves a section's first device further below the rule than Compound does —
measured off the mock, 27px from the rule to the top of the control, against
the 16px that Compound's 8px margin and the row's own 8px padding give. The
difference goes below the rule, so the rule stays tight under its own text
where Compound put it. */
.menu h3 {
margin-block-end: var(--cpd-space-5x);
}
/* One section is set much further from the one above it than Compound's 8px
heading margin allows: measured off the mock, 41px from the last device's
control to the next heading's text, against the 22px we had. Only between
sections — the first heading keeps Compound's spacing, because the menu's
own padding is already above it. */
.deviceList [role="group"] + [role="group"] h3 {
margin-block-start: var(--cpd-space-7x);
}
/* Each section's heading stays at the top of the scrollport while any of that
section is still in view, so a long list never leaves you wondering which
kind of device you are looking at. It leaves with its own section, because
sticky only holds while the group it belongs to is in view.
Opaque, and held a border width clear of the sides, for the same reason as
the meter below: this is a positioned element, so it paints above the
outline the menu draws its frame with, and would swallow it. */
.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);
}
/* The meter belongs to the microphone section: it stays at the bottom of the
scrollport while that section is in view, and leaves with it when the list
is scrolled up to the speakers. The wrapper is deliberately unpositioned —
a positioned one paints above the menu's outline and swallows the frame
along this whole section. Sticky is resolved against the scroll container,
so it does not need one. */
.stickyMeter {
position: sticky;
inset-block-end: 0;
/* Opaque, so the list does not show through it as it scrolls past. The menu
draws its frame as an outline inset by one border width, and the device
rows are transparent at rest, so this is the only thing that can cover it:
hold it clear on the sides and the bottom. */
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 own ring marks
the item under the mouse. It cannot distinguish the two modalities here, so
it is suppressed and replaced by one that can.
Every kind of item the menu can focus, not only the device rows: the camera
menu's blur toggle is a checkbox item and a child of the menu rather than of
the list, and left out it kept the browser's own ring and followed the
pointer with it. */
.menu [role^="menuitem"]:focus,
.menu [role^="menuitem"]:focus-visible {
outline: none;
}
/* Shown only when the keyboard is what moved the focus. */
.menu[data-focus-modality="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.
*/
import { fn, userEvent, within, expect } from "storybook/test";
import { type JSX } from "react";
import { fn, userEvent, waitFor, within, expect } from "storybook/test";
import { useEffect, useState, type FC, type JSX, type ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
import styles from "./MediaMuteAndSwitchButton.module.css";
import meterStyles from "./MicrophoneLevelMeter.module.css";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { RootElementProvider } from "../RootElementContext";
import { MediaDevices } from "../state/MediaDevices";
import { globalScope } from "../state/ObservableScope";
@@ -18,12 +21,97 @@ const mediaDevices = new MediaDevices(globalScope, {
controlledAudioDevices: false,
});
/**
* Gives these stories a microphone to read.
*
* The menu opens a capture of whichever device it has been told is selected,
* and the devices in a story are invented: asking for one by an id no hardware
* answers to fails, and the meter reports that — correctly — as there being no
* microphone. So the story provides one rather than borrowing the machine's: a
* wavering tone played into a real MediaStream, which the meter then runs its
* own analyser over. Nothing here stands in for the meter itself.
*/
const WithAMicrophone: FC<{ children: ReactNode }> = ({ children }) => {
useEffect(() => {
const context = new AudioContext();
const microphone = context.createMediaStreamDestination();
const tone = context.createOscillator();
const loudness = context.createGain();
// Swinging between about a third and two thirds of the range, so the meter
// reads as something live rather than as a level someone pinned there.
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 that a caller stopping its tracks when it is
// done does not take the microphone away from the next one.
devices.getUserMedia = async (): Promise<MediaStream> =>
(await opened).clone();
return (): void => {
devices.getUserMedia = openedForReal;
tone.stop();
swing.stop();
void context.close();
};
}, []);
return <>{children}</>;
};
/**
* Gives these stories the call area the menu belongs to.
*
* The menu sizes its device list against the space Element Call is drawn in,
* and takes that from a provider. Without one it falls back to the document
* body — which in a story is the whole of Storybook's frame, so the list is
* bounded by something far larger than the story it is drawn in and runs off
* the top of the canvas. Supplying a root is the same courtesy as supplying the
* devices: the story stands in for the call, so it has to say how big it is.
*/
const WithACallArea: FC<{ children: ReactNode }> = ({ children }) => {
const [callArea, setCallArea] = useState<HTMLElement | null>(null);
return (
<div
ref={setCallArea}
style={{
// The size of a call, not of a thumbnail: the device list is bounded to
// a share of this, so a small area makes even a two-device menu scroll,
// which no real call does. Tall enough to leave the menu room to open
// upward and still be wholly on screen in the story's frame.
blockSize: 720,
display: "flex",
alignItems: "flex-end",
justifyContent: "center",
}}
>
{callArea !== null && (
<RootElementProvider value={callArea}>{children}</RootElementProvider>
)}
</div>
);
};
const meta = {
component: MediaMuteAndSwitchButton,
decorators: [
(Story): JSX.Element => (
<MediaDevicesContext value={mediaDevices}>
<Story />
<WithACallArea>
<WithAMicrophone>
<Story />
</WithAMicrophone>
</WithACallArea>
</MediaDevicesContext>
),
],
@@ -42,6 +130,16 @@ export const Default: Story = {
{ label: { type: "name", name: "Option 2" }, id: "2" },
],
selectedOption: "1",
// The audio menu always has a speaker section: the footer hands it an
// output list whenever it draws the chevron at all, so a microphone menu
// with no speakers in it is a shape nothing in the app produces. Set here
// rather than in each story, since the others build on these.
outputOptions: [
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
],
selectedOutputOption: "default",
onSelectOutput: fn(),
onMuteClick: fn(),
onSelect: fn(),
},
@@ -73,6 +171,7 @@ export const AudioMute: Story = {
export const AudioUnmute: Story = {
args: {
...Default.args,
title: "Microphone",
iconsAndLabels: "audio",
enabled: true,
@@ -80,7 +179,6 @@ export const AudioUnmute: Story = {
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
],
selectedOption: "2",
},
};
@@ -113,3 +211,560 @@ export const VideoUnmute: Story = {
selectedOption: "2",
},
};
export const SpeakerAndMicrophoneSections: Story = {
args: {
...Default.args,
title: "Microphone",
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 handful of devices fits: only a list longer than the space it is given
// scrolls, and a menu that scrolled at four devices would be bounded by
// something far smaller than the call it is drawn in.
const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`,
)!;
await expect(list.scrollHeight).toBe(list.clientHeight);
// Each section is headed by its own rule, running the full width of the
// menu rather than inset — and nothing divides the sections besides.
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, stopping only where the menu's frame is drawn.
const box = rule.getBoundingClientRect();
await expect(box.left - frame.left).toBeLessThanOrEqual(2);
await expect(frame.right - box.right).toBeLessThanOrEqual(2);
}
// A section's first device sits further below the rule than it does from
// the menu's edge. Stated as the relationship rather than a number: what
// the design asks for is the asymmetry, and Compound's own heading margin
// alone would make the two equal.
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 one section stands further from the one above it than a heading does
// from its own first device — again the relationship, not a number.
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,
title: "Microphone",
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,
title: "Microphone",
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 rather than hidden, so the menu keeps its shape everywhere.
const only = await within(document.body).findByRole("menuitemradio", {
name: "Microphone 1",
});
await expect(only).toHaveAttribute("aria-disabled", "true");
},
};
/**
* A device has been asked for and has not arrived. Nothing in either section
* can be picked until it does, so a second request cannot overtake the first.
*/
export const SelectionSettling: Story = {
args: {
...Default.args,
title: "Microphone",
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);
// The story never changes `selectedOption`, which is what a device that has
// not taken effect yet looks like from here.
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 focus ring belongs to the keyboard. Radix focuses whatever the pointer is
* over, so a ring that followed focus alone would trail the mouse.
*
* Asserted on the painted outline rather than on `data-focus-modality`: the
* attribute is what the stylesheet keys off, so asserting it would pass even
* with the rule deleted.
*/
export const KeyboardFocusRing: Story = {
args: {
...Default.args,
title: "Microphone",
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);
},
};
/**
* More devices than the menu can show. The list scrolls, and the meter stays at
* the foot of the Microphone section rather than scrolling away with it.
*/
export const ManyDevices: Story = {
args: {
...Default.args,
title: "Microphone",
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 scroll container and the opaque sticky wrapper, named rather than
// walked: the nesting between them is layout, and it moves. The wrapper
// rather than the meter itself, because this story is about where the meter
// sits, not what it reads — without a fake microphone, as on WebKit, it
// says it has no permission instead of showing 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 of the scrollport.
// Its devices then run past the bottom, which is the position that tells a
// pinned meter from one that simply happens to be the last element: at the
// very bottom of the list the two look identical.
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. It opens upward from the foot of the call,
// so a list bounded by something bigger than the call — the document, say —
// runs off the top and takes the speakers with it.
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 one opaque thing in the menu, so it is the one thing
// that can cover the frame. Its box has to stay inside the menu's own. The
// paint itself needs a screenshot; this pins the geometry that decides it.
await expect(pinned.left).toBeGreaterThan(frame.left);
await expect(pinned.right).toBeLessThan(frame.right);
},
};
/** The painted outline width, in pixels, however the stylesheet spells it. */
function outlineWidth(element: HTMLElement): number {
const { outlineStyle, outlineWidth } = getComputedStyle(element);
if (outlineStyle === "none") return 0;
return Number.parseFloat(outlineWidth) || 0;
}
/**
* A platform that enumerates no output devices and offers no way to choose one
* — Safari. The section still names where audio is going, disabled, rather than
* leaving a heading with nothing under it.
*/
export const OutputNotEnumerated: Story = {
args: {
...Default.args,
title: "Microphone",
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 level meter's icon sits on the same centre line as the radio controls of
* the devices above it.
*
* Held here because it is a fact about two components side by side, and because
* layout decides it: the meter's row is inset to keep the menu's frame clear,
* and its icon is a different size from a radio control, so the padding that
* lines them up is arithmetic that would otherwise go stale in silence.
*/
export const MeterAlignsWithTheDeviceRows: Story = {
args: {
...Default.args,
title: "Microphone",
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);
},
};
/** Where an element sits on the inline axis, at its middle. */
function centre(element: Element): number {
const box = element.getBoundingClientRect();
return box.left + box.width / 2;
}
/**
* Walking the device list with the keyboard, all the way to the last entry.
*
* The level meter stands over the foot of the list, so a row scrolled flush to
* the bottom edge arrives underneath it and can only half be read. Nothing in
* the DOM says an element is covered, so this compares where the two were
* actually drawn.
*/
export const KeyboardReachesEveryDevice: Story = {
args: {
...Default.args,
title: "Microphone",
iconsAndLabels: "audio",
enabled: true,
// Enough of them that the list scrolls well past its own height, so that
// arrowing back up has to scroll too — which is where the heading can hide
// a row, as the meter can on the way down.
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;
});
// Everything that is drawn over the scrolling list: a heading holds the top
// while its section is in view, the meter holds the foot.
const overlays = [
meter,
...document.body.querySelectorAll<HTMLElement>(
`.${styles.sectionHeading}`,
),
];
const items = within(document.body).getAllByRole("menuitemradio");
// Down to the last device, as someone reading the list would, and back up
// again: a row can be hidden at either end.
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");
// Nothing is drawn over the row the keyboard has just reached. Stated
// as overlap rather than as an edge, because whether a heading is in
// the way depends on whether its section is still on screen.
await expect(overlapping(focused, overlays)).toBeLessThanOrEqual(1);
}
},
};
/**
* A long list scrolled well into the microphones.
*
* The heading of the section you are in stays at the top of the list, so it is
* always clear which kind of device the rows below are. It leaves with its own
* section rather than stacking with the next one.
*/
export const HeadingsStayWhileScrolling: Story = {
args: {
...Default.args,
title: "Microphone",
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, not role: the heading is aria-hidden decoration, because the
// group it belongs to is what carries the name.
const heading = group.querySelector<HTMLElement>(
`.${styles.sectionHeading}`,
)!;
// Far enough in that the heading's own place in the list is well above the
// top of the scrollport: it is only still 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,
);
// And it keeps clear of the menu's frame, as the meter does.
const frame = document.body
.querySelector("[role='menu']")!
.getBoundingClientRect();
await expect(heading.getBoundingClientRect().left).toBeGreaterThan(
frame.left,
);
},
};
/**
* How far an element is covered, in pixels, by the most overlapping of others.
*
* Nothing in the DOM says an element is obscured, and an element scrolled flush
* to an edge of its container looks no different there from one a sticky
* heading is sitting on top of. The boxes are the only witness.
*/
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 camera menu's blur toggle, which the keyboard reaches after the cameras.
*
* It is a checkbox item and a child of the menu rather than of the device list,
* so a focus ring hung on the list alone left it with the browser's own —
* which follows the pointer, and is what the ring exists to replace.
*/
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/,
});
// Arrowed down past the cameras to the toggle, which is the last thing in
// the menu.
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 again, with the toggle still focused — so
// there is something to light up and it is not lit.
await userEvent.hover(toggle);
await expect(document.activeElement).toBe(toggle);
await expect(outlineWidth(toggle)).toBe(0);
},
};
+383 -11
View File
@@ -6,7 +6,14 @@ Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test, vi } from "vitest";
import { act, render, screen, type RenderResult } from "@testing-library/react";
import { axe } from "vitest-axe";
import {
act,
render,
screen,
within,
type RenderResult,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type JSX, useState, type ReactNode } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -327,7 +334,7 @@ describe("MediaMuteAndSwitchButton", () => {
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 { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
@@ -342,19 +349,384 @@ describe("MediaMuteAndSwitchButton", () => {
/>,
);
// open menu
await user.click(getByRole("button", { name: "Microphone" }));
// The selected item (mic2) renders both an IconOptions SVG and a CheckIcon SVG
const mic1Item = screen.getByRole("menuitemradio", {
name: "Microphone 2",
});
expect(mic1Item.querySelectorAll("svg").length).toBe(2);
screen.getByRole("menuitemradio", { name: "Microphone 2", checked: true });
screen.getByRole("menuitemradio", { name: "Microphone 1", checked: false });
});
// The unselected item (mic1) only renders its IconOptions SVG
const mic2Item = screen.getByRole("menuitemradio", {
test("disables every device while a selection is settling", async () => {
const user = userEvent.setup();
const { promise, resolve } = Promise.withResolvers<void>();
function Wrapper(): JSX.Element {
const [selectedOption, setSelectedOption] = useState("mic1");
return (
<MediaMuteAndSwitchButton
title="Switcher"
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, so a second
// request cannot overtake the first.
for (const name of ["Microphone 1", "Speakers", "Headset"]) {
expect(screen.getByRole("menuitemradio", { name })).toHaveAttribute(
"aria-disabled",
"true",
);
}
await act(async () => {
resolve();
await promise;
});
// Settled: choosable 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("camera menu uses the same selection pattern and keeps the blur toggle", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
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" }));
// Same selection pattern as the microphone menu.
screen.getByRole("menuitemradio", { name: "Camera 1", checked: true });
screen.getByRole("menuitemradio", { name: "Camera 2", checked: false });
// And background blur is still reachable from here.
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
title="Switcher"
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-modality]");
// The menu focuses whatever the pointer is over, so focus alone says
// nothing about how someone is navigating.
expect(list).toHaveAttribute("data-focus-modality", "pointer");
await user.keyboard("{ArrowDown}");
expect(list).toHaveAttribute("data-focus-modality", "keyboard");
await user.pointer({
target: screen.getByRole("menuitemradio", { name: "Microphone 2" }),
coords: { clientX: 10, clientY: 10 },
});
expect(list).toHaveAttribute("data-focus-modality", "pointer");
});
test("marks the selected device with the accent fill", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
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();
// A read-only control is painted muted, which loses the accent fill that
// marks the selection and makes the menu differ from settings.
expect(selected).not.toHaveAttribute("readonly");
});
test("the open menu has no accessibility violations", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
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" }));
// Includes the menu's own structure: wrappers between the menu and its
// items break the relationship the roles describe.
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
title="Switcher"
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",
});
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
title="Switcher"
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
title="Switcher"
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
title="Switcher"
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 rather than hidden, so the menu keeps its shape, but not choosable.
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
title="Switcher"
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()}
// Safari enumerates no output devices at all, and offers no way to
// choose one.
outputOptions={[]}
selectedOutputOption={undefined}
onSelectOutput={undefined}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
// A heading with nothing under it says the feature is broken. Audio is
// playing somewhere, so the section names that somewhere and disables it.
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");
// And marked as the selection: it is where audio is going, so an unchecked
// lone entry would read as nothing being chosen at all.
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
title="Switcher"
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"
// No callback: nothing can be picked here.
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");
// The microphone section is unaffected.
expect(
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
).not.toHaveAttribute("aria-disabled", "true");
});
});
+384 -104
View File
@@ -5,37 +5,54 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
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 {
Button,
Menu,
MenuItem,
MenuTitle,
RadioInput,
ToggleMenuItem,
} from "@vector-im/compound-web";
import {
CheckIcon,
ChevronUpIcon,
ChevronDownIcon,
MicOnIcon,
SpinnerIcon,
VideoCallIcon,
} 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 "./MediaMuteAndSwitchButton.module.css";
import { MicButton, VideoButton } from "../button";
import { type DeviceLabel } from "../state/MediaDevices";
import {
type AudioOutputDeviceLabel,
type DeviceLabel,
} from "../state/MediaDevices";
import { useMediaDevices } from "../MediaDevicesContext";
import { useRootElement } from "../RootElementContext";
import { observeElementSize$ } from "../utils/elementSize";
import { MicrophoneLevelMeter } from "./MicrophoneLevelMeter";
import { useMicrophoneLevel } from "./useMicrophoneLevel";
export interface MenuOptions {
label: DeviceLabel;
label: DeviceLabel | AudioOutputDeviceLabel;
id: string;
}
export interface MediaMuteAndSwitchButtonProps {
/** The title used in the Switcher modal. */
title: string;
/**
* The accessible name of the menu. Defaults to a translated name for the
* media kind. Never shown: each section carries its own heading.
*/
title?: string;
/** If the Mute button is enabled */
enabled?: boolean;
/** Callback if the mute button is clicked */
@@ -47,6 +64,18 @@ export interface MediaMuteAndSwitchButtonProps {
options?: MenuOptions[];
/** The option that will currently be rendered as the selected option */
selectedOption?: string;
/**
* Output (speaker) devices, shown as their own section above the input
* section. Audio menu only; omitted entirely for video.
*/
outputOptions?: MenuOptions[];
/** The output option currently rendered as selected */
selectedOutputOption?: string;
/**
* Called when an output device is picked. Undefined means no output can be
* chosen here, and the section renders disabled.
*/
onSelectOutput?: (id: string) => void;
videoBlurToggleClick?: () => void;
videoBlurEnabled?: boolean;
/**
@@ -58,6 +87,31 @@ export interface MediaMuteAndSwitchButtonProps {
const BLUR_ID = "blur";
/**
* Stands for wherever the platform is sending audio, where it will not say.
*
* Not a device id the browser would recognise: nothing can be selected on a
* platform that lists no outputs, so this is only ever shown, never sent.
*/
const DEFAULT_OUTPUT_ID = "default";
/**
* The share of the call area the device list may fill.
*
* The menu carries its headings and the level meter as well, and a list that
* took the whole call would hide the call it belongs to.
*/
const LIST_SHARE_OF_CALL = 0.6;
/**
* The shortest the device list may be, whatever the call measures.
*
* A share alone collapses in a small call to a list that shows one device and
* gives no sign that there are others. Scrolling a short list is the better
* failure.
*/
const MIN_LIST_HEIGHT = 160;
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
title,
enabled,
@@ -66,82 +120,295 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
iconsAndLabels,
options,
selectedOption,
outputOptions,
selectedOutputOption,
onSelectOutput,
videoBlurEnabled,
videoBlurToggleClick,
onSelect,
}) => {
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
// Which device we have asked for but not yet been given. Carries the kind as
// well as the id, because an input and an output can share an id: "default"
// names both on Chrome.
const [plannedSelection, setPlannedSelection] = useState<{
kind: "input" | "output";
id: string;
} | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const isBusy = busy ?? false;
const { t } = useTranslation();
const devices = useMediaDevices();
// Radix focuses whatever the pointer is over, so the browser's own
// :focus-visible cannot tell us whether a person is navigating by keyboard:
// Chromium answers yes to everything after any key press, Firefox answers no
// to programmatic focus. Track it ourselves and let the styling follow.
/**
* Tracks which modality moved the focus, for as long as the list is mounted.
*
* A ref rather than an effect on `menuOpen`: that state is ours, the open
* menu is Radix's, and the two do not commit together — an effect keyed on
* ours can run before Radix has mounted the content, with nothing to attach
* to. The list existing is the honest signal that the menu is open.
*
* Recorded on the menu rather than held in state, because every item the menu
* can focus has to answer to it — the device rows and the camera menu's blur
* toggle, which is the menu's child and not the list's — and because which
* modality someone is using changes nothing that has to be rendered again.
*/
const trackFocusModality = useCallback(
(list: HTMLDivElement | null): (() => void) | undefined => {
// Watched on the menu, not on the document. Element Call can be mounted
// more than once in a host's page, and the menu is portalled out of the
// call root, so a document listener would also answer for a key pressed
// in the other instance, or in the host's own page. The menu rather than
// the list, because the first arrow key arrives while the menu itself
// holds focus, above anything we render.
const menu = list?.closest<HTMLElement>('[role="menu"]');
if (menu === null || menu === undefined) return;
// Each opening starts over: the modality belongs to whoever is using this
// menu now, not to whoever last used it.
const record = (modality: "keyboard" | "pointer"): void => {
menu.dataset.focusModality = modality;
};
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);
};
},
[],
);
// The menu is portalled outside the call root, so nothing in the stylesheets
// can size it against the call. Measure the call area rather than the window,
// or the menu is wrong wherever Element Call is not the whole page.
const rootElement = useRootElement();
const [listMaxHeight, setListMaxHeight] = useState<number>();
useEffect(() => {
if (!menuOpen) return;
// Followed rather than measured once: a host can resize the space Element
// Call is drawn in while the menu is open — a panel animating, a window
// dragged, a phone turned — and a bound taken on opening then describes a
// call area that no longer exists. Quantised before it reaches React, so a
// resize re-renders only when the bound itself moves.
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]);
// Only while the menu is open, so nothing holds a second capture of the
// microphone for the length of a call.
// The meter sits over the foot of the scrolling list, so the list has to
// keep that much of itself clear: a row scrolled to by the keyboard would
// otherwise arrive underneath it, half-read. Its own height, measured,
// because the failure states are two lines where a level is one.
const [meterHeight, setMeterHeight] = useState<number>();
const meter = useCallback(
(element: HTMLDivElement | null): (() => void) | undefined => {
if (element === null) return;
const subscription = observeElementSize$(element)
.pipe(
map(({ height }) => height),
distinctUntilChanged(),
)
.subscribe(setMeterHeight);
return (): void => subscription.unsubscribe();
},
[],
);
// The headings stand over the head of the list, so it has to keep their
// height clear too — the same bargain as the meter, at the other end. One
// measurement serves both: the sections are headed alike.
const [headingHeight, setHeadingHeight] = useState<number>();
const heading = useCallback(
(element: HTMLDivElement | null): (() => void) | undefined => {
if (element === null) return;
const subscription = observeElementSize$(element)
.pipe(
map(({ height }) => height),
distinctUntilChanged(),
)
.subscribe(setHeadingHeight);
return (): void => subscription.unsubscribe();
},
[],
);
const microphoneState = useMicrophoneLevel(
selectedOption,
menuOpen && iconsAndLabels === "audio",
);
useEffect(() => {
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
}, [menuOpen, devices]);
let button;
let toggles: { label: string; enabled: boolean; id: string }[] = [];
switch (iconsAndLabels) {
case "video":
button = (
<VideoButton
enabled={enabled ?? false}
busy={isBusy}
onClick={(e) => {
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
disabled={isBusy || onMuteClick === undefined}
data-testid="incall_videomute"
/>
);
if (videoBlurToggleClick !== undefined) {
toggles = [
// The mute control differs between the two only in which button it is and
// what it is called; how it behaves is the same, and was worth saying once.
const MuteButton = iconsAndLabels === "audio" ? MicButton : VideoButton;
const button = (
<MuteButton
enabled={enabled ?? false}
busy={isBusy}
onClick={(e) => {
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
disabled={isBusy || onMuteClick === undefined}
data-testid={
iconsAndLabels === "audio" ? "incall_mute" : "incall_videomute"
}
/>
);
// Only the camera menu carries a toggle, and only when the caller offers one.
const toggles =
iconsAndLabels === "video" && videoBlurToggleClick !== undefined
? [
{
label: t("action.blur_background"),
enabled: videoBlurEnabled ?? false,
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 defaultMenuTitle: string;
let numberedLabel: (number: number) => string;
switch (iconsAndLabels) {
case "video":
IconOptions = VideoCallIcon;
optionsButtonLabel = t("settings.devices.camera");
defaultMenuTitle = t("settings.devices.camera_source");
numberedLabel = (n): string =>
t("settings.devices.camera_numbered", { n });
break;
case "audio":
IconOptions = MicOnIcon;
optionsButtonLabel = t("settings.devices.microphone");
defaultMenuTitle = t("settings.devices.mic_source");
numberedLabel = (n): string =>
t("settings.devices.microphone_numbered", { n });
break;
}
/** The text shown for a device, whichever kind of label it carries. */
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");
}
};
// A device we asked for that has not arrived yet. Until it does, nothing in
// the menu can be picked, so a second request cannot overtake the first.
const settling =
plannedSelection !== null &&
plannedSelection.id !==
(plannedSelection.kind === "output"
? selectedOutputOption
: selectedOption);
// Safari enumerates no output devices at all, and offers no way to choose
// one, so the list arrives empty. The section is shown all the same — audio
// is playing somewhere — naming that somewhere and disabling it like any
// single entry. A heading with nothing beneath it reads as a broken feature,
// and leaves the menu a different shape on one browser.
const noOutputsListed = outputOptions?.length === 0;
const speakerOptions: MenuOptions[] | undefined = noOutputsListed
? [{ id: DEFAULT_OUTPUT_ID, label: { type: "default", name: null } }]
: outputOptions;
// And it is the selection, not merely the only row: it is where audio is
// going. An unchecked lone entry reads as nothing being chosen at all.
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 ?? [];
// Shown but not choosable when nothing can be picked here, or when there is
// only one device. The entry stays visible so the menu keeps the same shape
// on every platform.
const disabled = select === undefined || list.length <= 1 || settling;
return list.map(({ label, id }) => (
<MenuItem
// A radio input inside a button is invalid, and the menu needs an
// element it can give menuitemradio semantics to.
as="div"
hideChevron
disabled={disabled}
label={labelText(label, numbered)}
Icon={
// Inert, not aria-hidden: a form control inside a menu item must be
// out of the focus order and out of the accessibility tree, and
// aria-hidden alone leaves it focusable. The item's aria-checked is
// what conveys the selection.
<span inert>
<RadioInput
checked={selected === id}
disabled={disabled}
// Not readOnly: that styles the control as muted, losing the
// accent fill that marks the selection. The menu item owns the
// interaction, so the change handler has nothing to do.
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 (
<div
className={classNames({
@@ -152,8 +419,11 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
{/* The mute button lives inside */}
{button}
<Menu
title={title}
showTitle={true}
className={styles.menu}
title={title ?? defaultMenuTitle}
// Each section carries its own heading, so the menu's own title would
// sit on top of the first one. Kept for the accessible name only.
showTitle={false}
open={menuOpen}
onOpenChange={setMenuOpen}
side="top"
@@ -171,67 +441,77 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
/>
}
>
{options?.map(({ label, id }) => {
let labelText: string;
switch (label.type) {
case "name":
labelText = label.name;
break;
case "number":
labelText = numberedLabel(label.number);
break;
<div
ref={trackFocusModality}
// Transparent to assistive technology, so the menu still sees its
// items as its own children.
role="none"
className={styles.deviceList}
style={
{
"--device-list-max-height":
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
hideChevron
label={labelText}
Icon={
IconOptions && (
<IconOptions
width={24}
height={24}
className={styles.itemIcon}
aria-hidden
/>
)
}
onSelect={(e) => {
e.preventDefault();
if (id === selectedOption) return;
setPlannedSelection(id);
onSelect?.(id);
}}
key={id}
role="menuitemradio"
aria-checked={selectedOption === id}
>
{selectedOption === id && (
<CheckIcon
width={24}
height={24}
aria-hidden // A label would be redundant to aria-checked above
>
{iconsAndLabels === "audio" && speakerOptions && (
<>
{/* A menu may only contain items, separators and groups, so each
heading belongs to a group rather than sitting beside the
items it names. */}
<div role="group" aria-label={t("settings.devices.speaker")}>
{/* The heading is decoration: the group carries the name, and
a menu may only contain items, separators and groups. */}
<div aria-hidden className={styles.sectionHeading}>
<MenuTitle title={t("settings.devices.speaker")} />
</div>
{deviceItems(
"output",
speakerOptions,
selectedSpeaker,
onSelectOutput,
(n) => t("settings.devices.speaker_numbered", { n }),
)}
</div>
</>
)}
<div role="group" aria-label={optionsButtonLabel}>
<div ref={heading} aria-hidden className={styles.sectionHeading}>
<MenuTitle title={optionsButtonLabel} />
</div>
{/* The heading sits outside, so the meter can never ride up over it:
sticky only holds while this block is in view. */}
<div role="none">
{deviceItems(
"input",
options,
selectedOption,
onSelect,
numberedLabel,
)}
{iconsAndLabels === "audio" && (
<MicrophoneLevelMeter
ref={meter}
state={microphoneState}
className={styles.stickyMeter}
/>
)}
{selectedOption !== id && plannedSelection === id && (
<SpinnerIcon
width={24}
height={24}
className={styles.rotate}
aria-label={t("settings.devices.activating")}
/>
)}
</MenuItem>
);
})}
{(toggles?.length ?? 0) > 0 && <hr />}
{toggles?.map((toggle) => (
</div>
</div>
</div>
{toggles.length > 0 && <hr />}
{toggles.map((toggle) => (
<ToggleMenuItem
label={toggle.label}
onSelect={(e) => {
videoBlurToggleClick?.();
e.preventDefault();
}}
checked={toggle.enabled ?? false}
checked={toggle.enabled}
key={toggle.id}
/>
))}
@@ -0,0 +1,80 @@
/*
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 {
display: flex;
align-items: center;
gap: var(--cpd-space-3x);
padding-block: var(--cpd-space-2x);
/* The icon shares a centre with the radio controls in the device rows above,
which sit one 4x padding in from the menu's edge. Two things pull this row
out of line with them, and both come off the padding: the row is inset by a
border width so that the menu's frame stays visible behind it (see
`.stickyMeter`), and the icon is wider than a radio control, so it has to
start further left for the two to share a centre. Both are
measured in the MeterAlignsWithTheDeviceRows story rather than trusted:
jsdom lays nothing out, so only a real browser can hold this. */
padding-inline-start: calc(
var(--cpd-space-4x) - var(--cpd-border-width-1) - 2px
);
padding-inline-end: calc(var(--cpd-space-4x) * 2 + var(--cpd-space-2x));
}
.icon {
color: var(--cpd-color-icon-secondary);
flex-shrink: 0;
/* Same width as a device row's icon column, so the bars start where the
device names start. */
inline-size: 24px;
}
.segments {
/* A bar and the space beside it are always the same size; what changes with
the width available is how many bars there are. Spreading a fixed number of
bars instead would make the meter a different shape in every place it is
used, and close them up into one solid block wherever the space ran short —
and a meter whose bars touch stops reading as a count, which is what
carries the level for anyone who cannot rely on the colour.
Both sizes are set here and nowhere else: the component reads them back off
the rendered bars to work out how many fit. */
flex: 1;
/* The width decides the count, never the other way round. Containment is
what enforces that: without it the bars are both a floor under the width
and the widest thing in the menu, so the meter would set the menu's width
and the device names — which are what a person is reading — would have to
fit around it. */
contain: inline-size;
min-inline-size: 0;
display: flex;
align-items: center;
/* Half again as wide as a bar, as the design has it: bars set as close as
their own width read as a solid block long before they touch. */
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);
}
/* Filled segments carry the level by count as well as by colour, so the meter
stays readable without relying on hue. */
.segmentLit {
background: var(--cpd-color-bg-accent-rest);
}
.message {
color: var(--cpd-color-text-secondary);
/* A paragraph brings a margin below it, which in a centred row does not push
the text down but lifts it: the margin box is what gets centred, so the
words end up above the middle and the icon beside them looks low. */
margin-block: 0;
}
@@ -0,0 +1,189 @@
/*
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 { METER_SEGMENTS } from "../state/MicrophoneLevel";
/**
* A width to show the meter at, close to the menu it lives in.
*
* Not a copy of the menu's width, and nothing depends on the two agreeing: a
* bar and the gap beside it are a fixed size now, so this only decides how many
* bars there is room for. Without a width at all the stories would shrink-wrap
* to almost nothing and show a meter two bars wide, which is no use to anyone
* looking at them.
*/
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. Nothing is lit: room hiss below the noise floor must not read
* as "it can hear me".
*/
export const Silent: Story = {
args: { state: { type: "level", level: 0 } },
play: async ({ canvasElement }) => {
await expect(litSegments(canvasElement)).toBe(0);
},
};
export const QuietSpeech: Story = {
args: { state: { type: "level", level: 5 } },
};
export const NormalSpeech: Story = {
args: { state: { type: "level", level: 12 } },
play: async ({ canvasElement }) => {
// Shown at something like the width of the menu, so the meter in a story
// reads like the meter in a call rather than like a handful of bars. The
// design's own mock has sixteen of them at this width; a floor rather than
// a count, because the number follows from the bar and gap sizes and those
// are the design's to change.
await expect(
canvasElement.getElementsByClassName(styles.segment).length,
).toBeGreaterThanOrEqual(15);
},
};
export const LoudSpeech: Story = {
args: { state: { type: "level", level: METER_SEGMENTS } },
};
/**
* The three volumes differ by how many bars are lit, so the level survives
* greyscale and a screen reader as well as it survives colour.
*/
export const VolumesAreDistinguishable: Story = {
args: { state: { type: "level", level: 5 } },
play: async ({ canvasElement, mount }) => {
const lit: number[] = [];
for (const level of [5, 12, METER_SEGMENTS]) {
await mount(<MicrophoneLevelMeter state={{ type: "level", level }} />);
lit.push(litSegments(canvasElement));
await expect(within(canvasElement).getByRole("meter")).toHaveAttribute(
"aria-valuenow",
String(level),
);
}
// Three different counts, rising: the level is carried by how many bars
// are lit, not by their colour alone.
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, never a still meter that
* reads as silence.
*/
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(/Allow access in your browser settings/),
).toBeVisible();
},
};
/** No input device at all, 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 words, however many lines they run to.
// A paragraph's own margin would centre its margin box instead, leaving the
// text high and the icon looking low beside it.
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.
*
* A bar and the space beside it are always the same size; what changes is how
* many bars there are. Spreading a fixed number of bars instead would make the
* meter a different shape in every place it is used, and close the bars up into
* one block wherever the space ran short — and bars that touch cannot be
* counted, which is what carries the level without colour.
*/
export const ShapeStaysTheSameAtAnyWidth: Story = {
args: { state: { type: "level", level: 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);
// And the bars are still bars, not one run of colour.
await expect(narrow.gap).toBeGreaterThan(0);
},
};
/** How many bars are painted as carrying level, rather than as empty. */
function litSegments(canvasElement: HTMLElement): number {
return canvasElement.getElementsByClassName(styles.segmentLit).length;
}
/** Renders the meter at one width and reports the shape of its bars. */
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,40 @@
/*
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 { METER_SEGMENTS } from "../state/MicrophoneLevel";
describe("MicrophoneLevelMeter", () => {
test("announces the level rather than relying on hue", () => {
render(<MicrophoneLevelMeter state={{ type: "level", level: 6 }} />);
const meter = screen.getByRole("meter", { name: "Microphone level" });
expect(meter).toHaveAttribute("aria-valuenow", "6");
expect(meter).toHaveAttribute("aria-valuemax", String(METER_SEGMENTS));
expect(meter).toHaveAttribute("aria-valuetext", `6 of ${METER_SEGMENTS}`);
});
test("shows distinct messages for denied permission and no input device", () => {
const denied = render(
<MicrophoneLevelMeter state={{ type: "permission-denied" }} />,
);
// A next action, not a flat meter that reads as silence.
expect(
denied.getByText(/Allow access in your browser settings/),
).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();
});
});
+120
View File
@@ -0,0 +1,120 @@
/*
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, 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 { METER_SEGMENTS, type MicrophoneState } from "../state/MicrophoneLevel";
import { observeElementSize$ } from "../utils/elementSize";
export interface MicrophoneLevelMeterProps {
state: MicrophoneState;
className?: string;
/**
* The meter's own element. Its height is what a scroll container has to keep
* clear to stop the meter covering the row it has just scrolled to.
*/
ref?: Ref<HTMLDivElement>;
}
/**
* The live input level of the selected microphone, shown beneath it.
*
* It says whether the microphone is picking anything up, which is not the same
* as whether the user is being heard: it keeps moving while muted, and the mute
* control is what says nothing is transmitted.
*/
export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
state,
className,
ref,
}) => {
const { t } = useTranslation();
// How many bars there is room for. The bars never change size, so this is
// what absorbs a change of width. Starts at the full count so that the first
// paint is a meter rather than a single bar, and so that a renderer with no
// layout at all — jsdom — still draws the whole thing.
const [barCount, setBarCount] = useState(METER_SEGMENTS);
const track = useCallback(
(element: HTMLDivElement | null): (() => void) | undefined => {
if (element === null) return;
const subscription = observeElementSize$(element)
.pipe(
map(({ width }) => barsThatFit(element, width)),
distinctUntilChanged(),
)
.subscribe(setBarCount);
return (): void => subscription.unsubscribe();
},
[],
);
if (state.type !== "level")
return (
<div ref={ref} className={classNames(styles.meter, className)}>
<MicOnIcon width={24} height={24} className={styles.icon} aria-hidden />
<Text size="sm" className={styles.message}>
{state.type === "permission-denied"
? t("microphone_level.permission_denied")
: t("microphone_level.no_device")}
</Text>
</div>
);
return (
<div ref={ref} className={classNames(styles.meter, className)}>
<MicOnIcon width={24} height={24} className={styles.icon} aria-hidden />
<div
ref={track}
className={styles.segments}
role="meter"
aria-label={t("microphone_level.label")}
aria-valuemin={0}
aria-valuemax={METER_SEGMENTS}
aria-valuenow={state.level}
aria-valuetext={t("microphone_level.value", {
level: state.level,
max: METER_SEGMENTS,
})}
>
{Array.from({ length: barCount }, (_, i) => (
<span
key={i}
aria-hidden
className={classNames(styles.segment, {
// The level is a share of the scale, not a number of bars: how
// many bars stand for it depends on how many there are.
[styles.segmentLit]:
i < Math.round((state.level / METER_SEGMENTS) * barCount),
})}
/>
))}
</div>
</div>
);
};
/**
* How many bars fit across `width`, at the size the stylesheet draws them.
*
* Measured off a rendered bar rather than told: the size of a bar and of the
* space beside it are a design question, settled in the stylesheet, and reading
* them back is what keeps them from being settled twice.
*/
function barsThatFit(track: HTMLElement, width: number): number {
const gap = Number.parseFloat(getComputedStyle(track).columnGap);
const bar = track.firstElementChild?.getBoundingClientRect().width ?? 0;
// A renderer that lays nothing out tells us nothing; keep the full count.
if (!(bar > 0) || !(gap >= 0)) return METER_SEGMENTS;
return Math.max(1, Math.floor((width + gap) / (bar + gap)));
}
@@ -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 { afterEach, describe, expect, test } from "vitest";
import { renderHook } from "@testing-library/react";
import { useMicrophoneLevel } from "./useMicrophoneLevel";
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
// The capture itself, and releasing it, belong to observeMicrophoneState$ and
// are covered in src/state/MicrophoneLevel.test.ts. What is left here is the
// bridging: when the hook watches, and what it reports before it has an answer.
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: the meter reads the new device or nothing at all.
expect(result.current).toEqual({ type: "level", level: 0 });
});
});
+41
View File
@@ -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 { useEffect, useState } from "react";
import {
type MicrophoneState,
observeMicrophoneState$,
} from "../state/MicrophoneLevel";
const IDLE: MicrophoneState = { type: "level", level: 0 };
/**
* Reads the live input level of a microphone, while `active`.
*
* A bridge and nothing else: the capture, its lifetime and the maths belong to
* {@link observeMicrophoneState$}. Scoped to `active` so the device is held
* only while whatever shows the meter is on screen, rather than for the length
* of a call.
*/
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 starts from nothing rather than from the
// level the previous one was reading.
setState(IDLE);
const subscription = observeMicrophoneState$(deviceId).subscribe(setState);
return (): void => subscription.unsubscribe();
}, [deviceId, active]);
return state;
}
+75 -3
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.
*/
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { of } from "rxjs";
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
import { TooltipProvider } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk";
@@ -19,6 +21,7 @@ import {
import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
import { mockMediaDevices, mockMuteStates } from "../utils/test";
import { type MediaDevices } from "../state/MediaDevices";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type ProcessorState } from "../livekit/TrackProcessorContext";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
@@ -77,9 +80,10 @@ function renderLobbyView(
props: Partial<Parameters<typeof LobbyView>[0]> = {},
withAppBar = false,
platform = "android",
devices: Partial<MediaDevices> = {},
): ReturnType<typeof render> {
platformMock.mockReturnValue(platform);
const mediaDevices = mockMediaDevices({});
const mediaDevices = mockMediaDevices(devices);
const muteStates = mockMuteStates();
const hideHeader = withAppBar ? true : false;
const lobbyView = (
@@ -178,3 +182,71 @@ describe("LobbyView", () => {
expect(await axe(container)).toHaveNoViolations();
});
});
describe("LobbyView microphone level", () => {
const realMediaDevices = Object.getOwnPropertyDescriptor(
navigator,
"mediaDevices",
);
afterEach(() => {
vi.unstubAllGlobals();
// Put navigator back, or every later test in the run inherits the stub.
if (realMediaDevices === undefined) {
Reflect.deleteProperty(navigator, "mediaDevices");
} else {
Object.defineProperty(navigator, "mediaDevices", realMediaDevices);
}
});
/** Just enough of the Web Audio and capture APIs for the meter to run. */
function stubAudioCapture(): void {
vi.stubGlobal(
"AudioContext",
class {
public readonly state = "running";
public createAnalyser(): object {
return {
fftSize: 1024,
getByteTimeDomainData: (): void => {},
};
}
public createMediaStreamSource(): object {
return { connect: (): void => {} };
}
public close(): void {}
},
);
// Only this property: replacing navigator wholesale drops the getters on
// its prototype, such as userAgent.
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: {
getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [] }),
},
});
}
it("shows the microphone level meter", async () => {
stubAudioCapture();
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>);
// The meter lives with the microphone picker, which the pre-join screen
// reaches through the same chevron as a call in progress.
await user.click(getByRole("button", { name: "Microphone" }));
expect(
await screen.findByRole("meter", { name: "Microphone level" }),
).toBeInTheDocument();
});
});
+147 -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.
*/
import { describe, expect, test, vi } from "vitest";
import { of } from "rxjs";
import { afterEach, describe, expect, test, vi } from "vitest";
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({
@@ -15,9 +14,23 @@ vi.mock("../Platform", () => ({
},
isFirefox: (): boolean => false,
}));
vi.mock("@livekit/components-core", () => ({
createMediaDeviceObserver: () => of([]),
}));
// One observer per device kind, so a test can add and remove hardware.
const observers = vi.hoisted(
() => 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 { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput";
@@ -57,3 +70,132 @@ describe("MediaDevices audio output", () => {
expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput);
});
});
function device(deviceId: string, label: string, groupId = deviceId): object {
return { deviceId, label, groupId, kind: "audioinput" };
}
/** Replaces the hardware of one kind, as the browser would report it. */
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 on the same machine 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"]);
// A headset is plugged in.
setDevices("audioinput", [
device("mic1", "Microphone 1"),
device("mic2", "Headset"),
]);
expect([...devices.audioInput.available$.value.keys()]).toEqual([
"mic1",
"mic2",
]);
// And unplugged again.
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");
// The headset is unplugged mid-call.
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")]);
// Remembered from a previous call, on hardware that is not here now.
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();
// The browser withholds names until permission has been 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 follows the operating system and re-points when it changes, so
// it is its own choice rather than an alias for the device it resolves to.
// It carries no name of its own precisely because which device it resolves
// to is not knowable from here.
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");
});
});
+162
View File
@@ -0,0 +1,162 @@
/*
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,
METER_SEGMENTS,
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", () => {
// Without a noise floor these light the first segments permanently, which
// reads as "it can hear me" when nobody is speaking.
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 should reach the middle of the meter, not scrape along
// the floor: a meter that barely moves reads as a broken microphone.
expect(segmentsForVolume(0.2)).toBeGreaterThanOrEqual(METER_SEGMENTS / 4);
});
test("never exceeds the meter", () => {
expect(segmentsForVolume(1)).toBe(METER_SEGMENTS);
expect(segmentsForVolume(4)).toBe(METER_SEGMENTS);
});
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 there within one attack time constant, so speech does
// not lag the speaker.
expect(smoothVolume(0, 1, ATTACK_MS)).toBeGreaterThan(0.6);
});
test("rides over the gaps between words", () => {
// A pause of a few tens of milliseconds should not collapse the meter, or
// it flickers rather than reading as a level.
expect(smoothVolume(1, 0, 30)).toBeGreaterThan(0.7);
// A real silence still 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 user gives up on the permission prompt and closes the menu, and only
// then does the browser hand 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("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();
let emissions = 0;
const subscription = observeMicrophoneState$("mic1").subscribe(
() => emissions++,
);
capture.grant();
await vi.waitFor(() => expect(emissions).toBe(1));
// The analyser is read every animation frame, but the meter has only
// METER_SEGMENTS steps: a steady signal must not redraw the meter.
capture.drawFrames(20);
expect(emissions).toBe(1);
subscription.unsubscribe();
});
});
/** An error with the `name` the browser would give it, not just a message. */
function named(error: Error, name: string): Error {
error.name = name;
return error;
}
+192
View File
@@ -0,0 +1,192 @@
/*
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 { distinctUntilChanged, Observable } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
/**
* What the microphone selector can say about the input, beyond its level.
*
* Silence and a broken microphone look identical on a meter, so the states a
* user has to act on are named rather than drawn as a flat bar.
*/
export type MicrophoneState =
| { type: "level"; level: number }
| { type: "permission-denied" }
| { type: "no-device" };
/**
* Steps the meter is drawn in. The level is quantised to these.
*
* Enough of them that they sit close together across the width of the menu:
* the bars keep a fixed size, so too few leaves visible gaps between them.
*/
export const METER_SEGMENTS = 24;
/**
* Observes what a microphone is picking up, as the meter should show it.
*
* Subscribing opens the device and unsubscribing releases it, so it is held for
* exactly as long as something is watching — the menu, and not the call.
*
* Opens a capture of its own rather than reading the track the call already
* holds. That is a shortcut, recorded as S1 in the feature spec: the pre-join
* screen freezes its audio track to the device selected when it mounted, so a
* meter fed from that track could not follow the picker.
*
* The level says whether the microphone is picking anything up, which is not
* the same as whether the user is being heard: it keeps reading while muted,
* and the mute control is what says nothing is transmitted.
*/
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;
// Safe to call more than once: unsubscribing runs it, and `start` runs it
// again for anything the browser handed over after that.
const release = (): void => {
if (frame !== undefined) cancelAnimationFrame(frame);
stream?.getTracks().forEach((track) => track.stop());
void context?.close();
frame = undefined;
stream = undefined;
context = undefined;
};
const start = async (): Promise<void> => {
stream = await navigator.mediaDevices.getUserMedia({
audio:
deviceId === undefined ? true : { deviceId: { exact: deviceId } },
});
// A permission prompt outlives the subscription that asked for it, so by
// now nobody may be watching — and `release` ran while `stream` was still
// undefined. Nothing will call it again, so release here or the device
// stays held, with the indicator lit and no meter on screen.
if (subscriber.closed) return release();
context = new AudioContext();
// Chrome starts the context suspended unless it was created during a
// gesture; opening the menu is one, but resume explicitly so the meter
// cannot silently sit at zero.
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 read = (): void => {
analyser.getByteTimeDomainData(samples);
// Root mean square of the waveform around its centre, which is the
// loudness a listener perceives rather than the tallest spike.
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;
subscriber.next({ type: "level", level: segmentsForVolume(displayed) });
frame = requestAnimationFrame(read);
};
read();
};
start().catch((e: unknown) => subscriber.next(stateForFailure(e)));
return release;
}).pipe(
// Read every animation frame, but quantised to a whole number of segments,
// so most frames say nothing new and should not reach React.
distinctUntilChanged(
(a, b) =>
a.type === b.type &&
(a.type !== "level" || b.type !== "level" || a.level === b.level),
),
);
}
/** 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" };
}
/**
* Loudness below which the microphone is treated as picking up nothing.
*
* A quiet room is never digitally silent, and without a floor that hiss lights
* the first segments permanently — which reads as "it can hear me" when nobody
* is speaking.
*/
const NOISE_FLOOR = 0.02;
/**
* Quantises a 0..1 volume to a whole number of meter segments.
*
* Exported for the tests: the mapping from loudness to segments is the part
* that decides whether quiet, normal and loud speech look different.
*/
export function segmentsForVolume(volume: number): number {
if (!Number.isFinite(volume) || volume <= NOISE_FLOOR) return 0;
// Volume arrives as amplitude, where speech occupies a small part of the top
// of the range. A square root spreads that out, so ordinary speech moves the
// meter through its middle rather than barely leaving the floor.
const aboveFloor = (Math.min(volume, 1) - NOISE_FLOOR) / (1 - NOISE_FLOOR);
return Math.min(
METER_SEGMENTS,
Math.ceil(Math.sqrt(aboveFloor) * METER_SEGMENTS),
);
}
/**
* How quickly the meter follows a rise in loudness, as a time constant in
* milliseconds. Short, so a syllable registers the moment it starts.
*/
export const ATTACK_MS = 50;
/**
* How quickly the meter follows a fall. Longer than the attack: speech is full
* of gaps a few tens of milliseconds long, and a meter that tracked them
* exactly would flicker rather than read as a level.
*/
export const RELEASE_MS = 120;
/**
* Moves a displayed level towards a new reading, fast upwards and slowly
* downwards.
*
* Framed in elapsed time rather than frames, so the meter behaves the same on a
* 60Hz and a 120Hz display, and does not jump when a frame is dropped.
*/
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;
}
+90
View File
@@ -8,6 +8,7 @@ import { map, type Observable, of, type SchedulerLike } from "rxjs";
import { type RunHelpers, TestScheduler } from "rxjs/testing";
import {
expect,
type Mock,
type MockedObject,
type MockInstance,
onTestFinished,
@@ -590,3 +591,92 @@ export class MockConnection extends Connection {
public async start(): Promise<void> {}
public async stop(): Promise<void> {}
}
export interface StubbedCapture {
getUserMedia: Mock;
/** Hands the microphone over, as the browser does once permission is given. */
grant: () => void;
track: { stop: Mock };
contexts: { close: Mock }[];
/** Runs the animation frames the level meter reads on, in order. */
drawFrames: (count: number) => void;
}
/**
* Stubs just enough of the capture and Web Audio APIs for a microphone level to
* be read, with the grant held back so a test decides when — or whether — it
* lands, and with animation frames driven by hand rather than by a clock.
*
* Call {@link restoreAudioCapture} afterwards, or every later test in the run
* inherits the stub.
*/
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[] = [];
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,
// Digital silence, which is the midpoint of the range and not zero:
// a buffer left at zero reads as a full-scale waveform.
getByteTimeDomainData: (samples: Uint8Array): void => {
samples.fill(128);
},
};
}
public createMediaStreamSource(): object {
return { connect: (): void => {} };
}
},
);
// Only this property: replacing navigator wholesale drops the getters on its
// prototype, such as 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);
},
};
}
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);
}
}