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