Add speaker selection to the microphone quick menu

The microphone chevron listed input devices only, so changing the output
device meant leaving the call controls for the settings modal.

Render the audio menu as two sections, Speaker above Microphone, and wire
the output device list, selection and select callback through the footer
view model. Entries are shown but disabled where the platform cannot switch
that kind of device, or where only one exists, so the menu keeps the same
shape everywhere.

Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1, AC6, AC7, AC8
This commit is contained in:
fkwp
2026-09-16 12:58:32 +02:00
parent 71593f1a24
commit 27789131ee
5 changed files with 174 additions and 55 deletions
+1
View File
@@ -218,6 +218,7 @@
"change_device_button": "Change audio device", "change_device_button": "Change audio device",
"default": "Default", "default": "Default",
"default_named": "Default <2>({{name}})</2>", "default_named": "Default <2>({{name}})</2>",
"default_named_plain": "Default ({{name}})",
"handset": "Handset", "handset": "Handset",
"loudspeaker": "Loudspeaker", "loudspeaker": "Loudspeaker",
"microphone": "Microphone", "microphone": "Microphone",
+8
View File
@@ -137,10 +137,13 @@ export const Default: Story = {
debugTileLayout: false, debugTileLayout: false,
tileStoreGeneration: undefined, tileStoreGeneration: undefined,
audioOptions: [], audioOptions: [],
audioOutputOptions: [],
videoOptions: [], videoOptions: [],
selectedAudio: undefined, selectedAudio: undefined,
selectedAudioOutput: undefined,
selectedVideo: undefined, selectedVideo: undefined,
selectAudioButtonOption: undefined, selectAudioButtonOption: undefined,
selectAudioOutputOption: undefined,
selectVideoButtonOption: undefined, selectVideoButtonOption: undefined,
}, },
parameters: { parameters: {
@@ -158,11 +161,16 @@ export const WithAudioAndVideoOptions: Story = {
{ label: { type: "name", name: "Microphone 1" }, id: "1" }, { label: { type: "name", name: "Microphone 1" }, id: "1" },
{ label: { type: "name", name: "Microphone 2" }, id: "2" }, { label: { type: "name", name: "Microphone 2" }, id: "2" },
], ],
audioOutputOptions: [
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
{ label: { type: "name", name: "Headset" }, id: "2" },
],
videoOptions: [ videoOptions: [
{ label: { type: "name", name: "Camera 1" }, id: "1" }, { label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" }, { label: { type: "name", name: "Camera 2" }, id: "2" },
], ],
selectedAudio: "2", selectedAudio: "2",
selectedAudioOutput: "default",
selectedVideo: "1", selectedVideo: "1",
}, },
}; };
+10
View File
@@ -99,11 +99,15 @@ export interface FooterState {
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */ /** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
audioOptions: MenuOptions[]; audioOptions: MenuOptions[];
/** Output devices shown as their own section in the audio menu. */
audioOutputOptions: MenuOptions[];
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */ /** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
videoOptions: MenuOptions[]; videoOptions: MenuOptions[];
selectedAudio: string | undefined; selectedAudio: string | undefined;
selectedAudioOutput: string | undefined;
selectedVideo: string | undefined; selectedVideo: string | undefined;
selectAudioButtonOption: ((deviceId: string) => void) | undefined; selectAudioButtonOption: ((deviceId: string) => void) | undefined;
selectAudioOutputOption: ((deviceId: string) => void) | undefined;
selectVideoButtonOption: ((option: string) => void) | undefined; selectVideoButtonOption: ((option: string) => void) | undefined;
} }
@@ -143,6 +147,9 @@ export const CallFooter: FC<FooterProps> = ({
const audioOptions = useBehavior(vm.audioOptions$); const audioOptions = useBehavior(vm.audioOptions$);
const selectedAudio = useBehavior(vm.selectedAudio$); const selectedAudio = useBehavior(vm.selectedAudio$);
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$); const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
const audioOutputOptions = useBehavior(vm.audioOutputOptions$);
const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$);
const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$);
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$); const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
const toggleBlur = useBehavior(vm.toggleBlur$); const toggleBlur = useBehavior(vm.toggleBlur$);
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$); const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
@@ -178,6 +185,9 @@ export const CallFooter: FC<FooterProps> = ({
options={audioOptions} options={audioOptions}
selectedOption={selectedAudio} selectedOption={selectedAudio}
onSelect={selectAudioButtonOption} onSelect={selectAudioButtonOption}
outputOptions={audioOutputOptions}
selectedOutputOption={selectedAudioOutput}
onSelectOutput={selectAudioOutputOption}
/>, />,
); );
} else { } else {
+26
View File
@@ -67,6 +67,9 @@ function buildDeviceBehaviors(
| "audioOptions$" | "audioOptions$"
| "selectedAudio$" | "selectedAudio$"
| "selectAudioButtonOption$" | "selectAudioButtonOption$"
| "audioOutputOptions$"
| "selectedAudioOutput$"
| "selectAudioOutputOption$"
| "videoOptions$" | "videoOptions$"
| "selectedVideo$" | "selectedVideo$"
| "selectVideoButtonOption$" | "selectVideoButtonOption$"
@@ -94,6 +97,26 @@ function buildDeviceBehaviors(
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)), mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
), ),
selectAudioButtonOption$: constant(mediaDevices.audioInput.select), selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
audioOutputOptions$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: 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$: constant(mediaDevices.audioOutput.select),
videoOptions$: scope.behavior( videoOptions$: scope.behavior(
disableSwitcher$.pipe( disableSwitcher$.pipe(
switchMap((disable) => switchMap((disable) =>
@@ -263,10 +286,13 @@ export function createLobbyFooterViewModel(
reactionData: undefined, reactionData: undefined,
tileStoreGeneration: undefined, tileStoreGeneration: undefined,
audioOptions: undefined, audioOptions: undefined,
audioOutputOptions: undefined,
videoOptions: undefined, videoOptions: undefined,
selectedAudio: undefined, selectedAudio: undefined,
selectedAudioOutput: undefined,
selectedVideo: undefined, selectedVideo: undefined,
selectAudioButtonOption: undefined, selectAudioButtonOption: undefined,
selectAudioOutputOption: undefined,
selectVideoButtonOption: undefined, selectVideoButtonOption: undefined,
}), }),
...buildMuteBehaviors(scope, muteStates), ...buildMuteBehaviors(scope, muteStates),
+129 -55
View File
@@ -5,11 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. 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 ReactElement,
} from "react";
import { import {
Button, Button,
Menu, Menu,
MenuItem, MenuItem,
MenuTitle,
Separator,
ToggleMenuItem, ToggleMenuItem,
} from "@vector-im/compound-web"; } from "@vector-im/compound-web";
import { import {
@@ -19,17 +27,21 @@ import {
MicOnIcon, MicOnIcon,
SpinnerIcon, SpinnerIcon,
VideoCallIcon, VideoCallIcon,
VolumeOnIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons"; } from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import styles from "./MediaMuteAndSwitchButton.module.css"; import styles from "./MediaMuteAndSwitchButton.module.css";
import { MicButton, VideoButton } from "../button"; import { MicButton, VideoButton } from "../button";
import { type DeviceLabel } from "../state/MediaDevices"; import {
type AudioOutputDeviceLabel,
type DeviceLabel,
} from "../state/MediaDevices";
import { useMediaDevices } from "../MediaDevicesContext"; import { useMediaDevices } from "../MediaDevicesContext";
export interface MenuOptions { export interface MenuOptions {
label: DeviceLabel; label: DeviceLabel | AudioOutputDeviceLabel;
id: string; id: string;
} }
@@ -47,6 +59,18 @@ export interface MediaMuteAndSwitchButtonProps {
options?: MenuOptions[]; options?: MenuOptions[];
/** The option that will currently be rendered as the selected option */ /** The option that will currently be rendered as the selected option */
selectedOption?: string; selectedOption?: string;
/**
* Output (speaker) devices, shown as their own section above the input
* section. Audio menu only; omitted entirely for video.
*/
outputOptions?: MenuOptions[];
/** The output option currently rendered as selected */
selectedOutputOption?: string;
/**
* Called when an output device is picked. Undefined means the platform does
* not permit choosing an output, and the section renders disabled.
*/
onSelectOutput?: (id: string) => void;
videoBlurToggleClick?: () => void; videoBlurToggleClick?: () => void;
videoBlurEnabled?: boolean; videoBlurEnabled?: boolean;
/** /**
@@ -66,6 +90,9 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
iconsAndLabels, iconsAndLabels,
options, options,
selectedOption, selectedOption,
outputOptions,
selectedOutputOption,
onSelectOutput,
videoBlurEnabled, videoBlurEnabled,
videoBlurToggleClick, videoBlurToggleClick,
onSelect, onSelect,
@@ -142,6 +169,84 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
break; break;
} }
const labelToText = (
label: MenuOptions["label"],
numbered: (n: number) => string,
): string => {
switch (label.type) {
case "name":
return label.name;
case "number":
return numbered(label.number);
case "default":
return label.name === null
? t("settings.devices.default")
: t("settings.devices.default_named_plain", { name: label.name });
case "speaker":
return t("settings.devices.loudspeaker");
case "earpiece":
return t("settings.devices.handset");
}
};
const deviceItems = (
items: MenuOptions[] | undefined,
selected: string | undefined,
select: ((id: string) => void) | undefined,
numbered: (n: number) => string,
Icon: ComponentType<React.SVGAttributes<SVGElement>> | undefined,
): ReactElement[] => {
const list = items ?? [];
// Shown but not choosable when the platform will not switch this kind of
// device, or when there is only one of them. The entry stays visible so the
// menu keeps the same shape everywhere.
const disabled = select === undefined || list.length <= 1;
return list.map(({ label, id }) => (
<MenuItem
hideChevron
disabled={disabled}
label={labelToText(label, numbered)}
Icon={
Icon && (
<Icon
width={24}
height={24}
className={styles.itemIcon}
aria-hidden
/>
)
}
onSelect={(e) => {
e.preventDefault();
if (id === selected) return;
setPlannedSelection(id);
select?.(id);
}}
key={id}
role="menuitemradio"
aria-checked={selected === id}
>
{selected === id && (
<CheckIcon
width={24}
height={24}
aria-hidden // A label would be redundant to aria-checked above
/>
)}
{selected !== id && plannedSelection === id && (
<SpinnerIcon
width={24}
height={24}
className={styles.rotate}
aria-label={t("settings.devices.activating")}
/>
)}
</MenuItem>
));
};
const showOutputSection = iconsAndLabels === "audio" && outputOptions;
return ( return (
<div <div
className={classNames({ className={classNames({
@@ -171,58 +276,27 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
/> />
} }
> >
{options?.map(({ label, id }) => { {showOutputSection && (
let labelText: string; <>
switch (label.type) { <MenuTitle title={t("settings.devices.speaker")} />
case "name": {deviceItems(
labelText = label.name; outputOptions,
break; selectedOutputOption,
case "number": onSelectOutput,
labelText = numberedLabel(label.number); (n) => t("settings.devices.speaker_numbered", { n }),
break; VolumeOnIcon,
} )}
return ( <Separator />
<MenuItem <MenuTitle title={t("settings.devices.microphone")} />
hideChevron </>
label={labelText} )}
Icon={ {deviceItems(
IconOptions && ( options,
<IconOptions selectedOption,
width={24} onSelect,
height={24} numberedLabel,
className={styles.itemIcon} IconOptions,
aria-hidden )}
/>
)
}
onSelect={(e) => {
e.preventDefault();
if (id === selectedOption) return;
setPlannedSelection(id);
onSelect?.(id);
}}
key={id}
role="menuitemradio"
aria-checked={selectedOption === id}
>
{selectedOption === id && (
<CheckIcon
width={24}
height={24}
aria-hidden // A label would be redundant to aria-checked above
/>
)}
{selectedOption !== id && plannedSelection === id && (
<SpinnerIcon
width={24}
height={24}
className={styles.rotate}
aria-label={t("settings.devices.activating")}
/>
)}
</MenuItem>
);
})}
{(toggles?.length ?? 0) > 0 && <hr />} {(toggles?.length ?? 0) > 0 && <hr />}
{toggles?.map((toggle) => ( {toggles?.map((toggle) => (
<ToggleMenuItem <ToggleMenuItem