first step (starting to mix views and viewModels... this has to become

better)
This commit is contained in:
Timo K
2026-05-05 18:18:03 +02:00
parent 8213612135
commit 1fbd7ac80f
5 changed files with 120 additions and 63 deletions

View File

@@ -201,7 +201,6 @@
"camera_numbered": "Camera {{n}}", "camera_numbered": "Camera {{n}}",
"change_device_button": "Change audio device", "change_device_button": "Change audio device",
"default": "Default", "default": "Default",
"default_named": "Default <2>({{name}})</2>",
"handset": "Handset", "handset": "Handset",
"loudspeaker": "Loudspeaker", "loudspeaker": "Loudspeaker",
"microphone": "Microphone", "microphone": "Microphone",

View File

@@ -7,7 +7,8 @@ Please see LICENSE in the repository root for full details.
import { type FC, type JSX, type Ref, useMemo } from "react"; import { type FC, type JSX, type Ref, useMemo } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject, of } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import LogoMark from "../icons/LogoMark.svg?react"; import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react"; import LogoType from "../icons/LogoType.svg?react";
@@ -29,7 +30,18 @@ import {
MediaMuteAndSwitchButton, MediaMuteAndSwitchButton,
type MenuOptions, type MenuOptions,
} from "./MediaMuteAndSwitchButton"; } from "./MediaMuteAndSwitchButton";
import {
type AudioOutputDeviceLabel,
type DeviceLabel,
type MediaDevice,
type SelectedDevice,
} from "../state/MediaDevices";
import { mediaDeviceLabelToString } from "../settings/DeviceSelection";
import {
backgroundBlur as backgroundBlurSettings,
useSetting,
} from "../settings/settings";
import { useTrackProcessor } from "../livekit/TrackProcessorContext";
export interface AudioOutputSwitcher { export interface AudioOutputSwitcher {
targetOutput: string; targetOutput: string;
switch: () => void; switch: () => void;
@@ -85,6 +97,19 @@ export interface FooterProps {
selectedVideo?: string; selectedVideo?: string;
selectAudioDevice?: (deviceId: string) => void; selectAudioDevice?: (deviceId: string) => void;
selectVideoDevice?: (deviceId: string) => void; selectVideoDevice?: (deviceId: string) => void;
/**
* If provided the footer will use the switchAndMute buttons.
* If not provided it will use the normal mute Buttons
*/
audioDevice?: MediaDevice<
DeviceLabel | AudioOutputDeviceLabel,
SelectedDevice
>;
/**
* If provided the footer will use the switchAndMute buttons.
* If not provided it will use the normal mute Buttons
*/
videoDevice?: MediaDevice<DeviceLabel, SelectedDevice>;
} }
export const CallFooter: FC<FooterProps> = ({ export const CallFooter: FC<FooterProps> = ({
@@ -111,13 +136,25 @@ export const CallFooter: FC<FooterProps> = ({
debugTileLayout, debugTileLayout,
tileStoreGeneration, tileStoreGeneration,
audioOptions, audioDevice,
videoOptions, videoDevice,
selectedAudio,
selectedVideo,
selectAudioDevice,
selectVideoDevice,
}) => { }) => {
const videoOptions = useObservableEagerState(
videoDevice?.available$ ?? of(new Map()),
);
const selectedVideo = useObservableEagerState(
videoDevice?.selected$ ?? of(undefined),
);
const audioOptions = useObservableEagerState(
audioDevice?.available$ ?? of(new Map()),
);
const selectedAudio = useObservableEagerState(
audioDevice?.selected$ ?? of(undefined),
);
const { supported: blurSupported } = useTrackProcessor();
const [blurActive, setBlurActive] = useSetting(backgroundBlurSettings);
const buttons: JSX.Element[] = []; const buttons: JSX.Element[] = [];
const buttonSize = asPip ? "md" : "lg"; const buttonSize = asPip ? "md" : "lg";
const showSettingsButton = const showSettingsButton =
@@ -138,18 +175,24 @@ export const CallFooter: FC<FooterProps> = ({
); );
} }
if ((audioOptions?.length ?? 0) > 0) { if ((audioOptions?.size ?? 0) > 0) {
buttons.push( buttons.push(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title={"Mic Source"} title={"Mic Source"}
key="audio" key="audio"
iconsAndLabels="video" iconsAndLabels="audio"
enabled={audioEnabled ?? false} enabled={audioEnabled ?? false}
onMuteClick={toggleAudio} onMuteClick={toggleAudio}
data-testid="incall_mute" data-testid="incall_mute"
options={audioOptions} options={Array.from(audioOptions.entries()).map(([k, v]) => {
selectedOption={selectedAudio} const label = mediaDeviceLabelToString(v, (n) => "Audio Device " + n);
onSelect={selectAudioDevice} return {
id: k,
label: label,
};
})}
selectedOption={selectedAudio?.id}
onSelect={audioDevice?.select}
/>, />,
); );
} else { } else {
@@ -164,18 +207,40 @@ export const CallFooter: FC<FooterProps> = ({
/>, />,
); );
} }
if ((videoOptions?.length ?? 0) > 0) { if ((videoOptions?.size ?? 0) > 0) {
buttons.push( buttons.push(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title={"Camera Source"} title={"Camera Source"}
key="audio" key="video"
iconsAndLabels="audio" iconsAndLabels="video"
enabled={videoEnabled ?? false} enabled={videoEnabled ?? false}
onMuteClick={toggleVideo} onMuteClick={toggleVideo}
data-testid="incall_mute" data-testid="incall_mute"
options={videoOptions} options={Array.from(videoOptions.entries()).map(([k, v]) => ({
selectedOption={selectedVideo} id: k,
onSelect={selectVideoDevice} label: v.type === "name" ? v.name : "Camera " + v.number,
}))}
toggles={
blurSupported
? [
{
id: "blur",
enabled: blurActive,
label: "Blur Background",
},
]
: []
}
selectedOption={selectedVideo?.id}
onSelect={(option) => {
switch (option) {
case "blur":
setBlurActive(!blurActive);
break;
default:
videoDevice?.select(option);
}
}}
/>, />,
); );
} else { } else {

View File

@@ -220,7 +220,7 @@ export const InCallView: FC<InCallViewProps> = ({
muted: muteAllAudio, muted: muteAllAudio,
}); });
const latestPickupPhaseAudio = useLatest(pickupPhaseAudio); const latestPickupPhaseAudio = useLatest(pickupPhaseAudio);
const mediaDevices = useMediaDevices();
const audioEnabled = useBehavior(muteStates.audio.enabled$); const audioEnabled = useBehavior(muteStates.audio.enabled$);
const videoEnabled = useBehavior(muteStates.video.enabled$); const videoEnabled = useBehavior(muteStates.video.enabled$);
const toggleAudio = useBehavior(muteStates.audio.toggle$); const toggleAudio = useBehavior(muteStates.audio.toggle$);
@@ -597,6 +597,8 @@ export const InCallView: FC<InCallViewProps> = ({
//Debug props //Debug props
debugTileLayout={debugTileLayout} debugTileLayout={debugTileLayout}
tileStoreGeneration={tileStoreGeneration} tileStoreGeneration={tileStoreGeneration}
audioDevice={mediaDevices.audioInput}
videoDevice={mediaDevices.videoInput}
/> />
); );
const allConnections = useBehavior(vm.allConnections$); const allConnections = useBehavior(vm.allConnections$);

View File

@@ -236,6 +236,8 @@ export const LobbyView: FC<Props> = ({
hangup={!confineToRoom ? onLeaveClick : undefined} hangup={!confineToRoom ? onLeaveClick : undefined}
// Logo and header are connected. We will only show the logo in SPA with header. // Logo and header are connected. We will only show the logo in SPA with header.
hideLogo={hideHeader} hideLogo={hideHeader}
audioDevice={devices.audioInput}
videoDevice={devices.videoInput}
> >
{recentsButtonInFooter && recentsButton} {recentsButtonInFooter && recentsButton}
</CallFooter> </CallFooter>

View File

@@ -5,14 +5,7 @@ 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 { import { type ChangeEvent, type FC, useCallback, useId } from "react";
type ChangeEvent,
type FC,
type ReactElement,
type ReactNode,
useCallback,
useId,
} from "react";
import { import {
Heading, Heading,
InlineField, InlineField,
@@ -20,8 +13,8 @@ import {
RadioControl, RadioControl,
Separator, Separator,
} from "@vector-im/compound-web"; } from "@vector-im/compound-web";
import { Trans, useTranslation } from "react-i18next";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import { t } from "i18next";
import { import {
type AudioOutputDeviceLabel, type AudioOutputDeviceLabel,
@@ -37,12 +30,39 @@ interface Props {
numberedLabel: (number: number) => string; numberedLabel: (number: number) => string;
} }
export function mediaDeviceLabelToString(
label: DeviceLabel | AudioOutputDeviceLabel,
numberedLabel: (number: number) => string,
): string {
let labelText = "";
switch (label.type) {
case "name":
labelText = label.name;
break;
case "number":
labelText = numberedLabel(label.number);
break;
case "default":
labelText =
label.name === null
? t("settings.devices.default")
: t("settings.devices.default") + " (" + label.name + ")";
break;
case "speaker":
labelText = t("settings.devices.loudspeaker");
break;
case "earpiece":
labelText = t("settings.devices.handset");
break;
}
return labelText;
}
export const DeviceSelection: FC<Props> = ({ export const DeviceSelection: FC<Props> = ({
device, device,
title, title,
numberedLabel, numberedLabel,
}) => { }) => {
const { t } = useTranslation();
const groupId = useId(); const groupId = useId();
const available = useObservableEagerState(device.available$); const available = useObservableEagerState(device.available$);
const selectedId = useObservableEagerState(device.selected$)?.id; const selectedId = useObservableEagerState(device.selected$)?.id;
@@ -70,38 +90,7 @@ export const DeviceSelection: FC<Props> = ({
<Separator className={styles.separator} /> <Separator className={styles.separator} />
<div className={styles.options}> <div className={styles.options}>
{[...available].map(([id, label]) => { {[...available].map(([id, label]) => {
let labelText: ReactNode; const labelText = mediaDeviceLabelToString(label, numberedLabel);
switch (label.type) {
case "name":
labelText = label.name;
break;
case "number":
labelText = numberedLabel(label.number);
break;
case "default":
labelText =
label.name === null ? (
t("settings.devices.default")
) : (
<Trans
i18nKey="settings.devices.default_named"
name={label.name}
>
Default{" "}
<span className={styles.secondary}>
({{ name: label.name } as unknown as ReactElement})
</span>
</Trans>
);
break;
case "speaker":
labelText = t("settings.devices.loudspeaker");
break;
case "earpiece":
labelText = t("settings.devices.handset");
break;
}
return ( return (
<InlineField <InlineField
key={id} key={id}