From ace7e947b47a2077049b3f0e6306cbdf76346415 Mon Sep 17 00:00:00 2001 From: fkwp Date: Thu, 17 Sep 2026 11:46:41 +0200 Subject: [PATCH] Show a default speaker where the platform lists none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Safari enumerates no output devices at all, so the list arrived empty — and an empty array is truthy, so the Speaker heading rendered with nothing under it, which reads as a broken feature. - Show one entry instead, named as the default, disabled, and marked as the selection: audio is going somewhere, and an unchecked lone row reads as nothing being chosen. - D2 already says a row that cannot be chosen is shown disabled, never hidden. - Covered by its condition rather than by the browser, so Linux CI checks it: one test that the view model hands over an empty list rather than none, one that the menu then draws the default. --- src/components/CallFooterViewModel.test.ts | 40 ++++++++++++++++ .../MediaMuteAndSwitchButton.stories.tsx | 31 ++++++++++++ .../MediaMuteAndSwitchButton.test.tsx | 48 ++++++++++++++++++- src/components/MediaMuteAndSwitchButton.tsx | 29 +++++++++-- 4 files changed, 144 insertions(+), 4 deletions(-) diff --git a/src/components/CallFooterViewModel.test.ts b/src/components/CallFooterViewModel.test.ts index 82d92d395..df23cba22 100644 --- a/src/components/CallFooterViewModel.test.ts +++ b/src/components/CallFooterViewModel.test.ts @@ -126,6 +126,46 @@ describe("createCallFooterViewModel", () => { }); }); + describe("audioOutputOptions", () => { + it("is an empty list, not absent, where the platform enumerates no outputs", () => { + platformMock.mockReturnValue("desktop"); + outputSelectionMock.mockReturnValue(true); + + const vm = createCallFooterViewModel( + testScope(), + buildMinimalCallViewModel(gridLayout), + mockMuteStates(), + mockMediaDevices({ + audioInput: { + available$: constant( + new Map([ + ["mic1", { type: "name", name: "Microphone 1" }], + ]), + ), + selected$: constant(undefined), + select: vi.fn(), + }, + // Safari enumerates no output devices whatsoever. Reproduced by the + // condition rather than by the browser, so it is checked on the + // Linux CI runners that have no Safari to check it with. + audioOutput: { + available$: constant(new Map()), + selected$: constant(undefined), + select: vi.fn(), + }, + }), + /* reactionIdentifier */ undefined, + { showControls: true, header: HeaderStyle.Standard }, + ); + + // Empty rather than undefined: undefined means this menu has no notion + // of outputs at all, as the camera menu has none, and hides the section. + // Empty means there are none to list, and the menu still shows the + // section with a default in it, disabled. + expect(vm.audioOutputOptions$.value).toEqual([]); + }); + }); + describe("audioOptions and videoOptions", () => { function checkEmptyFor(platform: string, layout: Layout): void { platformMock.mockReturnValue(platform); diff --git a/src/components/MediaMuteAndSwitchButton.stories.tsx b/src/components/MediaMuteAndSwitchButton.stories.tsx index 30fc129c1..1bfa7ecd4 100644 --- a/src/components/MediaMuteAndSwitchButton.stories.tsx +++ b/src/components/MediaMuteAndSwitchButton.stories.tsx @@ -197,3 +197,34 @@ export const OnlyOneDevice: Story = { await expect(only).toHaveAttribute("aria-disabled", "true"); }, }; + +/** + * A platform that enumerates no output devices and offers no way to choose one + * — Safari. The section still names where audio is going, disabled, rather than + * leaving a heading with nothing under it. + */ +export const OutputNotEnumerated: Story = { + args: { + ...Default.args, + title: "Microphone", + iconsAndLabels: "audio", + enabled: true, + options: [ + { label: { type: "name", name: "Microphone 1" }, id: "mic1" }, + { label: { type: "name", name: "Microphone 2" }, id: "mic2" }, + ], + selectedOption: "mic1", + outputOptions: [], + selectedOutputOption: undefined, + onSelectOutput: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Microphone" })); + + const speakers = await within(document.body).findByRole("menuitemradio", { + name: "Default", + }); + await expect(speakers).toHaveAttribute("aria-disabled", "true"); + }, +}; diff --git a/src/components/MediaMuteAndSwitchButton.test.tsx b/src/components/MediaMuteAndSwitchButton.test.tsx index f2a55d48e..b466a0496 100644 --- a/src/components/MediaMuteAndSwitchButton.test.tsx +++ b/src/components/MediaMuteAndSwitchButton.test.tsx @@ -7,7 +7,13 @@ Please see LICENSE in the repository root for full details. import { describe, expect, test, vi } from "vitest"; import { axe } from "vitest-axe"; -import { act, render, screen, type RenderResult } from "@testing-library/react"; +import { + act, + render, + screen, + within, + type RenderResult, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { type JSX, useState, type ReactNode } from "react"; import { TooltipProvider } from "@vector-im/compound-web"; @@ -613,6 +619,46 @@ describe("MediaMuteAndSwitchButton", () => { expect(only).toHaveAttribute("aria-disabled", "true"); }); + test("shows a default speaker where the platform lists none", async () => { + const user = userEvent.setup(); + const { getByRole } = renderComponent( + , + ); + + await user.click(getByRole("button", { name: "Microphone" })); + + // A heading with nothing under it says the feature is broken. Audio is + // playing somewhere, so the section names that somewhere and disables it. + const speakers = screen + .getAllByRole("group") + .find((group) => group.getAttribute("aria-label") === "Speaker")!; + const entries = within(speakers).getAllByRole("menuitemradio"); + expect(entries).toHaveLength(1); + expect(entries[0]).toHaveAccessibleName("Default"); + expect(entries[0]).toHaveAttribute("aria-disabled", "true"); + // And marked as the selection: it is where audio is going, so an unchecked + // lone entry would read as nothing being chosen at all. + expect(entries[0]).toHaveAttribute("aria-checked", "true"); + expect( + within(entries[0]).getByRole("radio", { hidden: true }), + ).toBeChecked(); + }); + test("shows the speaker section disabled when output selection is unsupported", async () => { const user = userEvent.setup(); const { getByRole } = renderComponent( diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index 857f57271..a10d3d24c 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -85,6 +85,14 @@ export interface MediaMuteAndSwitchButtonProps { const BLUR_ID = "blur"; +/** + * Stands for wherever the platform is sending audio, where it will not say. + * + * Not a device id the browser would recognise: nothing can be selected on a + * platform that lists no outputs, so this is only ever shown, never sent. + */ +const DEFAULT_OUTPUT_ID = "default"; + export const MediaMuteAndSwitchButton: FC = ({ title, enabled, @@ -251,6 +259,21 @@ export const MediaMuteAndSwitchButton: FC = ({ ? selectedOutputOption : selectedOption); + // Safari enumerates no output devices at all, and offers no way to choose + // one, so the list arrives empty. The section is shown all the same — audio + // is playing somewhere — naming that somewhere and disabling it like any + // single entry. A heading with nothing beneath it reads as a broken feature, + // and leaves the menu a different shape on one browser. + const noOutputsListed = outputOptions?.length === 0; + const speakerOptions: MenuOptions[] | undefined = noOutputsListed + ? [{ id: DEFAULT_OUTPUT_ID, label: { type: "default", name: null } }] + : outputOptions; + // And it is the selection, not merely the only row: it is where audio is + // going. An unchecked lone entry reads as nothing being chosen at all. + const selectedSpeaker = noOutputsListed + ? DEFAULT_OUTPUT_ID + : selectedOutputOption; + const deviceItems = ( kind: "input" | "output", items: MenuOptions[] | undefined, @@ -356,7 +379,7 @@ export const MediaMuteAndSwitchButton: FC = ({ } as CSSProperties } > - {iconsAndLabels === "audio" && outputOptions && ( + {iconsAndLabels === "audio" && speakerOptions && ( <> {/* A menu may only contain items, separators and groups, so each heading belongs to a group rather than sitting beside the @@ -369,8 +392,8 @@ export const MediaMuteAndSwitchButton: FC = ({ {deviceItems( "output", - outputOptions, - selectedOutputOption, + speakerOptions, + selectedSpeaker, onSelectOutput, (n) => t("settings.devices.speaker_numbered", { n }), )}