From 2856f555e13a50d456a9592119292d767ec53750 Mon Sep 17 00:00:00 2001 From: fkwp Date: Thu, 10 Sep 2026 14:19:36 +0200 Subject: [PATCH] Add the speaker group to the microphone menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- locales/en/app.json | 3 + playwright/audio-menu.spec.ts | 74 +++++ src/components/CallFooter.stories.tsx | 69 ++++- src/components/CallFooter.test.tsx | 124 +++++++++ src/components/CallFooter.tsx | 31 ++- src/components/CallFooterViewModel.test.ts | 62 ++++- src/components/CallFooterViewModel.tsx | 44 ++- .../MediaMuteAndSwitchButton.module.css | 12 + .../MediaMuteAndSwitchButton.test.tsx | 155 ++++++++++- src/components/MediaMuteAndSwitchButton.tsx | 125 ++++++++- .../__snapshots__/InCallView.test.tsx.snap | 91 ++++--- .../__snapshots__/LobbyView.test.tsx.snap | 255 ++++++++++++------ 12 files changed, 924 insertions(+), 121 deletions(-) create mode 100644 playwright/audio-menu.spec.ts create mode 100644 src/components/CallFooter.test.tsx diff --git a/locales/en/app.json b/locales/en/app.json index f3d568bb8..103d74238 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -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 and our <6>Cookie Policy.", + "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?<1>You'll be able to keep your name and set an avatar for use on future calls", diff --git a/playwright/audio-menu.spec.ts b/playwright/audio-menu.spec.ts new file mode 100644 index 000000000..297e7c435 --- /dev/null +++ b/playwright/audio-menu.spec.ts @@ -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 { + 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"); +} diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index 3c3f46074..a0375d562 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -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; @@ -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); + }, +}; diff --git a/src/components/CallFooter.test.tsx b/src/components/CallFooter.test.tsx new file mode 100644 index 000000000..d7be700db --- /dev/null +++ b/src/components/CallFooter.test.tsx @@ -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, +
+ `Speaker ${n}`} + /> + , + ); + + // 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( + + + => Promise.resolve(), + sendReaction: async (): Promise => Promise.resolve(), + }} + > + + {settings} + + + , + ); + } + + /** 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([ + ["out-1", { type: "name", name: "Built-in Speakers" }], + ["out-2", { type: "name", name: "Headset" }], + ]), + ), + selected$, + select: (id: string): void => + selected$.next({ id, virtualEarpiece: false }), + }, + }); + } +}); diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index 4f79f236c..b9a259ec3 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -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 = ({ 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 = ({ 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 = ({ ); } - 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( = ({ options={audioOptions} selectedOption={selectedAudio} onSelect={selectAudioButtonOption} + audioControls={audioControls} />, ); } else { diff --git a/src/components/CallFooterViewModel.test.ts b/src/components/CallFooterViewModel.test.ts index 9e73393be..42ac5dbc8 100644 --- a/src/components/CallFooterViewModel.test.ts +++ b/src/components/CallFooterViewModel.test.ts @@ -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([ + ["", { 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 { + 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); diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx index a2ca6c88e..c85a24b15 100644 --- a/src/components/CallFooterViewModel.tsx +++ b/src/components/CallFooterViewModel.tsx @@ -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)), }; } diff --git a/src/components/MediaMuteAndSwitchButton.module.css b/src/components/MediaMuteAndSwitchButton.module.css index e5bba2383..408edf024 100644 --- a/src/components/MediaMuteAndSwitchButton.module.css +++ b/src/components/MediaMuteAndSwitchButton.module.css @@ -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); +} diff --git a/src/components/MediaMuteAndSwitchButton.test.tsx b/src/components/MediaMuteAndSwitchButton.test.tsx index fcc9f0b4e..c6c05ebc1 100644 --- a/src/components/MediaMuteAndSwitchButton.test.tsx +++ b/src/components/MediaMuteAndSwitchButton.test.tsx @@ -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 { + 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> { + const user = userEvent.setup(); + renderComponent( + , + ); + await user.click(screen.getByRole("button", { name: "Microphone" })); + await screen.findByRole("menu"); + return user; + } +}); diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index 3e344dd3a..b0bba7811 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -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 = ({ videoBlurEnabled, videoBlurToggleClick, onSelect, + audioControls, }) => { const [plannedSelection, setPlannedSelection] = useState(null); const [menuOpen, setMenuOpen] = useState(false); @@ -223,6 +255,16 @@ export const MediaMuteAndSwitchButton: FC = ({ ); })} + {audioControls && ( + <> +
+ + + )} {(toggles?.length ?? 0) > 0 &&
} {toggles?.map((toggle) => ( = ({ ); }; + +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 = ( + + ); + + if (options.length <= 1) { + const only = options[0]; + return ( +
+ {icon} + + {only ? labelText(only.label) : t("settings.devices.default")} + +
+ ); + } + + return ( + <> + {options.map(({ id, label }) => ( + { + e.preventDefault(); + if (id !== selected) onSelect(id); + }} + role="menuitemradio" + aria-checked={selected === id} + > + {selected === id && } + + ))} + + ); +} diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index b7f8b3fe3..b9b7ba551 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -328,36 +328,67 @@ exports[`InCallView > rendering > renders 1`] = ` /> - + + + + - + + + + - + + + + - + + + +