From 27789131ee2daa8d27b5635b08a83a2a52198c61 Mon Sep 17 00:00:00 2001 From: fkwp Date: Wed, 16 Sep 2026 12:58:32 +0200 Subject: [PATCH] Add speaker selection to the microphone quick menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- locales/en/app.json | 1 + src/components/CallFooter.stories.tsx | 8 + src/components/CallFooter.tsx | 10 ++ src/components/CallFooterViewModel.tsx | 26 +++ src/components/MediaMuteAndSwitchButton.tsx | 184 ++++++++++++++------ 5 files changed, 174 insertions(+), 55 deletions(-) diff --git a/locales/en/app.json b/locales/en/app.json index f3d568bb8..b0bc72edb 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -218,6 +218,7 @@ "change_device_button": "Change audio device", "default": "Default", "default_named": "Default <2>({{name}})", + "default_named_plain": "Default ({{name}})", "handset": "Handset", "loudspeaker": "Loudspeaker", "microphone": "Microphone", diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index 3c3f46074..6b94c929a 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -137,10 +137,13 @@ export const Default: Story = { debugTileLayout: false, tileStoreGeneration: undefined, audioOptions: [], + audioOutputOptions: [], videoOptions: [], selectedAudio: undefined, + selectedAudioOutput: undefined, selectedVideo: undefined, selectAudioButtonOption: undefined, + selectAudioOutputOption: undefined, selectVideoButtonOption: undefined, }, parameters: { @@ -158,11 +161,16 @@ export const WithAudioAndVideoOptions: Story = { { label: { type: "name", name: "Microphone 1" }, id: "1" }, { 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: [ { label: { type: "name", name: "Camera 1" }, id: "1" }, { label: { type: "name", name: "Camera 2" }, id: "2" }, ], selectedAudio: "2", + selectedAudioOutput: "default", selectedVideo: "1", }, }; diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index 4f79f236c..9ae56a591 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -99,11 +99,15 @@ export interface FooterState { /** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */ 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 */ videoOptions: MenuOptions[]; selectedAudio: string | undefined; + selectedAudioOutput: string | undefined; selectedVideo: string | undefined; selectAudioButtonOption: ((deviceId: string) => void) | undefined; + selectAudioOutputOption: ((deviceId: string) => void) | undefined; selectVideoButtonOption: ((option: string) => void) | undefined; } @@ -143,6 +147,9 @@ export const CallFooter: FC = ({ const audioOptions = useBehavior(vm.audioOptions$); const selectedAudio = useBehavior(vm.selectedAudio$); 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 toggleBlur = useBehavior(vm.toggleBlur$); const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$); @@ -178,6 +185,9 @@ export const CallFooter: FC = ({ options={audioOptions} selectedOption={selectedAudio} onSelect={selectAudioButtonOption} + outputOptions={audioOutputOptions} + selectedOutputOption={selectedAudioOutput} + onSelectOutput={selectAudioOutputOption} />, ); } else { diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx index a2ca6c88e..a8612dfa7 100644 --- a/src/components/CallFooterViewModel.tsx +++ b/src/components/CallFooterViewModel.tsx @@ -67,6 +67,9 @@ function buildDeviceBehaviors( | "audioOptions$" | "selectedAudio$" | "selectAudioButtonOption$" + | "audioOutputOptions$" + | "selectedAudioOutput$" + | "selectAudioOutputOption$" | "videoOptions$" | "selectedVideo$" | "selectVideoButtonOption$" @@ -94,6 +97,26 @@ function buildDeviceBehaviors( mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)), ), 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( disableSwitcher$.pipe( switchMap((disable) => @@ -263,10 +286,13 @@ export function createLobbyFooterViewModel( reactionData: undefined, tileStoreGeneration: undefined, audioOptions: undefined, + audioOutputOptions: undefined, videoOptions: undefined, selectedAudio: undefined, + selectedAudioOutput: undefined, selectedVideo: undefined, selectAudioButtonOption: undefined, + selectAudioOutputOption: undefined, selectVideoButtonOption: undefined, }), ...buildMuteBehaviors(scope, muteStates), diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index 3e344dd3a..01b0e54eb 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -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. */ -import { type ComponentType, useState, type FC, useEffect } from "react"; +import { + type ComponentType, + useState, + type FC, + useEffect, + type ReactElement, +} from "react"; import { Button, Menu, MenuItem, + MenuTitle, + Separator, ToggleMenuItem, } from "@vector-im/compound-web"; import { @@ -19,17 +27,21 @@ 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 { - label: DeviceLabel; + label: DeviceLabel | AudioOutputDeviceLabel; id: string; } @@ -47,6 +59,18 @@ export interface MediaMuteAndSwitchButtonProps { options?: MenuOptions[]; /** The option that will currently be rendered as the selected option */ 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; videoBlurEnabled?: boolean; /** @@ -66,6 +90,9 @@ export const MediaMuteAndSwitchButton: FC = ({ iconsAndLabels, options, selectedOption, + outputOptions, + selectedOutputOption, + onSelectOutput, videoBlurEnabled, videoBlurToggleClick, onSelect, @@ -142,6 +169,84 @@ export const MediaMuteAndSwitchButton: FC = ({ 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> | 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 }) => ( + + ) + } + onSelect={(e) => { + e.preventDefault(); + if (id === selected) return; + setPlannedSelection(id); + select?.(id); + }} + key={id} + role="menuitemradio" + aria-checked={selected === id} + > + {selected === id && ( + + )} + {selected !== id && plannedSelection === id && ( + + )} + + )); + }; + + const showOutputSection = iconsAndLabels === "audio" && outputOptions; + return (
= ({ /> } > - {options?.map(({ label, id }) => { - let labelText: string; - switch (label.type) { - case "name": - labelText = label.name; - break; - case "number": - labelText = numberedLabel(label.number); - break; - } - return ( - - ) - } - onSelect={(e) => { - e.preventDefault(); - if (id === selectedOption) return; - setPlannedSelection(id); - onSelect?.(id); - }} - key={id} - role="menuitemradio" - aria-checked={selectedOption === id} - > - {selectedOption === id && ( - - )} - {selectedOption !== id && plannedSelection === id && ( - - )} - - ); - })} + {showOutputSection && ( + <> + + {deviceItems( + outputOptions, + selectedOutputOption, + onSelectOutput, + (n) => t("settings.devices.speaker_numbered", { n }), + VolumeOnIcon, + )} + + + + )} + {deviceItems( + options, + selectedOption, + onSelect, + numberedLabel, + IconOptions, + )} {(toggles?.length ?? 0) > 0 &&
} {toggles?.map((toggle) => (