Add the speaker group to the microphone menu

The chevron beside the microphone button now opens a menu headed "Audio
controls" that lists the microphones and, below a separator, the audio
outputs. Selecting either switches the device and keeps the menu open.
Where the browser offers no choice of output, or only one, the speaker
group still names the output in use as a non-selectable row.

The footer view model derives the outputs from the same MediaDevices
helper as the device lists, so the menu reaches both surfaces at once:
in a call on desktop, and before joining on every platform.

Spec: FEATURES_SPEC/2026-09_Audio_Quick_Menu.md, slice 1 — AC1, AC2, AC3,
AC5, AC6, AC7, AC25.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-09-10 23:53:36 +02:00
co-authored by Claude Opus 5
parent 17ff9b1343
commit 2856f555e1
12 changed files with 924 additions and 121 deletions
+3
View File
@@ -23,6 +23,9 @@
"upload_file": "Upload file"
},
"analytics_notice": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <6>Cookie Policy</6>.",
"audio_menu": {
"title": "Audio controls"
},
"call_ended_view": {
"create_account_button": "Create account",
"create_account_prompt": "<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>",
+74
View File
@@ -0,0 +1,74 @@
/*
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, test } from "@playwright/test";
import { SpaHelpers } from "./spa-helpers";
test("audio menu leaves participants visible while switching output", async ({
browser,
browserName,
}) => {
test.skip(
browserName === "firefox",
"Firefox's fake media stream enumerates no audio outputs, so there is nothing to switch.",
);
// Reduced motion disables the animations that make these tests flaky.
const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
const page = await creatorContext.newPage();
await page.goto("/");
await SpaHelpers.createCall(page, "Inviter", "Audio menu", true);
const inviteLink = await SpaHelpers.getCallInviteLink(page);
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
const guestPage = await guestContext.newPage();
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Guest");
await SpaHelpers.expectVideoTilesCount(page, 2);
// The chevron beside the microphone button opens the audio menu in place.
await page.getByRole("button", { name: "Microphone" }).click();
const menu = page.getByRole("menu");
await expect(
menu.getByRole("heading", { name: "Audio controls" }),
).toBeVisible();
// Pick another output where the browser offers one; where it does not, the
// speaker group still names the output in use.
const outputs = menu
.getByTestId("audio_menu_scroll")
.locator('> [role="menuitemradio"]');
const count = await outputs.count();
if (count > 1) {
// Pinned by position: a locator on "the unchecked row" would re-resolve to
// the previously active row once the click has moved the check mark.
const other = outputs.nth(await firstUncheckedIndex(outputs, count));
await other.click();
await expect(other).toHaveAttribute("aria-checked", "true");
await expect(menu).toBeVisible();
} else {
await expect(menu.getByTestId("speaker_readonly")).toBeVisible();
}
// Nothing covered the call: the other participant is still on screen with
// the menu open, and after it closes.
await SpaHelpers.expectVideoTilesCount(page, 2);
await page.keyboard.press("Escape");
await expect(menu).not.toBeVisible();
await SpaHelpers.expectVideoTilesCount(page, 2);
});
async function firstUncheckedIndex(
rows: Locator,
count: number,
): Promise<number> {
for (let i = 0; i < count; i++) {
if ((await rows.nth(i).getAttribute("aria-checked")) === "false") return i;
}
throw new Error("every output row is marked active");
}
+68 -1
View File
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, fn, userEvent, within } from "storybook/test";
import { expect, fn, screen, userEvent, within } from "storybook/test";
import { BehaviorSubject } from "rxjs";
import { type JSX, type ReactNode } from "react";
import { Link } from "@vector-im/compound-web";
@@ -103,6 +103,7 @@ const meta = {
toggleAudio: fnArgType,
toggleVideo: fnArgType,
hangup: fnArgType,
selectAudioOutputOption: fnArgType,
},
} satisfies Meta<typeof CallFooterStoryWrapper>;
@@ -142,6 +143,9 @@ export const Default: Story = {
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectVideoButtonOption: undefined,
audioOutputOptions: [],
selectedAudioOutput: undefined,
selectAudioOutputOption: undefined,
},
parameters: {
layout: "fullscreen",
@@ -415,3 +419,66 @@ export const LobbyRecentButtonMobile: Story = {
...Default.parameters,
},
};
/** The microphone chevron opens the audio menu: microphones and speakers. */
export const WithAudioMenu: Story = {
...Default,
args: {
...Default.args,
audioEnabled: true,
audioOptions: [
{ label: { type: "name", name: "MacBook Pro Microphone" }, id: "1" },
{ label: { type: "name", name: "Jabra Evolve 65" }, id: "2" },
],
selectedAudio: "1",
selectAudioButtonOption: fn(),
audioOutputOptions: [
{ label: { type: "default", name: "MacBook Pro Speakers" }, id: "" },
{ label: { type: "name", name: "Jabra Evolve 65" }, id: "2" },
],
selectedAudioOutput: "",
selectAudioOutputOption: fn(),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const menu = within(await screen.findByRole("menu"));
await expect(
menu.getByRole("heading", { name: "Audio controls" }),
).toBeInTheDocument();
// The headset appears in both groups under the same name, as a real one
// does. The speaker rows are the scroll area's own children; the
// microphone rows sit one level deeper, inside their group.
const scroll = menu.getByTestId("audio_menu_scroll");
const outputs = [...scroll.children].filter(
(el) => el.getAttribute("role") === "menuitemradio",
);
await userEvent.click(outputs[1]);
await expect(args.selectAudioOutputOption).toHaveBeenCalledWith("2");
// The menu stays open after a selection.
await expect(screen.getByRole("menu")).toBeInTheDocument();
},
};
/** One output only: the speaker group names it without offering a choice. */
export const WithSingleAudioOutput: Story = {
...WithAudioMenu,
args: {
...WithAudioMenu.args,
audioOutputOptions: [
{ label: { type: "default", name: "MacBook Pro Speakers" }, id: "" },
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
await expect(
await screen.findByTestId("speaker_readonly"),
).toHaveTextContent("MacBook Pro Speakers");
await expect(
screen.queryByRole("menuitemradio", { name: /Speakers/ }),
).toBe(null);
},
};
+124
View File
@@ -0,0 +1,124 @@
/*
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, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BehaviorSubject } from "rxjs";
import { Root as Form, TooltipProvider } from "@vector-im/compound-web";
import { type ReactNode } from "react";
import { CallFooter } from "./CallFooter";
import { DeviceSelection } from "../settings/DeviceSelection";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
import {
type AudioOutputDeviceLabel,
type MediaDevices,
type SelectedAudioOutputDevice,
} from "../state/MediaDevices";
import { constant } from "../state/Behavior";
import { mockMediaDevices } from "../utils/test";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { alice, local } from "../utils/test-fixtures";
describe("CallFooter", () => {
test("audio menu is headed Audio controls", async () => {
const user = userEvent.setup();
renderFooter(mediaDevicesWithOutputs());
await user.click(screen.getByRole("button", { name: "Microphone" }));
const menu = await screen.findByRole("menu");
expect(
within(menu).getByRole("heading", { name: "Audio controls" }),
).toBeInTheDocument();
});
test("audio output selection is shared between menu and settings", async () => {
const user = userEvent.setup();
const mediaDevices = mediaDevicesWithOutputs();
renderFooter(
mediaDevices,
<Form>
<DeviceSelection
device={mediaDevices.audioOutput}
title="Speaker"
numberedLabel={(n) => `Speaker ${n}`}
/>
</Form>,
);
// Chosen in the menu, shown in settings. The open menu hides the rest of
// the page from assistive technology, so it closes before settings is read.
await user.click(screen.getByRole("button", { name: "Microphone" }));
await user.click(
await screen.findByRole("menuitemradio", { name: "Headset" }),
);
await user.keyboard("[Escape]");
expect(screen.getByRole("radio", { name: "Headset" })).toBeChecked();
// Chosen in settings, shown in the menu.
await user.click(screen.getByRole("radio", { name: "Built-in Speakers" }));
await user.click(screen.getByRole("button", { name: "Microphone" }));
expect(
await screen.findByRole("menuitemradio", { name: "Built-in Speakers" }),
).toHaveAttribute("aria-checked", "true");
expect(
screen.getByRole("menuitemradio", { name: "Headset" }),
).toHaveAttribute("aria-checked", "false");
});
/** The in-call footer over its real view model, with settings beside it. */
function renderFooter(
mediaDevices: MediaDevices,
settings?: ReactNode,
): void {
const { footerVm } = getBasicCallViewModelEnvironment(
[local, alice],
undefined,
mediaDevices,
);
render(
<TooltipProvider>
<MediaDevicesContext value={mediaDevices}>
<ReactionsSenderContext
value={{
supportsReactions: false,
toggleRaisedHand: async (): Promise<void> => Promise.resolve(),
sendReaction: async (): Promise<void> => Promise.resolve(),
}}
>
<CallFooter vm={footerVm} />
{settings}
</ReactionsSenderContext>
</MediaDevicesContext>
</TooltipProvider>,
);
}
/** Two outputs whose selection is one shared value, as in MediaDevices. */
function mediaDevicesWithOutputs(): MediaDevices {
const selected$ = new BehaviorSubject<
SelectedAudioOutputDevice | undefined
>({ id: "out-1", virtualEarpiece: false });
return mockMediaDevices({
requestDeviceNames: vi.fn(),
audioOutput: {
available$: constant(
new Map<string, AudioOutputDeviceLabel>([
["out-1", { type: "name", name: "Built-in Speakers" }],
["out-2", { type: "name", name: "Headset" }],
]),
),
selected$,
select: (id: string): void =>
selected$.next({ id, virtualEarpiece: false }),
},
});
}
});
+29 -2
View File
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type FC, type JSX, type Ref, useMemo } from "react";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
@@ -25,6 +26,7 @@ import styles from "./CallFooter.module.css";
import {
MediaMuteAndSwitchButton,
type MenuOptions,
type OutputMenuOptions,
} from "./MediaMuteAndSwitchButton";
import { type Behavior } from "../state/Behavior";
import { type ViewModel } from "../state/ViewModel";
@@ -105,6 +107,15 @@ export interface FooterState {
selectedVideo: string | undefined;
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
selectVideoButtonOption: ((option: string) => void) | undefined;
/**
* The audio outputs offered by the audio menu. Empty where the browser does
* not allow choosing one; the menu then names the default output.
*/
audioOutputOptions: OutputMenuOptions[];
selectedAudioOutput: string | undefined;
/** Also controls whether the microphone chevron opens the audio menu */
selectAudioOutputOption: ((deviceId: string) => void) | undefined;
}
export interface FooterProps {
@@ -119,6 +130,7 @@ export const CallFooter: FC<FooterProps> = ({
children,
vm,
}) => {
const { t } = useTranslation();
const asOverlay = useBehavior(vm.asOverlay$);
const showFooter = useBehavior(vm.showFooter$);
const hideControls = useBehavior(vm.hideControls$);
@@ -144,6 +156,9 @@ export const CallFooter: FC<FooterProps> = ({
const selectedAudio = useBehavior(vm.selectedAudio$);
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
const audioOutputOptions = useBehavior(vm.audioOutputOptions$);
const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$);
const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$);
const toggleBlur = useBehavior(vm.toggleBlur$);
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
const buttonSize = useBehavior(vm.buttonSize$);
@@ -165,10 +180,21 @@ export const CallFooter: FC<FooterProps> = ({
);
}
if ((audioOptions?.length ?? 0) > 0) {
// The audio menu exists wherever an output can be selected. It names the
// output in use even when there is no microphone to list.
const audioControls =
selectAudioOutputOption === undefined
? undefined
: {
outputOptions: audioOutputOptions ?? [],
selectedOutput: selectedAudioOutput,
onSelectOutput: selectAudioOutputOption,
};
if ((audioOptions?.length ?? 0) > 0 || audioControls !== undefined) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Mic Source"}
title={audioControls ? t("audio_menu.title") : "Mic Source"}
key="audio"
iconsAndLabels="audio"
enabled={audioEnabled ?? false}
@@ -178,6 +204,7 @@ export const CallFooter: FC<FooterProps> = ({
options={audioOptions}
selectedOption={selectedAudio}
onSelect={selectAudioButtonOption}
audioControls={audioControls}
/>,
);
} else {
+61 -1
View File
@@ -13,9 +13,14 @@ import { constant } from "../state/Behavior";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import type { Alignment, Layout } from "../state/layout-types";
import type { SpotlightTileViewModel } from "../state/TileViewModel";
import type { DeviceLabel } from "../state/MediaDevices";
import type {
AudioOutputDeviceLabel,
DeviceLabel,
} from "../state/MediaDevices";
import { createCallFooterViewModel } from "./CallFooterViewModel";
import { HeaderStyle } from "../UrlParams";
import { type FooterSnapshot } from "./CallFooter";
import { type ViewModel } from "../state/ViewModel";
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({
@@ -95,7 +100,62 @@ const twoMicsAndOneCamMediaDevices = mockMediaDevices({
},
});
const selectOutput = vi.fn();
const twoOutputsMediaDevices = mockMediaDevices({
audioOutput: {
available$: constant(
new Map<string, AudioOutputDeviceLabel>([
["", { type: "default", name: "Built-in Speakers" }],
["out2", { type: "name", name: "Headset" }],
]),
),
selected$: constant({ id: "out2", virtualEarpiece: false }),
select: selectOutput,
},
});
describe("createCallFooterViewModel", () => {
describe("audio output", () => {
function createVm(
platform: string,
layout: Layout,
): ViewModel<FooterSnapshot> {
platformMock.mockReturnValue(platform);
return createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(layout),
mockMuteStates(),
twoOutputsMediaDevices,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
}
it("offers no audio menu when the platform is iOS", () => {
const vm = createVm("ios", gridLayout);
expect(vm.audioOutputOptions$.value).toEqual([]);
expect(vm.selectAudioOutputOption$.value).toBeUndefined();
});
it("offers no audio menu when the layout is pip", () => {
const vm = createVm("desktop", pipLayout);
expect(vm.audioOutputOptions$.value).toEqual([]);
expect(vm.selectAudioOutputOption$.value).toBeUndefined();
});
it("lists the outputs and the selection on desktop", () => {
const vm = createVm("desktop", gridLayout);
expect(vm.audioOutputOptions$.value).toEqual([
{ id: "", label: { type: "default", name: "Built-in Speakers" } },
{ id: "out2", label: { type: "name", name: "Headset" } },
]);
expect(vm.selectedAudioOutput$.value).toBe("out2");
vm.selectAudioOutputOption$.value?.("");
expect(selectOutput).toHaveBeenCalledWith("");
});
});
describe("audioOptions and videoOptions", () => {
function checkEmptyFor(platform: string, layout: Layout): void {
platformMock.mockReturnValue(platform);
+41 -3
View File
@@ -9,7 +9,10 @@ import { combineLatest, map, switchMap } from "rxjs";
import { supportsBackgroundProcessors } from "@livekit/track-processors";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
import {
type MenuOptions,
type OutputMenuOptions,
} from "./MediaMuteAndSwitchButton";
import { type MediaDevices } from "../state/MediaDevices";
import {
backgroundBlur as backgroundBlurSettings,
@@ -54,8 +57,10 @@ function buildMuteBehaviors(
}
/**
* Shared helper: maps MediaDevices into the audio/video device-list behaviors
* needed by FooterSnapshot (options, selection, callbacks, blur toggle).
* Shared helper: maps MediaDevices into every device behavior FooterSnapshot
* needs, for the switcher menus and the audio menu alike. The output list is
* empty wherever the browser cannot switch output; the menu then names the
* default rather than hiding the group.
*/
function buildDeviceBehaviors(
scope: ObservableScope,
@@ -72,6 +77,9 @@ function buildDeviceBehaviors(
| "selectVideoButtonOption$"
| "toggleBlur$"
| "videoBlurEnabled$"
| "audioOutputOptions$"
| "selectedAudioOutput$"
| "selectAudioOutputOption$"
> {
return {
audioOptions$: scope.behavior(
@@ -126,6 +134,34 @@ function buildDeviceBehaviors(
),
),
videoBlurEnabled$: backgroundBlurSettings.value$,
audioOutputOptions$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as OutputMenuOptions[])
: mediaDevices.audioOutput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label,
})),
),
),
),
),
),
selectedAudioOutput$: scope.behavior(
mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)),
),
selectAudioOutputOption$: scope.behavior(
disableSwitcher$.pipe(
map((disable) =>
disable
? undefined
: (id: string): void => mediaDevices.audioOutput.select(id),
),
),
),
};
}
@@ -270,6 +306,8 @@ export function createLobbyFooterViewModel(
selectVideoButtonOption: undefined,
}),
...buildMuteBehaviors(scope, muteStates),
// Nothing is gated before joining: the chevron already exists on every
// platform there, and a device check matters most before a call starts.
...buildDeviceBehaviors(scope, mediaDevices, constant(false)),
};
}
@@ -35,3 +35,15 @@ Please see LICENSE in the repository root for full details.
transform: rotate(360deg);
}
}
/* The speaker group's one row where there is no choice to make: it names the
output in use wherever audio cannot be redirected. Laid out like a menu item
so it lines up with the rows around it. */
.readOnlyRow {
display: flex;
align-items: center;
gap: var(--cpd-space-4x);
padding: var(--cpd-space-3x) var(--cpd-space-4x);
color: var(--cpd-color-text-primary);
font: var(--cpd-font-body-md-regular);
}
@@ -11,7 +11,10 @@ import userEvent from "@testing-library/user-event";
import { type JSX, useState, type ReactNode } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
import {
MediaMuteAndSwitchButton,
type AudioControls,
} from "./MediaMuteAndSwitchButton";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type MediaDevices } from "../state/MediaDevices";
@@ -358,3 +361,153 @@ describe("MediaMuteAndSwitchButton", () => {
expect(mic2Item.querySelectorAll("svg").length).toBe(1);
});
});
describe("audio menu", () => {
test("audio menu switches microphone and stays open", async () => {
const onSelect = vi.fn();
const user = await openAudioMenu({ onSelect });
await user.click(
screen.getByRole("menuitemradio", { name: "Headset Microphone" }),
);
expect(onSelect).toHaveBeenCalledWith("mic-2");
// Selecting a device must not dismiss the menu: the user needs to see the
// choice take effect and may want to change it again.
expect(screen.getByRole("menu")).toBeInTheDocument();
});
test("audio menu switches audio output and stays open", async () => {
const controls = audioControls();
const user = await openAudioMenu({ audioControls: controls });
await user.click(screen.getByRole("menuitemradio", { name: "Headset" }));
expect(controls.onSelectOutput).toHaveBeenCalledWith("out-2");
expect(screen.getByRole("menu")).toBeInTheDocument();
});
test("audio menu marks the active output below the microphones", async () => {
await openAudioMenu();
expect(
screen.getByRole("menuitemradio", { name: "Built-in Speakers" }),
).toHaveAttribute("aria-checked", "true");
expect(
screen.getByRole("menuitemradio", { name: "Headset" }),
).toHaveAttribute("aria-checked", "false");
// One rule between the microphone group and the speaker group.
expect(screen.getByRole("menu").querySelectorAll("hr")).toHaveLength(1);
});
test("audio menu does not reselect the active output", async () => {
const controls = audioControls();
const user = await openAudioMenu({ audioControls: controls });
await user.click(
screen.getByRole("menuitemradio", { name: "Built-in Speakers" }),
);
expect(controls.onSelectOutput).not.toHaveBeenCalled();
});
test("audio menu shows single audio output as non-selectable", async () => {
await openAudioMenu({
audioControls: audioControls({
outputOptions: [
{ label: { type: "name", name: "Built-in Speakers" }, id: "out-1" },
],
}),
});
expect(screen.getByTestId("speaker_readonly")).toHaveTextContent(
"Built-in Speakers",
);
// The one output must not present itself as a choice.
expect(
screen.queryByRole("menuitemradio", { name: "Built-in Speakers" }),
).toBe(null);
});
test("audio menu names the default output where none can be chosen", async () => {
await openAudioMenu({
audioControls: audioControls({ outputOptions: [] }),
});
expect(screen.getByTestId("speaker_readonly")).toHaveTextContent("Default");
});
test("audio menu labels every kind of output", async () => {
await openAudioMenu({
audioControls: audioControls({
outputOptions: [
{ label: { type: "default", name: "Built-in Speakers" }, id: "" },
{ label: { type: "default", name: null }, id: "default" },
{ label: { type: "number", number: 2 }, id: "out-2" },
{ label: { type: "speaker" }, id: "speaker" },
{ label: { type: "earpiece" }, id: "earpiece" },
],
selectedOutput: "",
}),
});
for (const name of [
"Default (Built-in Speakers)",
"Default",
"Speaker 2",
"Loudspeaker",
"Handset",
])
expect(screen.getByRole("menuitemradio", { name })).toBeInTheDocument();
});
const micOptions = [
{
label: { type: "name" as const, name: "Built-in Microphone" },
id: "mic-1",
},
{
label: { type: "name" as const, name: "Headset Microphone" },
id: "mic-2",
},
];
function audioControls(over: Partial<AudioControls> = {}): AudioControls {
return {
outputOptions: [
{
label: { type: "name" as const, name: "Built-in Speakers" },
id: "out-1",
},
{ label: { type: "name" as const, name: "Headset" }, id: "out-2" },
],
selectedOutput: "out-1",
onSelectOutput: vi.fn(),
...over,
};
}
/** Renders the microphone button with the audio menu and opens the menu. */
async function openAudioMenu(
props: {
audioControls?: AudioControls;
onSelect?: (id: string) => void;
} = {},
): Promise<ReturnType<typeof userEvent.setup>> {
const user = userEvent.setup();
renderComponent(
<MediaMuteAndSwitchButton
title="Audio controls"
iconsAndLabels="audio"
enabled
onMuteClick={vi.fn()}
options={micOptions}
selectedOption="mic-1"
onSelect={props.onSelect ?? vi.fn()}
audioControls={props.audioControls ?? audioControls()}
/>,
);
await user.click(screen.getByRole("button", { name: "Microphone" }));
await screen.findByRole("menu");
return user;
}
});
+123 -2
View File
@@ -5,7 +5,13 @@ 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 {
type ComponentType,
useState,
type FC,
useEffect,
type JSX,
} from "react";
import {
Button,
Menu,
@@ -19,13 +25,17 @@ import {
MicOnIcon,
SpinnerIcon,
VideoCallIcon,
VolumeOnIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
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";
export interface MenuOptions {
@@ -33,6 +43,25 @@ export interface MenuOptions {
id: string;
}
export interface OutputMenuOptions {
label: AudioOutputDeviceLabel;
id: string;
}
/**
* The controls that turn the microphone chevron menu into the audio menu: a
* speaker group below the microphone list. Absent for the camera menu.
*/
export interface AudioControls {
/**
* The audio outputs to choose from. Empty where the browser does not allow
* choosing one; the menu then names the default output instead.
*/
outputOptions: OutputMenuOptions[];
selectedOutput: string | undefined;
onSelectOutput: (id: string) => void;
}
export interface MediaMuteAndSwitchButtonProps {
/** The title used in the Switcher modal. */
title: string;
@@ -49,6 +78,8 @@ export interface MediaMuteAndSwitchButtonProps {
selectedOption?: string;
videoBlurToggleClick?: () => void;
videoBlurEnabled?: boolean;
/** When present, the menu renders the speaker group below the microphone list. */
audioControls?: AudioControls;
/**
* For any toggle and option this method will be called.
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
@@ -69,6 +100,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
videoBlurEnabled,
videoBlurToggleClick,
onSelect,
audioControls,
}) => {
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
@@ -223,6 +255,16 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
</MenuItem>
);
})}
{audioControls && (
<>
<hr />
<SpeakerSection
options={audioControls.outputOptions}
selected={audioControls.selectedOutput}
onSelect={audioControls.onSelectOutput}
/>
</>
)}
{(toggles?.length ?? 0) > 0 && <hr />}
{toggles?.map((toggle) => (
<ToggleMenuItem
@@ -239,3 +281,82 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
</div>
);
};
interface SpeakerSectionProps {
options: OutputMenuOptions[];
selected: string | undefined;
onSelect: (id: string) => void;
}
/**
* The speaker group of the audio menu.
*
* With more than one output to choose from this is a radio group. With one or
* none it still names the output in use, as a plain row: knowing where audio
* goes is useful even where it cannot be redirected, as in browsers that do
* not support choosing an output at all.
*/
function SpeakerSection({
options,
selected,
onSelect,
}: SpeakerSectionProps): JSX.Element {
const { t } = useTranslation();
const labelText = (label: AudioOutputDeviceLabel): string => {
switch (label.type) {
case "name":
return label.name;
case "number":
return t("settings.devices.speaker_numbered", { n: label.number });
case "speaker":
return t("settings.devices.loudspeaker");
case "earpiece":
return t("settings.devices.handset");
case "default":
return label.name === null
? t("settings.devices.default")
: `${t("settings.devices.default")} (${label.name})`;
}
};
const icon = (
<VolumeOnIcon
width={24}
height={24}
className={styles.itemIcon}
aria-hidden
/>
);
if (options.length <= 1) {
const only = options[0];
return (
<div className={styles.readOnlyRow} data-testid="speaker_readonly">
{icon}
<span>
{only ? labelText(only.label) : t("settings.devices.default")}
</span>
</div>
);
}
return (
<>
{options.map(({ id, label }) => (
<MenuItem
hideChevron
key={id}
label={labelText(label)}
Icon={icon}
onSelect={(e) => {
e.preventDefault();
if (id !== selected) onSelect(id);
}}
role="menuitemradio"
aria-checked={selected === id}
>
{selected === id && <CheckIcon width={24} height={24} aria-hidden />}
</MenuItem>
))}
</>
);
}
+61 -30
View File
@@ -328,36 +328,67 @@ exports[`InCallView > rendering > renders 1`] = `
/>
</svg>
</button>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_l_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="switch"
tabindex="0"
<div
class="_container_e649de"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_l_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="switch"
tabindex="0"
>
<path
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
/>
</svg>
</button>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
/>
</svg>
</button>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Microphone"
class="_button_1nw83_8 _menuButton_e649de _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="tertiary"
data-size="lg"
data-state="closed"
id="radix-_r_q_"
role="button"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</button>
</div>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_q_"
aria-labelledby="_r_s_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
@@ -382,7 +413,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-labelledby="_r_v_"
aria-labelledby="_r_11_"
class="_button_1nw83_8 _raiseHand_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
@@ -405,7 +436,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-labelledby="_r_17_"
aria-labelledby="_r_19_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
@@ -433,8 +464,8 @@ exports[`InCallView > rendering > renders 1`] = `
data-size="lg"
>
<input
aria-labelledby="_r_1d_"
name="_r_1c_"
aria-labelledby="_r_1f_"
name="_r_1e_"
type="radio"
value="spotlight"
/>
@@ -453,9 +484,9 @@ exports[`InCallView > rendering > renders 1`] = `
/>
</svg>
<input
aria-labelledby="_r_1i_"
aria-labelledby="_r_1k_"
checked=""
name="_r_1c_"
name="_r_1e_"
type="radio"
value="grid"
/>
+174 -81
View File
@@ -7,7 +7,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
>
<header>
<button
aria-labelledby="_r_36_"
aria-labelledby="_r_3c_"
class="_icon-button_1215g_8 _primaryButton_221541"
data-kind="primary"
role="button"
@@ -102,7 +102,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
class="_settingsLogoContainer_20b7b4"
>
<button
aria-labelledby="_r_3c_"
aria-labelledby="_r_3i_"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
data-kind="secondary"
data-testid="settings-bottom-left"
@@ -133,7 +133,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
class="_buttons_20b7b4"
>
<button
aria-labelledby="_r_3h_"
aria-labelledby="_r_3n_"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
@@ -154,36 +154,67 @@ exports[`LobbyView > renders with AppBar android 1`] = `
/>
</svg>
</button>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_3m_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="switch"
tabindex="0"
<div
class="_container_e649de"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_3s_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="switch"
tabindex="0"
>
<path
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
/>
</svg>
</button>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
/>
</svg>
</button>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Microphone"
class="_button_1nw83_8 _menuButton_e649de _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="tertiary"
data-size="lg"
data-state="closed"
id="radix-_r_41_"
role="button"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</button>
</div>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_3r_"
aria-labelledby="_r_43_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
@@ -205,7 +236,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
</svg>
</button>
<button
aria-labelledby="_r_40_"
aria-labelledby="_r_48_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
@@ -239,7 +270,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
>
<header>
<button
aria-labelledby="_r_4a_"
aria-labelledby="_r_4i_"
class="_icon-button_1215g_8 _primaryButton_221541"
data-kind="primary"
role="button"
@@ -334,7 +365,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
class="_settingsLogoContainer_20b7b4"
>
<button
aria-labelledby="_r_4g_"
aria-labelledby="_r_4o_"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
data-kind="secondary"
data-testid="settings-bottom-left"
@@ -365,7 +396,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
class="_buttons_20b7b4"
>
<button
aria-labelledby="_r_4l_"
aria-labelledby="_r_4t_"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
@@ -386,36 +417,67 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
/>
</svg>
</button>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_4q_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="switch"
tabindex="0"
<div
class="_container_e649de"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_52_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="switch"
tabindex="0"
>
<path
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
/>
</svg>
</button>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
/>
</svg>
</button>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Microphone"
class="_button_1nw83_8 _menuButton_e649de _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="tertiary"
data-size="lg"
data-state="closed"
id="radix-_r_57_"
role="button"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</button>
</div>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_4v_"
aria-labelledby="_r_59_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
@@ -437,7 +499,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
</svg>
</button>
<button
aria-labelledby="_r_54_"
aria-labelledby="_r_5e_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
@@ -768,36 +830,67 @@ exports[`LobbyView > renders with header and participant count 1`] = `
/>
</svg>
</button>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_g_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="switch"
tabindex="0"
<div
class="_container_e649de"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_g_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="switch"
tabindex="0"
>
<path
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
/>
</svg>
</button>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
/>
</svg>
</button>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Microphone"
class="_button_1nw83_8 _menuButton_e649de _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="tertiary"
data-size="lg"
data-state="closed"
id="radix-_r_l_"
role="button"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</button>
</div>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_l_"
aria-labelledby="_r_n_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
@@ -819,7 +912,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
</svg>
</button>
<button
aria-labelledby="_r_q_"
aria-labelledby="_r_s_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"