Show a default speaker where the platform lists none

- Safari enumerates no output devices at all, so the list arrived empty — and
  an empty array is truthy, so the Speaker heading rendered with nothing under
  it, which reads as a broken feature.
- Show one entry instead, named as the default, disabled, and marked as the
  selection: audio is going somewhere, and an unchecked lone row reads as
  nothing being chosen.
- D2 already says a row that cannot be chosen is shown disabled, never hidden.
- Covered by its condition rather than by the browser, so Linux CI checks it:
  one test that the view model hands over an empty list rather than none, one
  that the menu then draws the default.
This commit is contained in:
fkwp
2026-09-17 11:56:31 +02:00
parent 6c3012a40b
commit ace7e947b4
4 changed files with 144 additions and 4 deletions
@@ -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<string, DeviceLabel>([
["mic1", { type: "name", name: "Microphone 1" }],
]),
),
selected$: constant(undefined),
select: vi.fn(),
},
// Safari enumerates no output devices whatsoever. Reproduced by the
// condition rather than by the browser, so it is checked on the
// Linux CI runners that have no Safari to check it with.
audioOutput: {
available$: constant(new Map<string, DeviceLabel>()),
selected$: constant(undefined),
select: vi.fn(),
},
}),
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
// Empty rather than undefined: undefined means this menu has no notion
// of outputs at all, as the camera menu has none, and hides the section.
// Empty means there are none to list, and the menu still shows the
// section with a default in it, disabled.
expect(vm.audioOutputOptions$.value).toEqual([]);
});
});
describe("audioOptions and videoOptions", () => { describe("audioOptions and videoOptions", () => {
function checkEmptyFor(platform: string, layout: Layout): void { function checkEmptyFor(platform: string, layout: Layout): void {
platformMock.mockReturnValue(platform); platformMock.mockReturnValue(platform);
@@ -197,3 +197,34 @@ export const OnlyOneDevice: Story = {
await expect(only).toHaveAttribute("aria-disabled", "true"); 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");
},
};
@@ -7,7 +7,13 @@ Please see LICENSE in the repository root for full details.
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { axe } from "vitest-axe"; 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 userEvent from "@testing-library/user-event";
import { type JSX, useState, type ReactNode } from "react"; import { type JSX, useState, type ReactNode } from "react";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
@@ -613,6 +619,46 @@ describe("MediaMuteAndSwitchButton", () => {
expect(only).toHaveAttribute("aria-disabled", "true"); expect(only).toHaveAttribute("aria-disabled", "true");
}); });
test("shows a default speaker where the platform lists none", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
// Safari enumerates no output devices at all, and offers no way to
// choose one.
outputOptions={[]}
selectedOutputOption={undefined}
onSelectOutput={undefined}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
// A heading with nothing under it says the feature is broken. Audio is
// playing somewhere, so the section names that somewhere and disables it.
const speakers = screen
.getAllByRole("group")
.find((group) => group.getAttribute("aria-label") === "Speaker")!;
const entries = within(speakers).getAllByRole("menuitemradio");
expect(entries).toHaveLength(1);
expect(entries[0]).toHaveAccessibleName("Default");
expect(entries[0]).toHaveAttribute("aria-disabled", "true");
// And marked as the selection: it is where audio is going, so an unchecked
// lone entry would read as nothing being chosen at all.
expect(entries[0]).toHaveAttribute("aria-checked", "true");
expect(
within(entries[0]).getByRole("radio", { hidden: true }),
).toBeChecked();
});
test("shows the speaker section disabled when output selection is unsupported", async () => { test("shows the speaker section disabled when output selection is unsupported", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { getByRole } = renderComponent( const { getByRole } = renderComponent(
+26 -3
View File
@@ -85,6 +85,14 @@ export interface MediaMuteAndSwitchButtonProps {
const BLUR_ID = "blur"; 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<MediaMuteAndSwitchButtonProps> = ({ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
title, title,
enabled, enabled,
@@ -251,6 +259,21 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
? selectedOutputOption ? selectedOutputOption
: selectedOption); : 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 = ( const deviceItems = (
kind: "input" | "output", kind: "input" | "output",
items: MenuOptions[] | undefined, items: MenuOptions[] | undefined,
@@ -356,7 +379,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
} as CSSProperties } as CSSProperties
} }
> >
{iconsAndLabels === "audio" && outputOptions && ( {iconsAndLabels === "audio" && speakerOptions && (
<> <>
{/* A menu may only contain items, separators and groups, so each {/* A menu may only contain items, separators and groups, so each
heading belongs to a group rather than sitting beside the heading belongs to a group rather than sitting beside the
@@ -369,8 +392,8 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
</div> </div>
{deviceItems( {deviceItems(
"output", "output",
outputOptions, speakerOptions,
selectedOutputOption, selectedSpeaker,
onSelectOutput, onSelectOutput,
(n) => t("settings.devices.speaker_numbered", { n }), (n) => t("settings.devices.speaker_numbered", { n }),
)} )}