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) => (