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}}",
"change_device_button": "Change audio device",
"default": "Default",
"default_named": "Default <2>({{name}})</2>",
"handset": "Handset",
"loudspeaker": "Loudspeaker",
"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 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 LogoType from "../icons/LogoType.svg?react";
@@ -29,7 +30,18 @@ import {
MediaMuteAndSwitchButton,
type MenuOptions,
} 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 {
targetOutput: string;
switch: () => void;
@@ -85,6 +97,19 @@ export interface FooterProps {
selectedVideo?: string;
selectAudioDevice?: (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> = ({
@@ -111,13 +136,25 @@ export const CallFooter: FC<FooterProps> = ({
debugTileLayout,
tileStoreGeneration,
audioOptions,
videoOptions,
selectedAudio,
selectedVideo,
selectAudioDevice,
selectVideoDevice,
audioDevice,
videoDevice,
}) => {
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 buttonSize = asPip ? "md" : "lg";
const showSettingsButton =
@@ -138,18 +175,24 @@ export const CallFooter: FC<FooterProps> = ({
);
}
if ((audioOptions?.length ?? 0) > 0) {
if ((audioOptions?.size ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Mic Source"}
key="audio"
iconsAndLabels="video"
iconsAndLabels="audio"
enabled={audioEnabled ?? false}
onMuteClick={toggleAudio}
data-testid="incall_mute"
options={audioOptions}
selectedOption={selectedAudio}
onSelect={selectAudioDevice}
options={Array.from(audioOptions.entries()).map(([k, v]) => {
const label = mediaDeviceLabelToString(v, (n) => "Audio Device " + n);
return {
id: k,
label: label,
};
})}
selectedOption={selectedAudio?.id}
onSelect={audioDevice?.select}
/>,
);
} else {
@@ -164,18 +207,40 @@ export const CallFooter: FC<FooterProps> = ({
/>,
);
}
if ((videoOptions?.length ?? 0) > 0) {
if ((videoOptions?.size ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Camera Source"}
key="audio"
iconsAndLabels="audio"
key="video"
iconsAndLabels="video"
enabled={videoEnabled ?? false}
onMuteClick={toggleVideo}
data-testid="incall_mute"
options={videoOptions}
selectedOption={selectedVideo}
onSelect={selectVideoDevice}
options={Array.from(videoOptions.entries()).map(([k, v]) => ({
id: k,
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 {

View File

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

View File

@@ -236,6 +236,8 @@ export const LobbyView: FC<Props> = ({
hangup={!confineToRoom ? onLeaveClick : undefined}
// Logo and header are connected. We will only show the logo in SPA with header.
hideLogo={hideHeader}
audioDevice={devices.audioInput}
videoDevice={devices.videoInput}
>
{recentsButtonInFooter && recentsButton}
</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.
*/
import {
type ChangeEvent,
type FC,
type ReactElement,
type ReactNode,
useCallback,
useId,
} from "react";
import { type ChangeEvent, type FC, useCallback, useId } from "react";
import {
Heading,
InlineField,
@@ -20,8 +13,8 @@ import {
RadioControl,
Separator,
} from "@vector-im/compound-web";
import { Trans, useTranslation } from "react-i18next";
import { useObservableEagerState } from "observable-hooks";
import { t } from "i18next";
import {
type AudioOutputDeviceLabel,
@@ -37,12 +30,39 @@ interface Props {
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> = ({
device,
title,
numberedLabel,
}) => {
const { t } = useTranslation();
const groupId = useId();
const available = useObservableEagerState(device.available$);
const selectedId = useObservableEagerState(device.selected$)?.id;
@@ -70,38 +90,7 @@ export const DeviceSelection: FC<Props> = ({
<Separator className={styles.separator} />
<div className={styles.options}>
{[...available].map(([id, label]) => {
let labelText: ReactNode;
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;
}
const labelText = mediaDeviceLabelToString(label, numberedLabel);
return (
<InlineField
key={id}