mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
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:
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user