mirror of
https://github.com/vector-im/element-call.git
synced 2026-11-13 01:12:29 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60985885d3 | ||
|
|
889828763c |
@@ -10,6 +10,11 @@
|
||||
- Owning a resource: a class taking the scope in its constructor — `MediaDevices`,
|
||||
`MuteStates`, `TileStore`, `Publisher`, `Connection`. Both styles are current;
|
||||
don't convert one to the other in passing.
|
||||
- A factory's positional parameters are what the thing is made of: required, no
|
||||
sensible default. Its trailing `options` bag is how the host wants it to
|
||||
behave: every field optional, each defaulting to standalone behaviour, so `{}`
|
||||
works. A value with no default is never an option; a test seam is never an
|
||||
option either — mock the dependency it comes from. See `createCallViewModel$`.
|
||||
- `foo$` is an observable. `Behavior<T>`, an observable with a current value, is
|
||||
the default for anything a view reads.
|
||||
|
||||
|
||||
@@ -132,16 +132,16 @@ export async function createMatrixRTCSdk(
|
||||
room,
|
||||
mediaDevices,
|
||||
muteStates,
|
||||
encryptionSystem,
|
||||
// The bundle owns its page, so the body is the space it has
|
||||
scope.behavior(observeElementSize$(document.body)),
|
||||
of({}),
|
||||
of({}),
|
||||
{
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem,
|
||||
capture,
|
||||
hostBridge,
|
||||
// The bundle owns its page, so the body is the space it has
|
||||
windowSize$: scope.behavior(observeElementSize$(document.body)),
|
||||
},
|
||||
of({}),
|
||||
of({}),
|
||||
);
|
||||
// Once the hang-up has gone through, unstick and ask the host to close us
|
||||
vm.leave$
|
||||
|
||||
@@ -45,7 +45,7 @@ interface Props {
|
||||
/** Offers to add a background of one's own. Omit to leave that tile out. */
|
||||
onAdd?: () => void;
|
||||
addLabel?: string;
|
||||
/** Removes a removable one, never the one in force. Omit to offer none. */
|
||||
/** Removes a removable one, unless it is the selected one. Omit to offer none. */
|
||||
onRemove?: (id: string) => void;
|
||||
removeLabel?: string;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { MediaDevices } from "../state/MediaDevices";
|
||||
import { globalScope } from "../state/ObservableScope";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
|
||||
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
|
||||
import { type MediaMenuSnapshot } from "./MediaMenuViewModel";
|
||||
|
||||
// consts for tests
|
||||
const reactionIdentifier = "@user:example.com:DEVICE";
|
||||
@@ -35,10 +35,57 @@ const mediaDevices = new MediaDevices(globalScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
function menu(
|
||||
kind: "audio" | "video",
|
||||
withDevices: boolean,
|
||||
): MediaMenuSnapshot {
|
||||
const device = kind === "audio" ? "Microphone" : "Camera";
|
||||
return {
|
||||
enabled: true,
|
||||
busy: false,
|
||||
toggle: fn(),
|
||||
options: withDevices
|
||||
? [
|
||||
{ id: "1", label: { type: "name", name: `${device} 1` } },
|
||||
{ id: "2", label: { type: "name", name: `${device} 2` } },
|
||||
]
|
||||
: [],
|
||||
selected: "1",
|
||||
select: fn(),
|
||||
outputOptions:
|
||||
withDevices && kind === "audio"
|
||||
? [
|
||||
{
|
||||
id: "default",
|
||||
label: { type: "default", name: "Built-in Output" },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
selectedOutput: "default",
|
||||
selectOutput: fn(),
|
||||
backgroundEffect: "none",
|
||||
backgroundEffects:
|
||||
kind === "video"
|
||||
? [
|
||||
{ id: "none", kind: "none" },
|
||||
{ id: "blur", kind: "blur" },
|
||||
]
|
||||
: [],
|
||||
backgroundEffectNotice: undefined,
|
||||
backgroundEffectSettling: false,
|
||||
backgroundImageRefusal: undefined,
|
||||
selectBackgroundEffect: fn(),
|
||||
addBackgroundImage: undefined,
|
||||
removeBackgroundEffect: undefined,
|
||||
cameraTrack: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper component that is used for:
|
||||
* - exposing the snapshot via props so the storybook documents the snapshot properties (basically unpack them form the vm)
|
||||
* - constructing the layout switch view model
|
||||
* - constructing the layout switch and the menus' view models; the menus'
|
||||
* own states are in the MediaMenuButton stories
|
||||
* - Add additional react context
|
||||
* The paraeters are all params from the FooterSnapshot,
|
||||
* the Snapshot of the vm, the wrapper will create a mocked vm from it and pass it to the CallFooter.
|
||||
@@ -49,15 +96,22 @@ function CallFooterStoryWrapper({
|
||||
children,
|
||||
layout,
|
||||
setLayout,
|
||||
withDevices,
|
||||
...vmSnapshot
|
||||
}: Omit<FooterSnapshot, "layoutSwitchVm"> & {
|
||||
}: Omit<FooterSnapshot, "layoutSwitchVm" | "audioMenuVm" | "cameraMenuVm"> & {
|
||||
children?: false | JSX.Element | JSX.Element[] | undefined;
|
||||
layout: LayoutMode | null;
|
||||
setLayout: (value: LayoutMode) => void;
|
||||
/** Whether the menus have devices to pick, or are plain mute buttons. */
|
||||
withDevices: boolean;
|
||||
}): ReactNode {
|
||||
const audioMenuVm = useStaticViewModel(menu("audio", withDevices));
|
||||
const cameraMenuVm = useStaticViewModel(menu("video", withDevices));
|
||||
const vm = useStaticViewModel({
|
||||
...vmSnapshot,
|
||||
layoutSwitchVm: layout && { layout$: constant(layout), setLayout },
|
||||
audioMenuVm,
|
||||
cameraMenuVm,
|
||||
});
|
||||
return (
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
@@ -102,8 +156,6 @@ const meta = {
|
||||
},
|
||||
toggleScreenSharing: fnArgType,
|
||||
openSettings: fnArgType,
|
||||
toggleAudio: fnArgType,
|
||||
toggleVideo: fnArgType,
|
||||
hangup: fnArgType,
|
||||
},
|
||||
} satisfies Meta<typeof CallFooterStoryWrapper>;
|
||||
@@ -116,32 +168,9 @@ export const Default: Story = {
|
||||
showLogo: false,
|
||||
layout: "grid",
|
||||
setLayout: fn(),
|
||||
audioEnabled: true,
|
||||
audioBusy: false,
|
||||
videoEnabled: true,
|
||||
videoBusy: false,
|
||||
withDevices: false,
|
||||
openSettings: fn(),
|
||||
toggleAudio: fn(),
|
||||
toggleVideo: fn(),
|
||||
toggleScreenSharing: fn(),
|
||||
selectBackgroundEffect: fn(),
|
||||
backgroundEffect: "blur",
|
||||
backgroundEffects: [
|
||||
{ id: "none", kind: "none", removable: false },
|
||||
{ id: "blur", kind: "blur", removable: false },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: `image:${background.id}`,
|
||||
kind: "image" as const,
|
||||
imageUrl: background.imagePath,
|
||||
removable: false,
|
||||
})),
|
||||
],
|
||||
backgroundEffectNotice: undefined,
|
||||
backgroundEffectSettling: false,
|
||||
backgroundImageRefusal: undefined,
|
||||
addBackgroundImage: fn(),
|
||||
removeBackgroundEffect: fn(),
|
||||
cameraTrack: null,
|
||||
hangup: fn(),
|
||||
buttonSize: "lg",
|
||||
showFooter: true,
|
||||
@@ -154,49 +183,17 @@ export const Default: Story = {
|
||||
reactionData: undefined,
|
||||
debugTileLayout: false,
|
||||
tileStoreGeneration: undefined,
|
||||
audioOptions: [],
|
||||
audioOutputOptions: [],
|
||||
videoOptions: [],
|
||||
selectedAudio: undefined,
|
||||
selectedAudioOutput: undefined,
|
||||
selectedVideo: undefined,
|
||||
selectAudioButtonOption: undefined,
|
||||
selectAudioOutputOption: undefined,
|
||||
selectVideoButtonOption: undefined,
|
||||
},
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAudioAndVideoOptions: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: false,
|
||||
videoEnabled: true,
|
||||
audioOptions: [
|
||||
{ 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",
|
||||
},
|
||||
};
|
||||
|
||||
export const StaysWhileAMenuIsOpen: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...WithAudioAndVideoOptions.args,
|
||||
...Default.args,
|
||||
withDevices: true,
|
||||
// In a short window the footer overlays the call and hides itself.
|
||||
asOverlay: true,
|
||||
showFooter: true,
|
||||
@@ -219,25 +216,6 @@ export const StaysWhileAMenuIsOpen: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const AudioBusy: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: true,
|
||||
audioBusy: true,
|
||||
videoEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const VideoBusy: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: true,
|
||||
videoEnabled: true,
|
||||
videoBusy: true,
|
||||
},
|
||||
};
|
||||
export const WithLogo: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
@@ -246,45 +224,7 @@ export const WithLogo: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const AudioVideoEnabled: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: true,
|
||||
videoEnabled: true,
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" });
|
||||
await userEvent.click(spotlightRadio);
|
||||
await expect(args.setLayout).toHaveBeenCalledWith("spotlight");
|
||||
|
||||
const micButtonMute = canvas.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
});
|
||||
await userEvent.click(micButtonMute);
|
||||
await expect(args.toggleAudio).toHaveBeenCalled();
|
||||
|
||||
const videoMuteButton = canvas.getByRole("switch", {
|
||||
name: "Stop video",
|
||||
});
|
||||
await userEvent.click(videoMuteButton);
|
||||
await expect(args.toggleVideo).toHaveBeenCalled();
|
||||
const screenShare = canvas.getByRole("switch", {
|
||||
name: "Share screen",
|
||||
});
|
||||
await userEvent.click(screenShare);
|
||||
await expect(args.toggleScreenSharing).toHaveBeenCalled();
|
||||
const endCall = canvas.getByRole("button", {
|
||||
name: "End call",
|
||||
});
|
||||
await userEvent.click(endCall);
|
||||
await expect(args.hangup).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
/** used to test switching to grid mode */
|
||||
/** Switching to grid, and the footer's own buttons, act. */
|
||||
export const SpotlightMode: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
@@ -293,10 +233,12 @@ export const SpotlightMode: Story = {
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const spotlightRadio = canvas.getByRole("radio", { name: "Grid" });
|
||||
await userEvent.click(spotlightRadio);
|
||||
await userEvent.click(canvas.getByRole("radio", { name: "Grid" }));
|
||||
await expect(args.setLayout).toHaveBeenCalledWith("grid");
|
||||
await userEvent.click(canvas.getByRole("switch", { name: "Share screen" }));
|
||||
await expect(args.toggleScreenSharing).toHaveBeenCalled();
|
||||
await userEvent.click(canvas.getByRole("button", { name: "End call" }));
|
||||
await expect(args.hangup).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -330,34 +272,10 @@ export const Pip: Story = {
|
||||
buttonSize: "md",
|
||||
layout: null,
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(
|
||||
canvas.queryByRole("radio", { name: "Spotlight" }),
|
||||
within(canvasElement).queryByRole("radio", { name: "Spotlight" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const micButtonMute = canvas.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
});
|
||||
await userEvent.click(micButtonMute);
|
||||
await expect(args.toggleAudio).toHaveBeenCalled();
|
||||
|
||||
const videoMuteButton = canvas.getByRole("switch", {
|
||||
name: "Stop video",
|
||||
});
|
||||
await userEvent.click(videoMuteButton);
|
||||
await expect(args.toggleVideo).toHaveBeenCalled();
|
||||
const screenShare = canvas.getByRole("switch", {
|
||||
name: "Share screen",
|
||||
});
|
||||
await userEvent.click(screenShare);
|
||||
await expect(args.toggleScreenSharing).toHaveBeenCalled();
|
||||
const endCall = canvas.getByRole("button", {
|
||||
name: "End call",
|
||||
});
|
||||
await userEvent.click(endCall);
|
||||
await expect(args.hangup).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
export const NoControlsWithLogo: Story = {
|
||||
@@ -378,18 +296,6 @@ export const DebugData: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const UnavailableMediaDevices: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: false,
|
||||
videoEnabled: false,
|
||||
toggleAudio: undefined,
|
||||
toggleVideo: undefined,
|
||||
audioOutputSwitcher: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const MobileLayout: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
@@ -420,37 +326,6 @@ export const Lobby: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const LobbyMobile: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showLogo: false,
|
||||
|
||||
layout: null,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
globals: {
|
||||
viewport: { value: "mobile2", isRotated: false },
|
||||
},
|
||||
parameters: {
|
||||
...Default.parameters,
|
||||
},
|
||||
};
|
||||
|
||||
export const LobbyRecentButton: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
children: <Link>Back To Recents</Link>,
|
||||
showLogo: false,
|
||||
layout: null,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
parameters: {
|
||||
...Default.parameters,
|
||||
},
|
||||
};
|
||||
|
||||
export const LobbyRecentButtonMobile: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
|
||||
+20
-165
@@ -7,14 +7,11 @@ 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 { useTranslation } from "react-i18next";
|
||||
|
||||
import LogoMark from "../icons/LogoMark.svg?react";
|
||||
import LogoType from "../icons/LogoType.svg?react";
|
||||
import {
|
||||
EndCallButton,
|
||||
MicButton,
|
||||
VideoButton,
|
||||
ShareScreenButton,
|
||||
SettingsButton,
|
||||
ReactionToggleButton,
|
||||
@@ -23,23 +20,13 @@ import {
|
||||
type ReactionData,
|
||||
} from "../button";
|
||||
import styles from "./CallFooter.module.css";
|
||||
import {
|
||||
MediaMuteAndSwitchButton,
|
||||
type MenuOptions,
|
||||
} from "./MediaMuteAndSwitchButton";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
import { type ViewModel } from "../state/ViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
|
||||
import { LayoutSwitch } from "../room/LayoutSwitch";
|
||||
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
import { SelfPreview } from "./SelfPreview";
|
||||
import { type UnusableReason } from "../tracks/backgroundImages";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
export interface BackgroundImageRefusal {
|
||||
reason: UnusableReason | "not-kept";
|
||||
}
|
||||
import { type MediaMenuViewModel } from "./MediaMenuViewModel";
|
||||
import { MediaMenuButton } from "./MediaMenuButton";
|
||||
|
||||
export interface AudioOutputSwitcher {
|
||||
targetOutput: string;
|
||||
@@ -61,16 +48,6 @@ export interface AudioOutputSwitcher {
|
||||
*/
|
||||
export type FooterSnapshot = FooterActions & FooterState;
|
||||
export interface FooterActions {
|
||||
/** Also controls if the audioMute button is disabled */
|
||||
toggleAudio: (() => void) | undefined;
|
||||
/** Also controls if the videoMute button is disabled */
|
||||
toggleVideo: (() => void) | undefined;
|
||||
/** Undefined where background effects can't be chosen. */
|
||||
selectBackgroundEffect: ((id: string) => void) | undefined;
|
||||
/** Undefined where no background can be added. */
|
||||
addBackgroundImage: ((file: File) => void) | undefined;
|
||||
/** Removes an added background by its option id; never the one in force. */
|
||||
removeBackgroundEffect: ((id: string) => void) | undefined;
|
||||
toggleScreenSharing: (() => void) | undefined;
|
||||
/** Also controls if the settings button is visible */
|
||||
openSettings: (() => void) | undefined;
|
||||
@@ -80,22 +57,8 @@ export interface FooterActions {
|
||||
|
||||
// we do not use any ? optional properties so that the vm type is including all fields.
|
||||
export interface FooterState {
|
||||
audioEnabled: boolean;
|
||||
audioBusy: boolean;
|
||||
videoEnabled: boolean;
|
||||
videoBusy: boolean;
|
||||
/** The background effect in force, as its option id. */
|
||||
backgroundEffect: string;
|
||||
/** Every effect on offer, in the order they are shown. */
|
||||
backgroundEffects: BackgroundEffectGridItem[];
|
||||
/** What the user is told about effects here, if anything. */
|
||||
backgroundEffectNotice: "unavailable" | "slow" | undefined;
|
||||
/** Whether the first effect chosen is still being prepared. */
|
||||
backgroundEffectSettling: boolean;
|
||||
/** Why the last file offered couldn't be kept, if it couldn't. */
|
||||
backgroundImageRefusal: BackgroundImageRefusal | undefined;
|
||||
/** The camera to preview, with the effect in force; null while it is off. */
|
||||
cameraTrack: LocalVideoMediaTrack | null;
|
||||
audioMenuVm: MediaMenuViewModel;
|
||||
cameraMenuVm: MediaMenuViewModel;
|
||||
showFooter: boolean;
|
||||
|
||||
/* This is needed for WindowMode = "flat" */
|
||||
@@ -122,19 +85,6 @@ export interface FooterState {
|
||||
// debug stuff
|
||||
debugTileLayout: boolean;
|
||||
tileStoreGeneration: number | undefined;
|
||||
|
||||
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
||||
audioOptions: MenuOptions[];
|
||||
/** Output (speaker) devices. */
|
||||
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;
|
||||
}
|
||||
|
||||
export interface FooterProps {
|
||||
@@ -150,19 +100,14 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
children,
|
||||
vm,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const asOverlay = useBehavior(vm.asOverlay$);
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const hideControls = useBehavior(vm.hideControls$);
|
||||
const showModals = useBehavior(vm.showModals$);
|
||||
const layoutSwitchVm = useBehavior(vm.layoutSwitchVm$);
|
||||
const audioMenuVm = useBehavior(vm.audioMenuVm$);
|
||||
const cameraMenuVm = useBehavior(vm.cameraMenuVm$);
|
||||
const openSettings = useBehavior(vm.openSettings$);
|
||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||
const audioBusy = useBehavior(vm.audioBusy$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
const videoBusy = useBehavior(vm.videoBusy$);
|
||||
const toggleAudio = useBehavior(vm.toggleAudio$);
|
||||
const toggleVideo = useBehavior(vm.toggleVideo$);
|
||||
const sharingScreen = useBehavior(vm.sharingScreen$);
|
||||
const toggleScreenSharing = useBehavior(vm.toggleScreenSharing$);
|
||||
const reactionIdentifier = useBehavior(vm.reactionIdentifier$);
|
||||
@@ -170,38 +115,6 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
||||
const hangup = useBehavior(vm.hangup$);
|
||||
const debugTileLayout = useBehavior(vm.debugTileLayout$);
|
||||
const videoOptions = useBehavior(vm.videoOptions$);
|
||||
const selectedVideo = useBehavior(vm.selectedVideo$);
|
||||
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 backgroundEffect = useBehavior(vm.backgroundEffect$);
|
||||
const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
|
||||
const backgroundEffectNotice = useBehavior(vm.backgroundEffectNotice$);
|
||||
const backgroundEffectSettling = useBehavior(vm.backgroundEffectSettling$);
|
||||
const addBackgroundImage = useBehavior(vm.addBackgroundImage$);
|
||||
const removeBackgroundEffect = useBehavior(vm.removeBackgroundEffect$);
|
||||
const cameraTrack = useBehavior(vm.cameraTrack$);
|
||||
const refusal = useBehavior(vm.backgroundImageRefusal$);
|
||||
const refusalMessage = useMemo(() => {
|
||||
switch (refusal?.reason) {
|
||||
case undefined:
|
||||
return undefined;
|
||||
case "not-an-image":
|
||||
return { text: t("error.background_not_an_image") };
|
||||
case "animated":
|
||||
return { text: t("error.background_animated") };
|
||||
case "undecodable":
|
||||
return { text: t("error.background_undecodable") };
|
||||
case "not-kept":
|
||||
return { text: t("error.background_not_kept") };
|
||||
}
|
||||
}, [refusal, t]);
|
||||
const backgroundEffects = useBehavior(vm.backgroundEffects$);
|
||||
const buttonSize = useBehavior(vm.buttonSize$);
|
||||
const showLogo = useBehavior(vm.showLogo$);
|
||||
|
||||
@@ -221,78 +134,20 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if ((audioOptions?.length ?? 0) > 0) {
|
||||
buttons.push(
|
||||
<MediaMuteAndSwitchButton
|
||||
key="audio"
|
||||
iconsAndLabels="audio"
|
||||
enabled={audioEnabled ?? false}
|
||||
busy={audioBusy ?? false}
|
||||
onMuteClick={toggleAudio}
|
||||
data-testid="incall_mute"
|
||||
options={audioOptions}
|
||||
selectedOption={selectedAudio}
|
||||
onSelect={selectAudioButtonOption}
|
||||
outputOptions={audioOutputOptions}
|
||||
selectedOutputOption={selectedAudioOutput}
|
||||
onSelectOutput={selectAudioOutputOption}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
buttons.push(
|
||||
<MicButton
|
||||
size={buttonSize}
|
||||
key="audio"
|
||||
enabled={audioEnabled ?? false}
|
||||
busy={audioBusy ?? false}
|
||||
onClick={toggleAudio}
|
||||
disabled={(audioBusy ?? false) || toggleAudio === undefined}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if ((videoOptions?.length ?? 0) > 0) {
|
||||
buttons.push(
|
||||
<MediaMuteAndSwitchButton
|
||||
key="video"
|
||||
iconsAndLabels="video"
|
||||
enabled={videoEnabled ?? false}
|
||||
busy={videoBusy ?? false}
|
||||
onMuteClick={toggleVideo}
|
||||
options={videoOptions}
|
||||
selectedOption={selectedVideo}
|
||||
onSelect={selectVideoButtonOption}
|
||||
backgroundEffects={backgroundEffects}
|
||||
selectedBackgroundEffect={backgroundEffect}
|
||||
onSelectBackgroundEffect={selectBackgroundEffect}
|
||||
backgroundEffectSettling={backgroundEffectSettling}
|
||||
onAddBackgroundImage={addBackgroundImage}
|
||||
onRemoveBackgroundEffect={removeBackgroundEffect}
|
||||
selfPreview={<SelfPreview track={cameraTrack} />}
|
||||
backgroundImageRefusal={refusalMessage}
|
||||
backgroundEffectNotice={
|
||||
backgroundEffectNotice === "unavailable"
|
||||
? t("background_effects.unavailable")
|
||||
: backgroundEffectNotice === "slow"
|
||||
? t("background_effects.slow")
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
buttons.push(
|
||||
<VideoButton
|
||||
size={buttonSize}
|
||||
key="video"
|
||||
enabled={videoEnabled ?? false}
|
||||
busy={videoBusy ?? false}
|
||||
onClick={toggleVideo}
|
||||
disabled={(videoBusy ?? false) || toggleVideo === undefined}
|
||||
data-testid="incall_videomute"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
buttons.push(
|
||||
<MediaMenuButton
|
||||
key="audio"
|
||||
kind="audio"
|
||||
vm={audioMenuVm}
|
||||
size={buttonSize}
|
||||
/>,
|
||||
<MediaMenuButton
|
||||
key="video"
|
||||
kind="video"
|
||||
vm={cameraMenuVm}
|
||||
size={buttonSize}
|
||||
/>,
|
||||
);
|
||||
|
||||
if (toggleScreenSharing !== undefined) {
|
||||
buttons.push(
|
||||
|
||||
@@ -5,18 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
flushPromises,
|
||||
testScope,
|
||||
mockMuteStates,
|
||||
mockMediaDevices,
|
||||
mockVideoTrack,
|
||||
} from "../utils/test";
|
||||
import { type Behavior, constant } from "../state/Behavior";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import type { Layout } from "../state/layout-types";
|
||||
@@ -27,15 +24,6 @@ import {
|
||||
createLobbyFooterViewModel,
|
||||
} from "./CallFooterViewModel";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
|
||||
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
|
||||
import type * as BackgroundImages from "../tracks/backgroundImages";
|
||||
import {
|
||||
type AddedBackground,
|
||||
addedBackgrounds,
|
||||
maxAddedBackgrounds,
|
||||
UnusableImage,
|
||||
} from "../tracks/backgroundImages";
|
||||
|
||||
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../Platform", () => ({
|
||||
@@ -44,35 +32,6 @@ vi.mock("../Platform", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// The SDK's own check needs WebGL and canvas APIs jsdom does not have.
|
||||
const sdkSupportMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const modernRouteMock = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("@livekit/track-processors", () => ({
|
||||
supportsBackgroundProcessors: (): boolean => sdkSupportMock(),
|
||||
supportsModernBackgroundProcessors: (): boolean => modernRouteMock(),
|
||||
}));
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
add: vi.fn<(file: Blob) => Promise<string>>(),
|
||||
remove: vi.fn<(id: string) => Promise<void>>(),
|
||||
}));
|
||||
vi.mock("../tracks/backgroundImages", async (original) => {
|
||||
const { BehaviorSubject } = await import("rxjs");
|
||||
return {
|
||||
...(await original<typeof BackgroundImages>()),
|
||||
addedBackgrounds: {
|
||||
added$: new BehaviorSubject<AddedBackground[] | undefined>([]),
|
||||
add: store.add,
|
||||
remove: store.remove,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("livekit-client", () => ({
|
||||
supportsAudioOutputSelection: (): boolean => outputSelectionMock(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Returns the minimum set of CallViewModel fields required by
|
||||
* createCallFooterViewModel, with all other properties stubbed to
|
||||
@@ -113,424 +72,82 @@ const pipLayout: Layout = {
|
||||
spotlight: {} as SpotlightTileViewModel,
|
||||
};
|
||||
|
||||
const noPipeline = constant<BackgroundEffectsState>({
|
||||
supported: false,
|
||||
const pipeline = constant<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
});
|
||||
|
||||
/** The provider's verdict, which the SDK's check stands for here. */
|
||||
function pipelineAsTheSdkSays(): Behavior<BackgroundEffectsState> {
|
||||
return constant({ supported: sdkSupportMock(), processor: undefined });
|
||||
}
|
||||
|
||||
const twoMicsAndOneCamMediaDevices = mockMediaDevices({
|
||||
audioInput: {
|
||||
available$: constant(
|
||||
new Map<string, DeviceLabel>([
|
||||
["mic1", { type: "number", number: 1 }],
|
||||
["mic2", { type: "name", name: "Microphone 2" }],
|
||||
]),
|
||||
),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
videoInput: {
|
||||
available$: constant(
|
||||
new Map<string, DeviceLabel>([
|
||||
["cam1", { type: "name", name: "Camera 1" }],
|
||||
]),
|
||||
),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
const oneOfEach = {
|
||||
available$: constant(
|
||||
new Map<string, DeviceLabel>([["1", { type: "number", number: 1 }]]),
|
||||
),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
};
|
||||
const devices = mockMediaDevices({
|
||||
audioInput: oneOfEach,
|
||||
videoInput: oneOfEach,
|
||||
});
|
||||
|
||||
describe("createCallFooterViewModel", () => {
|
||||
describe("selectAudioOutputOption", () => {
|
||||
function buildFooterVm(): ReturnType<typeof createCallFooterViewModel> {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
return createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
}
|
||||
|
||||
it("is withheld where the platform cannot route audio to a chosen device", () => {
|
||||
outputSelectionMock.mockReturnValue(false);
|
||||
expect(buildFooterVm().selectAudioOutputOption$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is offered where the platform can route audio to a chosen device", () => {
|
||||
outputSelectionMock.mockReturnValue(true);
|
||||
expect(buildFooterVm().selectAudioOutputOption$.value).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("audioOutputOptions", () => {
|
||||
it("is an empty list, not absent, where the platform enumerates no outputs", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
outputSelectionMock.mockReturnValue(true);
|
||||
|
||||
const vm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
mockMediaDevices({
|
||||
audioInput: {
|
||||
available$: constant(
|
||||
new Map<string, DeviceLabel>([
|
||||
["mic1", { type: "name", name: "Microphone 1" }],
|
||||
]),
|
||||
),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
// As Safari: no outputs listed.
|
||||
audioOutput: {
|
||||
available$: constant(new Map<string, DeviceLabel>()),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
}),
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
|
||||
// Empty, not undefined: undefined would hide the section.
|
||||
expect(vm.audioOutputOptions$.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audioOptions and videoOptions", () => {
|
||||
function checkEmptyFor(platform: string, layout: Layout): void {
|
||||
it.each([
|
||||
["desktop", "grid", true, gridLayout],
|
||||
["ios", "grid", false, gridLayout],
|
||||
["desktop", "pip", false, pipLayout],
|
||||
] as const)(
|
||||
"on %s in a %s layout, offers the device menus: %s",
|
||||
(platform, _, offered, layout) => {
|
||||
platformMock.mockReturnValue(platform);
|
||||
|
||||
const vm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(layout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
devices,
|
||||
pipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
const audio = vm.audioMenuVm$.value;
|
||||
const camera = vm.cameraMenuVm$.value;
|
||||
expect(audio.options$.value.length > 0).toBe(offered);
|
||||
expect(camera.options$.value.length > 0).toBe(offered);
|
||||
expect(camera.selectBackgroundEffect$.value !== undefined).toBe(offered);
|
||||
},
|
||||
);
|
||||
|
||||
expect(vm.audioOptions$.value).toEqual([]);
|
||||
expect(vm.videoOptions$.value).toEqual([]);
|
||||
}
|
||||
it("are both empty when the platform is iOS", () => {
|
||||
checkEmptyFor("ios", gridLayout);
|
||||
});
|
||||
it("are both empty when the layout is pip", () => {
|
||||
checkEmptyFor("desktop", pipLayout);
|
||||
});
|
||||
|
||||
it("are populated when the platform is desktop and the layout is not PiP", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
|
||||
const vm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
|
||||
expect(vm.audioOptions$?.value).toEqual([
|
||||
{
|
||||
id: "mic1",
|
||||
label: {
|
||||
number: 1,
|
||||
type: "number",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mic2",
|
||||
label: {
|
||||
name: "Microphone 2",
|
||||
type: "name",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(vm.videoOptions$?.value).toEqual([
|
||||
{
|
||||
id: "cam1",
|
||||
label: {
|
||||
name: "Camera 1",
|
||||
type: "name",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("previews the call's camera", () => {
|
||||
const track = mockVideoTrack({});
|
||||
const vm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
{ ...buildMinimalCallViewModel(gridLayout), camera$: constant(track) },
|
||||
mockMuteStates(),
|
||||
devices,
|
||||
pipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
expect(vm.cameraMenuVm$.value.cameraTrack$.value).toBe(track);
|
||||
});
|
||||
|
||||
describe("background effects", () => {
|
||||
afterEach(() => backgroundEffectSetting.setValue("none"));
|
||||
|
||||
// The lobby, because in a call the whole device switcher is already
|
||||
// withheld on a phone, whatever the verdict says.
|
||||
function lobbyOn(
|
||||
platform: string,
|
||||
): ReturnType<typeof createLobbyFooterViewModel> {
|
||||
platformMock.mockReturnValue(platform);
|
||||
return createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
pipelineAsTheSdkSays(),
|
||||
/* camera$ */ constant(null),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
}
|
||||
|
||||
it("offers them on a phone whose browser can run them", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
expect(lobbyOn("ios").selectBackgroundEffect$.value).toBeDefined();
|
||||
});
|
||||
|
||||
it("offers them on a desktop whose browser can run them", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
expect(lobbyOn("desktop").selectBackgroundEffect$.value).toBeDefined();
|
||||
});
|
||||
|
||||
it("offers nothing where the browser itself cannot run them", () => {
|
||||
sdkSupportMock.mockReturnValue(false);
|
||||
expect(lobbyOn("desktop").selectBackgroundEffect$.value).toBeUndefined();
|
||||
expect(lobbyOn("desktop").addBackgroundImage$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("puts no effect in force where the browser cannot run them", () => {
|
||||
backgroundEffectSetting.setValue("blur");
|
||||
sdkSupportMock.mockReturnValue(false);
|
||||
expect(lobbyOn("desktop").backgroundEffect$.value).toBe("none");
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
expect(lobbyOn("desktop").backgroundEffect$.value).toBe("blur");
|
||||
});
|
||||
|
||||
it("stores the effect chosen", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
const vm = lobbyOn("desktop");
|
||||
vm.selectBackgroundEffect$.value?.("image:arc");
|
||||
expect(vm.backgroundEffect$.value).toBe("image:arc");
|
||||
// What isn't an effect on offer is stored as none.
|
||||
vm.selectBackgroundEffect$.value?.("image:gone");
|
||||
expect(backgroundEffectSetting.getValue()).toBe("none");
|
||||
});
|
||||
|
||||
it("says they run slowly where only the slower route exists", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
modernRouteMock.mockReturnValue(false);
|
||||
const slow = lobbyOn("desktop");
|
||||
expect(slow.backgroundEffectNotice$.value).toBe("slow");
|
||||
expect(slow.selectBackgroundEffect$.value).toBeDefined();
|
||||
modernRouteMock.mockReturnValue(true);
|
||||
expect(lobbyOn("desktop").backgroundEffectNotice$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("offers every effect in order, the added ones removable", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
const added$ = addedBackgrounds.added$ as BehaviorSubject<
|
||||
AddedBackground[] | undefined
|
||||
>;
|
||||
added$.next([{ id: "mine", url: "blob:mine" }]);
|
||||
expect(lobbyOn("desktop").backgroundEffects$.value).toEqual([
|
||||
{ id: "none", kind: "none", removable: false },
|
||||
{ id: "blur", kind: "blur", removable: false },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: `image:${background.id}`,
|
||||
kind: "image",
|
||||
imageUrl: background.imagePath,
|
||||
removable: false,
|
||||
})),
|
||||
{
|
||||
id: "added:mine",
|
||||
kind: "image",
|
||||
imageUrl: "blob:mine",
|
||||
removable: true,
|
||||
},
|
||||
]);
|
||||
added$.next([]);
|
||||
});
|
||||
|
||||
it("says they are unavailable where they cannot be chosen", () => {
|
||||
sdkSupportMock.mockReturnValue(false);
|
||||
expect(lobbyOn("desktop").backgroundEffectNotice$.value).toBe(
|
||||
"unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes on the wait for the first effect", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
wait: "settling",
|
||||
});
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
pipeline$,
|
||||
/* camera$ */ constant(null),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
expect(vm.backgroundEffectSettling$.value).toBe(true);
|
||||
pipeline$.next({ ...pipeline$.value, wait: undefined });
|
||||
expect(vm.backgroundEffectSettling$.value).toBe(false);
|
||||
});
|
||||
|
||||
it("offers nothing once the pipeline fails to build, keeping the choice", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
backgroundEffectSetting.setValue("blur");
|
||||
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
});
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
pipeline$,
|
||||
/* camera$ */ constant(null),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
expect(vm.backgroundEffect$.value).toBe("blur");
|
||||
|
||||
pipeline$.next({ supported: false, processor: undefined });
|
||||
expect(vm.selectBackgroundEffect$.value).toBeUndefined();
|
||||
expect(vm.addBackgroundImage$.value).toBeUndefined();
|
||||
expect(vm.backgroundEffect$.value).toBe("none");
|
||||
expect(backgroundEffectSetting.getValue()).toBe("blur");
|
||||
});
|
||||
|
||||
it("adding is unavailable at the limit", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
const added$ = addedBackgrounds.added$ as BehaviorSubject<
|
||||
AddedBackground[] | undefined
|
||||
>;
|
||||
const vm = lobbyOn("desktop");
|
||||
expect(vm.addBackgroundImage$.value).toBeDefined();
|
||||
added$.next(
|
||||
Array.from({ length: maxAddedBackgrounds }, (_, i) => ({
|
||||
id: `${i}`,
|
||||
url: `blob:${i}`,
|
||||
})),
|
||||
);
|
||||
expect(vm.addBackgroundImage$.value).toBeUndefined();
|
||||
added$.next(added$.value!.slice(1));
|
||||
expect(vm.addBackgroundImage$.value).toBeDefined();
|
||||
added$.next([]);
|
||||
});
|
||||
|
||||
it("says why each refused file was refused", async () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
const vm = lobbyOn("desktop");
|
||||
store.add.mockRejectedValue(new UnusableImage("animated"));
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
|
||||
await flushPromises();
|
||||
const first = vm.backgroundImageRefusal$.value;
|
||||
expect(first).toEqual({ reason: "animated" });
|
||||
|
||||
// The same again is a refusal of its own, so it is shown again.
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
|
||||
await flushPromises();
|
||||
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "animated" });
|
||||
expect(vm.backgroundImageRefusal$.value).not.toBe(first);
|
||||
|
||||
store.add.mockRejectedValue(new Error("quota"));
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "b.png"));
|
||||
await flushPromises();
|
||||
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "not-kept" });
|
||||
|
||||
store.add.mockResolvedValue("kept");
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "c.png"));
|
||||
await flushPromises();
|
||||
expect(vm.backgroundImageRefusal$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never removes the background in force", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
store.remove.mockResolvedValue();
|
||||
const vm = lobbyOn("desktop");
|
||||
backgroundEffectSetting.setValue("added:worn");
|
||||
vm.removeBackgroundEffect$.value!("added:worn");
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
vm.removeBackgroundEffect$.value!("added:spare");
|
||||
expect(store.remove).toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("previews the lobby's camera while there is one", () => {
|
||||
const track = mockVideoTrack({});
|
||||
const camera$ = new BehaviorSubject<LocalVideoMediaTrack | null>(track);
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
camera$,
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
// The lobby releases the camera when it is turned off
|
||||
camera$.next(null);
|
||||
expect(vm.cameraTrack$.value).toBeNull();
|
||||
});
|
||||
|
||||
it("previews the call's camera only while it is not muted", () => {
|
||||
const muted$ = new BehaviorSubject(false);
|
||||
const track = mockVideoTrack({ muted$ });
|
||||
const vm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
{ ...buildMinimalCallViewModel(gridLayout), camera$: constant(track) },
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
muted$.next(true);
|
||||
expect(vm.cameraTrack$.value).toBeNull();
|
||||
muted$.next(false);
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
});
|
||||
|
||||
it("availability is the same before and during a call", () => {
|
||||
for (const supported of [true, false]) {
|
||||
sdkSupportMock.mockReturnValue(supported);
|
||||
const lobby = lobbyOn("desktop");
|
||||
const inCall = createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
pipelineAsTheSdkSays(),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
const offeredInLobby =
|
||||
lobby.selectBackgroundEffect$.value !== undefined;
|
||||
expect(inCall.selectBackgroundEffect$.value !== undefined).toBe(
|
||||
offeredInLobby,
|
||||
);
|
||||
expect(offeredInLobby).toBe(supported);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLobbyFooterViewModel", () => {
|
||||
it("offers the device menus even on a phone, previewing the lobby's camera", () => {
|
||||
platformMock.mockReturnValue("ios");
|
||||
const track = mockVideoTrack({});
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
devices,
|
||||
pipeline,
|
||||
constant(track),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
const camera = vm.cameraMenuVm$.value;
|
||||
expect(vm.audioMenuVm$.value.options$.value).not.toEqual([]);
|
||||
expect(camera.options$.value).not.toEqual([]);
|
||||
expect(camera.selectBackgroundEffect$.value).toBeDefined();
|
||||
expect(camera.cameraTrack$.value).toBe(track);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,253 +5,24 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { supportsAudioOutputSelection } from "livekit-client";
|
||||
import { combineLatest, map, type Observable } from "rxjs";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
import { supportsModernBackgroundProcessors } from "@livekit/track-processors";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
parseEffect,
|
||||
serializeEffect,
|
||||
shippedBackgrounds,
|
||||
} from "../tracks/backgroundEffectCatalog";
|
||||
import {
|
||||
type AddedBackground,
|
||||
addedBackgrounds,
|
||||
maxAddedBackgrounds,
|
||||
UnusableImage,
|
||||
} from "../tracks/backgroundImages";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import {
|
||||
backgroundEffect as backgroundEffectSetting,
|
||||
debugTileLayout as debugTileLayoutSetting,
|
||||
} from "../settings/settings";
|
||||
import { debugTileLayout as debugTileLayoutSetting } from "../settings/settings";
|
||||
import { type Behavior, constant } from "../state/Behavior";
|
||||
import type { ObservableScope } from "../state/ObservableScope";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { platform } from "../Platform";
|
||||
import { type BackgroundImageRefusal, type FooterSnapshot } from "./CallFooter";
|
||||
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
|
||||
/**
|
||||
* Shared helper: maps MuteStates into the audio/video enabled + toggle behaviors
|
||||
* needed by FooterSnapshot.
|
||||
*/
|
||||
function buildMuteBehaviors(
|
||||
scope: ObservableScope,
|
||||
muteStates: MuteStates,
|
||||
): Pick<
|
||||
ViewModel<FooterSnapshot>,
|
||||
| "audioEnabled$"
|
||||
| "audioBusy$"
|
||||
| "toggleAudio$"
|
||||
| "videoEnabled$"
|
||||
| "videoBusy$"
|
||||
| "toggleVideo$"
|
||||
> {
|
||||
return {
|
||||
audioEnabled$: muteStates.audio.enabled$,
|
||||
audioBusy$: muteStates.audio.syncing$,
|
||||
toggleAudio$: scope.behavior(
|
||||
muteStates.audio.toggle$.pipe(map((t) => t ?? undefined)),
|
||||
),
|
||||
videoEnabled$: muteStates.video.enabled$,
|
||||
videoBusy$: muteStates.video.syncing$,
|
||||
toggleVideo$: scope.behavior(
|
||||
muteStates.video.toggle$.pipe(map((t) => t ?? undefined)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared helper: maps MediaDevices into the audio/video device-list behaviors
|
||||
* needed by FooterSnapshot (options, selection, callbacks, background effect).
|
||||
*/
|
||||
function buildDeviceBehaviors(
|
||||
scope: ObservableScope,
|
||||
mediaDevices: MediaDevices,
|
||||
/** return empty arrays for audioOptions and videoOptions*/
|
||||
disableSwitcher$: Behavior<boolean>,
|
||||
backgroundEffectsState$: Observable<BackgroundEffectsState>,
|
||||
/** The camera as the pipeline has it, for the menu's preview. */
|
||||
camera$: Observable<LocalVideoMediaTrack | null | undefined>,
|
||||
): Pick<
|
||||
ViewModel<FooterSnapshot>,
|
||||
| "audioOptions$"
|
||||
| "selectedAudio$"
|
||||
| "selectAudioButtonOption$"
|
||||
| "audioOutputOptions$"
|
||||
| "selectedAudioOutput$"
|
||||
| "selectAudioOutputOption$"
|
||||
| "videoOptions$"
|
||||
| "selectedVideo$"
|
||||
| "selectVideoButtonOption$"
|
||||
| "backgroundEffect$"
|
||||
| "selectBackgroundEffect$"
|
||||
| "backgroundEffects$"
|
||||
| "backgroundEffectNotice$"
|
||||
| "backgroundEffectSettling$"
|
||||
| "addBackgroundImage$"
|
||||
| "backgroundImageRefusal$"
|
||||
| "removeBackgroundEffect$"
|
||||
| "cameraTrack$"
|
||||
> {
|
||||
const options$ = (
|
||||
available$: Behavior<Map<string, MenuOptions["label"]>>,
|
||||
): Observable<MenuOptions[]> =>
|
||||
disableSwitcher$.pipe(
|
||||
switchMap((disable) =>
|
||||
disable
|
||||
? constant([] as MenuOptions[])
|
||||
: available$.pipe(
|
||||
map((available) =>
|
||||
[...available.entries()].map(([id, label]) => ({ id, label })),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Also false once the pipeline has failed to build this session.
|
||||
const supported$ = backgroundEffectsState$.pipe(
|
||||
map(({ supported }) => supported === true),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const slow = !supportsModernBackgroundProcessors();
|
||||
const offered$ = combineLatest([disableSwitcher$, supported$]).pipe(
|
||||
map(([switcherDisabled, supported]) => !switcherDisabled && supported),
|
||||
);
|
||||
// A new object for each refusal, so the same one twice is shown twice.
|
||||
const refusal$ = new BehaviorSubject<BackgroundImageRefusal | undefined>(
|
||||
undefined,
|
||||
);
|
||||
return {
|
||||
audioOptions$: scope.behavior(options$(mediaDevices.audioInput.available$)),
|
||||
selectedAudio$: scope.behavior(
|
||||
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
|
||||
audioOutputOptions$: scope.behavior(
|
||||
options$(mediaDevices.audioOutput.available$),
|
||||
),
|
||||
selectedAudioOutput$: scope.behavior(
|
||||
mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
// Withheld where the platform can't route audio to a chosen device, which
|
||||
// disables the speaker section.
|
||||
selectAudioOutputOption$: constant(
|
||||
supportsAudioOutputSelection()
|
||||
? mediaDevices.audioOutput.select
|
||||
: undefined,
|
||||
),
|
||||
videoOptions$: scope.behavior(options$(mediaDevices.videoInput.available$)),
|
||||
selectedVideo$: scope.behavior(
|
||||
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
|
||||
backgroundEffect$: scope.behavior(
|
||||
combineLatest([backgroundEffectSetting.value$, supported$]).pipe(
|
||||
map(([raw, supported]) =>
|
||||
supported ? serializeEffect(parseEffect(raw)) : "none",
|
||||
),
|
||||
),
|
||||
),
|
||||
selectBackgroundEffect$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
offered
|
||||
? (id: string): void =>
|
||||
backgroundEffectSetting.setValue(
|
||||
serializeEffect(parseEffect(id)),
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundEffects$: scope.behavior(
|
||||
addedBackgrounds.added$.pipe(
|
||||
map((added) => backgroundEffectChoices(added ?? [])),
|
||||
),
|
||||
),
|
||||
backgroundEffectNotice$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
!offered ? "unavailable" : slow ? "slow" : undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundEffectSettling$: scope.behavior(
|
||||
backgroundEffectsState$.pipe(
|
||||
map(({ wait }) => wait !== undefined),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
false,
|
||||
),
|
||||
// Kept and offered, not put on: that waits for the user to choose it.
|
||||
addBackgroundImage$: scope.behavior(
|
||||
combineLatest([offered$, addedBackgrounds.added$]).pipe(
|
||||
map(([offered, added]) =>
|
||||
offered && (added?.length ?? 0) < maxAddedBackgrounds
|
||||
? (file: File): void => {
|
||||
refusal$.next(undefined);
|
||||
addedBackgrounds.add(file).catch((e) => {
|
||||
logger.warn("Could not keep that background", e);
|
||||
refusal$.next({
|
||||
reason: e instanceof UnusableImage ? e.reason : "not-kept",
|
||||
});
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundImageRefusal$: refusal$,
|
||||
// None while muted: the call mutes its track rather than releasing it.
|
||||
cameraTrack$: scope.behavior(
|
||||
camera$.pipe(
|
||||
switchMap((camera) =>
|
||||
camera
|
||||
? camera.muted$.pipe(map((muted) => (muted ? null : camera)))
|
||||
: of(null),
|
||||
),
|
||||
),
|
||||
null,
|
||||
),
|
||||
// Never the one in force: taking it away would leave nothing chosen.
|
||||
removeBackgroundEffect$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
offered
|
||||
? (optionId: string): void => {
|
||||
const effect = parseEffect(optionId);
|
||||
if (effect.kind !== "added") return;
|
||||
if (backgroundEffectSetting.getValue() === optionId) return;
|
||||
addedBackgrounds
|
||||
.remove(effect.id)
|
||||
.catch((e) =>
|
||||
logger.warn("Could not remove that background", e),
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
import { type FooterSnapshot } from "./CallFooter";
|
||||
import {
|
||||
createAudioMenuViewModel,
|
||||
createCameraMenuViewModel,
|
||||
} from "./MediaMenuViewModel";
|
||||
|
||||
/**
|
||||
* Creates the ViewModel for the CallFooter.
|
||||
@@ -286,13 +57,23 @@ export function createCallFooterViewModel(
|
||||
isPip$.pipe(map((isPip) => isPip || platform !== "desktop")),
|
||||
);
|
||||
return {
|
||||
...buildMuteBehaviors(scope, muteStates),
|
||||
...buildDeviceBehaviors(
|
||||
scope,
|
||||
mediaDevices,
|
||||
disableDeviceSwitcher$,
|
||||
backgroundEffectsState$,
|
||||
callModel.camera$,
|
||||
audioMenuVm$: constant(
|
||||
createAudioMenuViewModel(
|
||||
scope,
|
||||
muteStates,
|
||||
mediaDevices,
|
||||
disableDeviceSwitcher$,
|
||||
),
|
||||
),
|
||||
cameraMenuVm$: constant(
|
||||
createCameraMenuViewModel(
|
||||
scope,
|
||||
muteStates,
|
||||
mediaDevices,
|
||||
disableDeviceSwitcher$,
|
||||
backgroundEffectsState$,
|
||||
callModel.camera$,
|
||||
),
|
||||
),
|
||||
// candidat to move into the FooterViewModel
|
||||
showFooter$: callModel.showFooter$,
|
||||
@@ -372,72 +153,40 @@ export function createLobbyFooterViewModel(
|
||||
hangup: (() => void) | undefined,
|
||||
showLogo: boolean,
|
||||
): ViewModel<FooterSnapshot> {
|
||||
return {
|
||||
...createStaticViewModel({
|
||||
// we can safly skip any props that we do not need.
|
||||
// The view model will then have less keys.
|
||||
// But as soon as we call `useViewModel` and convert back to a snapshot the missing props will
|
||||
// be correcty matching the snapshot type.
|
||||
showLogo,
|
||||
hideControls: false,
|
||||
asOverlay: false,
|
||||
showModals: true,
|
||||
buttonSize: "lg",
|
||||
openSettings,
|
||||
hangup,
|
||||
debugTileLayout: false,
|
||||
showFooter: true,
|
||||
toggleAudio: undefined,
|
||||
toggleVideo: undefined,
|
||||
toggleScreenSharing: undefined,
|
||||
audioEnabled: undefined,
|
||||
audioBusy: false,
|
||||
videoEnabled: undefined,
|
||||
videoBusy: false,
|
||||
layoutSwitchVm: null,
|
||||
sharingScreen: false,
|
||||
audioOutputSwitcher: undefined,
|
||||
reactionIdentifier: undefined,
|
||||
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),
|
||||
...buildDeviceBehaviors(
|
||||
return createStaticViewModel({
|
||||
// we can safly skip any props that we do not need.
|
||||
// The view model will then have less keys.
|
||||
// But as soon as we call `useViewModel` and convert back to a snapshot the missing props will
|
||||
// be correcty matching the snapshot type.
|
||||
showLogo,
|
||||
hideControls: false,
|
||||
asOverlay: false,
|
||||
showModals: true,
|
||||
buttonSize: "lg",
|
||||
openSettings,
|
||||
hangup,
|
||||
debugTileLayout: false,
|
||||
showFooter: true,
|
||||
toggleScreenSharing: undefined,
|
||||
layoutSwitchVm: null,
|
||||
sharingScreen: false,
|
||||
audioOutputSwitcher: undefined,
|
||||
reactionIdentifier: undefined,
|
||||
reactionData: undefined,
|
||||
tileStoreGeneration: undefined,
|
||||
audioMenuVm: createAudioMenuViewModel(
|
||||
scope,
|
||||
muteStates,
|
||||
mediaDevices,
|
||||
constant(false),
|
||||
),
|
||||
cameraMenuVm: createCameraMenuViewModel(
|
||||
scope,
|
||||
muteStates,
|
||||
mediaDevices,
|
||||
constant(false),
|
||||
backgroundEffectsState$,
|
||||
camera$,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function backgroundEffectChoices(
|
||||
added: AddedBackground[],
|
||||
): BackgroundEffectGridItem[] {
|
||||
return [
|
||||
{ id: serializeEffect({ kind: "none" }), kind: "none", removable: false },
|
||||
{ id: serializeEffect({ kind: "blur" }), kind: "blur", removable: false },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: serializeEffect({ kind: "shipped", id: background.id }),
|
||||
kind: "image" as const,
|
||||
imageUrl: background.imagePath,
|
||||
removable: false,
|
||||
})),
|
||||
...added.map((background) => ({
|
||||
id: serializeEffect({ kind: "added", id: background.id }),
|
||||
kind: "image" as const,
|
||||
imageUrl: background.url,
|
||||
removable: true,
|
||||
})),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { type JSX, type ReactNode } from "react";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MediaMenuButton } from "./MediaMenuButton";
|
||||
import { type MediaMenuSnapshot } from "./MediaMenuViewModel";
|
||||
import { useStaticViewModel } from "../state/ViewModel";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { MediaDevices } from "../state/MediaDevices";
|
||||
import { globalScope } from "../state/ObservableScope";
|
||||
|
||||
const mediaDevices = new MediaDevices(globalScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
/** Builds the view model from the story's args, so each field is a control. */
|
||||
function MediaMenuButtonStoryWrapper({
|
||||
kind,
|
||||
...snapshot
|
||||
}: MediaMenuSnapshot & { kind: "audio" | "video" }): ReactNode {
|
||||
const vm = useStaticViewModel(snapshot);
|
||||
return <MediaMenuButton kind={kind} vm={vm} size="lg" />;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
component: MediaMenuButtonStoryWrapper,
|
||||
decorators: [
|
||||
(Story): JSX.Element => (
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<Story />
|
||||
</MediaDevicesContext>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof MediaMenuButtonStoryWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const empty = {
|
||||
outputOptions: [],
|
||||
selectedOutput: undefined,
|
||||
selectOutput: undefined,
|
||||
backgroundEffect: undefined,
|
||||
backgroundEffects: [],
|
||||
backgroundEffectNotice: undefined,
|
||||
backgroundEffectSettling: false,
|
||||
backgroundImageRefusal: undefined,
|
||||
selectBackgroundEffect: undefined,
|
||||
addBackgroundImage: undefined,
|
||||
removeBackgroundEffect: undefined,
|
||||
cameraTrack: null,
|
||||
} satisfies Partial<MediaMenuSnapshot>;
|
||||
|
||||
export const Microphone: Story = {
|
||||
args: {
|
||||
...empty,
|
||||
kind: "audio",
|
||||
enabled: true,
|
||||
busy: false,
|
||||
toggle: fn(),
|
||||
options: [
|
||||
{ id: "mic1", label: { type: "name", name: "Microphone 1" } },
|
||||
{ id: "mic2", label: { type: "name", name: "Microphone 2" } },
|
||||
],
|
||||
selected: "mic1",
|
||||
select: fn(),
|
||||
outputOptions: [
|
||||
{ id: "default", label: { type: "default", name: "Built-in Output" } },
|
||||
],
|
||||
selectedOutput: "default",
|
||||
selectOutput: fn(),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole("switch", { name: "Mute microphone" }),
|
||||
);
|
||||
await expect(args.toggle).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const Camera: Story = {
|
||||
args: {
|
||||
...empty,
|
||||
kind: "video",
|
||||
enabled: true,
|
||||
busy: false,
|
||||
toggle: fn(),
|
||||
options: [{ id: "cam1", label: { type: "name", name: "Camera 1" } }],
|
||||
selected: "cam1",
|
||||
select: fn(),
|
||||
backgroundEffect: "none",
|
||||
backgroundEffects: [
|
||||
{ id: "none", kind: "none" },
|
||||
{ id: "blur", kind: "blur" },
|
||||
],
|
||||
selectBackgroundEffect: fn(),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole("switch", { name: "Stop video" }),
|
||||
);
|
||||
await expect(args.toggle).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const CameraBusy: Story = {
|
||||
args: { ...Camera.args, busy: true },
|
||||
};
|
||||
|
||||
/** Opens the effects, for the stories that check what they say. */
|
||||
async function openEffects(canvasElement: HTMLElement): Promise<HTMLElement> {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole("button", { name: "Camera" }),
|
||||
);
|
||||
return within(document.body).findByRole("group", {
|
||||
name: "Background effects",
|
||||
});
|
||||
}
|
||||
|
||||
/** The view model says why; the view says it in words. */
|
||||
export const CameraEffectsUnavailable: Story = {
|
||||
args: {
|
||||
...Camera.args,
|
||||
selectBackgroundEffect: undefined,
|
||||
backgroundEffectNotice: "unavailable",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const section = await openEffects(canvasElement);
|
||||
await expect(
|
||||
within(section).getByRole("group"),
|
||||
).toHaveAccessibleDescription(
|
||||
"Background effects are not supported on this platform.",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CameraEffectsSlow: Story = {
|
||||
args: {
|
||||
...Camera.args,
|
||||
backgroundEffectNotice: "slow",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const section = await openEffects(canvasElement);
|
||||
await expect(
|
||||
within(section).getByRole("group"),
|
||||
).toHaveAccessibleDescription(
|
||||
"Background effects run slowly on this platform, which may cause your video to stutter.",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CameraFileRefused: Story = {
|
||||
args: {
|
||||
...Camera.args,
|
||||
addBackgroundImage: fn(),
|
||||
backgroundImageRefusal: { reason: "not-an-image" },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await openEffects(canvasElement);
|
||||
await expect(
|
||||
await within(document.body).findByText(
|
||||
"That file is not a supported image",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** No devices to pick and none to mute: a plain, disabled mute button. */
|
||||
export const WithoutDevices: Story = {
|
||||
args: {
|
||||
...Camera.args,
|
||||
enabled: false,
|
||||
toggle: undefined,
|
||||
options: [],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.queryByRole("button", { name: "Camera" }),
|
||||
).not.toBeInTheDocument();
|
||||
await expect(canvas.getByTestId("incall_videomute")).toHaveAttribute(
|
||||
"aria-disabled",
|
||||
"true",
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MicButton, VideoButton } from "../button";
|
||||
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||
import { type MediaMenuViewModel } from "./MediaMenuViewModel";
|
||||
import { SelfPreview } from "./SelfPreview";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface Props {
|
||||
vm: MediaMenuViewModel;
|
||||
kind: "audio" | "video";
|
||||
size: "md" | "lg";
|
||||
}
|
||||
|
||||
/**
|
||||
* The microphone's or the camera's mute button: with a menu to pick the
|
||||
* device while there are devices to offer, a plain mute button otherwise.
|
||||
*/
|
||||
export const MediaMenuButton: FC<Props> = ({ vm, kind, size }) => {
|
||||
const { t } = useTranslation();
|
||||
const enabled = useBehavior(vm.enabled$);
|
||||
const busy = useBehavior(vm.busy$);
|
||||
const toggle = useBehavior(vm.toggle$);
|
||||
const options = useBehavior(vm.options$);
|
||||
const selected = useBehavior(vm.selected$);
|
||||
const select = useBehavior(vm.select$);
|
||||
const outputOptions = useBehavior(vm.outputOptions$);
|
||||
const selectedOutput = useBehavior(vm.selectedOutput$);
|
||||
const selectOutput = useBehavior(vm.selectOutput$);
|
||||
const backgroundEffect = useBehavior(vm.backgroundEffect$);
|
||||
const backgroundEffects = useBehavior(vm.backgroundEffects$);
|
||||
const notice = useBehavior(vm.backgroundEffectNotice$);
|
||||
const settling = useBehavior(vm.backgroundEffectSettling$);
|
||||
const refusal = useBehavior(vm.backgroundImageRefusal$);
|
||||
const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
|
||||
const addBackgroundImage = useBehavior(vm.addBackgroundImage$);
|
||||
const removeBackgroundEffect = useBehavior(vm.removeBackgroundEffect$);
|
||||
const cameraTrack = useBehavior(vm.cameraTrack$);
|
||||
|
||||
const testId = kind === "audio" ? "incall_mute" : "incall_videomute";
|
||||
|
||||
const noticeText = useMemo(() => {
|
||||
switch (notice) {
|
||||
case undefined:
|
||||
return undefined;
|
||||
case "unavailable":
|
||||
return t("background_effects.unavailable");
|
||||
case "slow":
|
||||
return t("background_effects.slow");
|
||||
}
|
||||
}, [notice, t]);
|
||||
// A new object for each refusal, so the same one twice is shown twice.
|
||||
const refusalMessage = useMemo(() => {
|
||||
switch (refusal?.reason) {
|
||||
case undefined:
|
||||
return undefined;
|
||||
case "not-an-image":
|
||||
return { text: t("error.background_not_an_image") };
|
||||
case "animated":
|
||||
return { text: t("error.background_animated") };
|
||||
case "undecodable":
|
||||
return { text: t("error.background_undecodable") };
|
||||
case "not-kept":
|
||||
return { text: t("error.background_not_kept") };
|
||||
}
|
||||
}, [refusal, t]);
|
||||
|
||||
if (options.length === 0) {
|
||||
const PlainButton = kind === "audio" ? MicButton : VideoButton;
|
||||
return (
|
||||
<PlainButton
|
||||
size={size}
|
||||
enabled={enabled}
|
||||
busy={busy}
|
||||
onClick={toggle}
|
||||
disabled={busy || toggle === undefined}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return kind === "audio" ? (
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="audio"
|
||||
enabled={enabled}
|
||||
busy={busy}
|
||||
onMuteClick={toggle}
|
||||
data-testid={testId}
|
||||
options={options}
|
||||
selectedOption={selected}
|
||||
onSelect={select}
|
||||
outputOptions={outputOptions}
|
||||
selectedOutputOption={selectedOutput}
|
||||
onSelectOutput={selectOutput}
|
||||
/>
|
||||
) : (
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="video"
|
||||
enabled={enabled}
|
||||
busy={busy}
|
||||
onMuteClick={toggle}
|
||||
options={options}
|
||||
selectedOption={selected}
|
||||
onSelect={select}
|
||||
backgroundEffects={backgroundEffects}
|
||||
selectedBackgroundEffect={backgroundEffect}
|
||||
onSelectBackgroundEffect={selectBackgroundEffect}
|
||||
backgroundEffectSettling={settling}
|
||||
onAddBackgroundImage={addBackgroundImage}
|
||||
onRemoveBackgroundEffect={removeBackgroundEffect}
|
||||
selfPreview={<SelfPreview track={cameraTrack} />}
|
||||
backgroundImageRefusal={refusalMessage}
|
||||
backgroundEffectNotice={noticeText}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { BehaviorSubject, type Observable } from "rxjs";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import {
|
||||
flushPromises,
|
||||
testScope,
|
||||
mockMuteStates,
|
||||
mockMediaDevices,
|
||||
mockVideoTrack,
|
||||
} from "../utils/test";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import type { DeviceLabel } from "../state/MediaDevices";
|
||||
import {
|
||||
createAudioMenuViewModel,
|
||||
createCameraMenuViewModel,
|
||||
type MediaMenuViewModel,
|
||||
} from "./MediaMenuViewModel";
|
||||
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
|
||||
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
|
||||
import type * as BackgroundImages from "../tracks/backgroundImages";
|
||||
import {
|
||||
type AddedBackground,
|
||||
addedBackgrounds,
|
||||
maxAddedBackgrounds,
|
||||
UnusableImage,
|
||||
} from "../tracks/backgroundImages";
|
||||
|
||||
const modernRouteMock = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("@livekit/track-processors", () => ({
|
||||
supportsModernBackgroundProcessors: (): boolean => modernRouteMock(),
|
||||
}));
|
||||
|
||||
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("livekit-client", () => ({
|
||||
supportsAudioOutputSelection: (): boolean => outputSelectionMock(),
|
||||
}));
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
add: vi.fn<(file: Blob) => Promise<string>>(),
|
||||
remove: vi.fn<(id: string) => Promise<void>>(),
|
||||
}));
|
||||
vi.mock("../tracks/backgroundImages", async (original) => {
|
||||
const { BehaviorSubject } = await import("rxjs");
|
||||
return {
|
||||
...(await original<typeof BackgroundImages>()),
|
||||
addedBackgrounds: {
|
||||
added$: new BehaviorSubject<AddedBackground[] | undefined>([]),
|
||||
add: store.add,
|
||||
remove: store.remove,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const added$ = addedBackgrounds.added$ as BehaviorSubject<
|
||||
AddedBackground[] | undefined
|
||||
>;
|
||||
|
||||
const devices = mockMediaDevices({
|
||||
audioInput: {
|
||||
available$: constant(
|
||||
new Map<string, DeviceLabel>([
|
||||
["mic1", { type: "number", number: 1 }],
|
||||
["mic2", { type: "name", name: "Microphone 2" }],
|
||||
]),
|
||||
),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
// As Safari: no outputs listed.
|
||||
audioOutput: {
|
||||
available$: constant(new Map<string, DeviceLabel>()),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
function audioMenu(disableSwitcher = false): MediaMenuViewModel {
|
||||
return createAudioMenuViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
devices,
|
||||
constant(disableSwitcher),
|
||||
);
|
||||
}
|
||||
|
||||
function cameraMenu({
|
||||
supported = true,
|
||||
disableSwitcher = false,
|
||||
pipeline$ = constant({ supported, processor: undefined }),
|
||||
camera$ = constant(null),
|
||||
}: {
|
||||
supported?: boolean;
|
||||
disableSwitcher?: boolean;
|
||||
pipeline$?: Observable<BackgroundEffectsState>;
|
||||
camera$?: Observable<LocalVideoMediaTrack | null>;
|
||||
} = {}): MediaMenuViewModel {
|
||||
return createCameraMenuViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
devices,
|
||||
constant(disableSwitcher),
|
||||
pipeline$,
|
||||
camera$,
|
||||
);
|
||||
}
|
||||
|
||||
describe("createAudioMenuViewModel", () => {
|
||||
it("lists the devices, unless the switcher is withheld", () => {
|
||||
expect(audioMenu().options$.value).toEqual([
|
||||
{ id: "mic1", label: { type: "number", number: 1 } },
|
||||
{ id: "mic2", label: { type: "name", name: "Microphone 2" } },
|
||||
]);
|
||||
expect(audioMenu(true).options$.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists no outputs as an empty list, which keeps the section", () => {
|
||||
expect(audioMenu().outputOptions$.value).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"offers to pick an output only where the platform can route to it (%s)",
|
||||
(canRoute) => {
|
||||
outputSelectionMock.mockReturnValue(canRoute);
|
||||
expect(audioMenu().selectOutput$.value !== undefined).toBe(canRoute);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("createCameraMenuViewModel", () => {
|
||||
afterEach(() => {
|
||||
backgroundEffectSetting.setValue("none");
|
||||
added$.next([]);
|
||||
});
|
||||
|
||||
it("offers effects where they run, saying where they run slowly", () => {
|
||||
modernRouteMock.mockReturnValue(true);
|
||||
const vm = cameraMenu();
|
||||
expect(vm.selectBackgroundEffect$.value).toBeDefined();
|
||||
expect(vm.addBackgroundImage$.value).toBeDefined();
|
||||
expect(vm.backgroundEffectNotice$.value).toBeUndefined();
|
||||
|
||||
modernRouteMock.mockReturnValue(false);
|
||||
const slow = cameraMenu();
|
||||
expect(slow.selectBackgroundEffect$.value).toBeDefined();
|
||||
expect(slow.backgroundEffectNotice$.value).toBe("slow");
|
||||
modernRouteMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("offers nothing while the switcher is withheld", () => {
|
||||
const vm = cameraMenu({ disableSwitcher: true });
|
||||
expect(vm.options$.value).toEqual([]);
|
||||
expect(vm.selectBackgroundEffect$.value).toBeUndefined();
|
||||
expect(vm.backgroundEffectNotice$.value).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("offers nothing once the pipeline fails to build, keeping the choice", () => {
|
||||
backgroundEffectSetting.setValue("blur");
|
||||
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
});
|
||||
const vm = cameraMenu({ pipeline$ });
|
||||
expect(vm.backgroundEffect$.value).toBe("blur");
|
||||
|
||||
pipeline$.next({ supported: false, processor: undefined });
|
||||
expect(vm.selectBackgroundEffect$.value).toBeUndefined();
|
||||
expect(vm.addBackgroundImage$.value).toBeUndefined();
|
||||
expect(vm.removeBackgroundEffect$.value).toBeUndefined();
|
||||
expect(vm.backgroundEffectNotice$.value).toBe("unavailable");
|
||||
expect(vm.backgroundEffect$.value).toBe("none");
|
||||
expect(backgroundEffectSetting.getValue()).toBe("blur");
|
||||
});
|
||||
|
||||
it("saves the effect chosen, and none for one not on offer", () => {
|
||||
const vm = cameraMenu();
|
||||
vm.selectBackgroundEffect$.value!("image:arc");
|
||||
expect(vm.backgroundEffect$.value).toBe("image:arc");
|
||||
vm.selectBackgroundEffect$.value!("image:gone");
|
||||
expect(backgroundEffectSetting.getValue()).toBe("none");
|
||||
});
|
||||
|
||||
it("offers every effect in order, the added ones removable", () => {
|
||||
added$.next([{ id: "mine", url: "blob:mine" }]);
|
||||
expect(cameraMenu().backgroundEffects$.value).toEqual([
|
||||
{ id: "none", kind: "none", removable: false },
|
||||
{ id: "blur", kind: "blur", removable: false },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: `image:${background.id}`,
|
||||
kind: "image",
|
||||
imageUrl: background.imagePath,
|
||||
removable: false,
|
||||
})),
|
||||
{
|
||||
id: "added:mine",
|
||||
kind: "image",
|
||||
imageUrl: "blob:mine",
|
||||
removable: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes on the wait for the first effect", () => {
|
||||
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
wait: "settling",
|
||||
});
|
||||
const vm = cameraMenu({ pipeline$ });
|
||||
expect(vm.backgroundEffectSettling$.value).toBe(true);
|
||||
pipeline$.next({ ...pipeline$.value, wait: undefined });
|
||||
expect(vm.backgroundEffectSettling$.value).toBe(false);
|
||||
});
|
||||
|
||||
it("offers adding only below the limit", () => {
|
||||
const vm = cameraMenu();
|
||||
added$.next(
|
||||
Array.from({ length: maxAddedBackgrounds }, (_, i) => ({
|
||||
id: `${i}`,
|
||||
url: `blob:${i}`,
|
||||
})),
|
||||
);
|
||||
expect(vm.addBackgroundImage$.value).toBeUndefined();
|
||||
added$.next(added$.value!.slice(1));
|
||||
expect(vm.addBackgroundImage$.value).toBeDefined();
|
||||
});
|
||||
|
||||
it("says why each refused file was refused", async () => {
|
||||
const vm = cameraMenu();
|
||||
store.add.mockRejectedValue(new UnusableImage("animated"));
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
|
||||
await flushPromises();
|
||||
const first = vm.backgroundImageRefusal$.value;
|
||||
expect(first).toEqual({ reason: "animated" });
|
||||
|
||||
// The same again is a refusal of its own, so it is shown again.
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
|
||||
await flushPromises();
|
||||
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "animated" });
|
||||
expect(vm.backgroundImageRefusal$.value).not.toBe(first);
|
||||
|
||||
store.add.mockRejectedValue(new Error("quota"));
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "b.png"));
|
||||
await flushPromises();
|
||||
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "not-kept" });
|
||||
|
||||
store.add.mockResolvedValue("kept");
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "c.png"));
|
||||
await flushPromises();
|
||||
expect(vm.backgroundImageRefusal$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never removes the selected background", () => {
|
||||
store.remove.mockResolvedValue();
|
||||
const vm = cameraMenu();
|
||||
backgroundEffectSetting.setValue("added:worn");
|
||||
vm.removeBackgroundEffect$.value!("added:worn");
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
vm.removeBackgroundEffect$.value!("added:spare");
|
||||
expect(store.remove).toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("previews the camera only while there is one and it is not muted", () => {
|
||||
const muted$ = new BehaviorSubject(false);
|
||||
const track = mockVideoTrack({ muted$ });
|
||||
const camera$ = new BehaviorSubject<LocalVideoMediaTrack | null>(track);
|
||||
const vm = cameraMenu({ camera$ });
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
muted$.next(true);
|
||||
expect(vm.cameraTrack$.value).toBeNull();
|
||||
muted$.next(false);
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
camera$.next(null);
|
||||
expect(vm.cameraTrack$.value).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { supportsAudioOutputSelection } from "livekit-client";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
import { supportsModernBackgroundProcessors } from "@livekit/track-processors";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
parseEffect,
|
||||
serializeEffect,
|
||||
shippedBackgrounds,
|
||||
} from "../tracks/backgroundEffectCatalog";
|
||||
import {
|
||||
type AddedBackground,
|
||||
addedBackgrounds,
|
||||
maxAddedBackgrounds,
|
||||
type UnusableReason,
|
||||
UnusableImage,
|
||||
} from "../tracks/backgroundImages";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
|
||||
import { type Behavior, constant } from "../state/Behavior";
|
||||
import type { ObservableScope } from "../state/ObservableScope";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
|
||||
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
|
||||
export interface BackgroundImageRefusal {
|
||||
reason: UnusableReason | "not-kept";
|
||||
}
|
||||
|
||||
/**
|
||||
* A mute button with a menu to pick the device, shared by the microphone and
|
||||
* the camera. Fields that belong to the other device are left empty.
|
||||
*/
|
||||
export interface MediaMenuSnapshot {
|
||||
enabled: boolean;
|
||||
busy: boolean;
|
||||
/** Also controls if the button is disabled */
|
||||
toggle: (() => void) | undefined;
|
||||
/** No options leave out the menu, which is then a plain mute button. */
|
||||
options: MenuOptions[];
|
||||
selected: string | undefined;
|
||||
select: ((id: string) => void) | undefined;
|
||||
|
||||
/** Output (speaker) devices. Microphone only. */
|
||||
outputOptions: MenuOptions[];
|
||||
selectedOutput: string | undefined;
|
||||
/** Undefined where the platform can't route audio to a chosen device. */
|
||||
selectOutput: ((id: string) => void) | undefined;
|
||||
|
||||
/** The selected background effect, as its option id. Camera only. */
|
||||
backgroundEffect: string | undefined;
|
||||
/** Every effect on offer, in the order they are shown. */
|
||||
backgroundEffects: BackgroundEffectGridItem[];
|
||||
/** What the user is told about effects here, if anything. */
|
||||
backgroundEffectNotice: "unavailable" | "slow" | undefined;
|
||||
/** Whether the first effect chosen is still being prepared. */
|
||||
backgroundEffectSettling: boolean;
|
||||
/** Why the last file offered couldn't be kept, if it couldn't. */
|
||||
backgroundImageRefusal: BackgroundImageRefusal | undefined;
|
||||
/** Undefined where background effects can't be chosen. */
|
||||
selectBackgroundEffect: ((id: string) => void) | undefined;
|
||||
/** Undefined where no background can be added. */
|
||||
addBackgroundImage: ((file: File) => void) | undefined;
|
||||
/** Removes an added background by its option id, unless it is the selected one. */
|
||||
removeBackgroundEffect: ((id: string) => void) | undefined;
|
||||
/** The camera to preview, with the selected effect; null while it is off. */
|
||||
cameraTrack: LocalVideoMediaTrack | null;
|
||||
}
|
||||
|
||||
export type MediaMenuViewModel = ViewModel<MediaMenuSnapshot>;
|
||||
|
||||
const noBackgroundEffects = {
|
||||
backgroundEffect: undefined,
|
||||
backgroundEffects: [],
|
||||
backgroundEffectNotice: undefined,
|
||||
backgroundEffectSettling: false,
|
||||
backgroundImageRefusal: undefined,
|
||||
selectBackgroundEffect: undefined,
|
||||
addBackgroundImage: undefined,
|
||||
removeBackgroundEffect: undefined,
|
||||
cameraTrack: null,
|
||||
} satisfies Partial<MediaMenuSnapshot>;
|
||||
|
||||
function options$(
|
||||
disableSwitcher$: Behavior<boolean>,
|
||||
available$: Behavior<Map<string, MenuOptions["label"]>>,
|
||||
): Observable<MenuOptions[]> {
|
||||
return disableSwitcher$.pipe(
|
||||
switchMap((disable) =>
|
||||
disable
|
||||
? constant([] as MenuOptions[])
|
||||
: available$.pipe(
|
||||
map((available) =>
|
||||
[...available.entries()].map(([id, label]) => ({ id, label })),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The microphone's mute button and menu, with the speaker choice.
|
||||
*
|
||||
* @param disableSwitcher$ - True to leave out the menu.
|
||||
*/
|
||||
export function createAudioMenuViewModel(
|
||||
scope: ObservableScope,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
disableSwitcher$: Behavior<boolean>,
|
||||
): MediaMenuViewModel {
|
||||
const { audio } = muteStates;
|
||||
return {
|
||||
...createStaticViewModel(noBackgroundEffects),
|
||||
enabled$: audio.enabled$,
|
||||
busy$: audio.syncing$,
|
||||
toggle$: scope.behavior(audio.toggle$.pipe(map((t) => t ?? undefined))),
|
||||
options$: scope.behavior(
|
||||
options$(disableSwitcher$, mediaDevices.audioInput.available$),
|
||||
),
|
||||
selected$: scope.behavior(
|
||||
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
select$: constant(mediaDevices.audioInput.select),
|
||||
outputOptions$: scope.behavior(
|
||||
options$(disableSwitcher$, mediaDevices.audioOutput.available$),
|
||||
),
|
||||
selectedOutput$: scope.behavior(
|
||||
mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
// Withheld where the platform can't route audio to a chosen device, which
|
||||
// disables the speaker section.
|
||||
selectOutput$: constant(
|
||||
supportsAudioOutputSelection()
|
||||
? mediaDevices.audioOutput.select
|
||||
: undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The camera's mute button and menu, with the background effects and a
|
||||
* preview of the camera.
|
||||
*
|
||||
* @param disableSwitcher$ - True to leave out the menu.
|
||||
* @param backgroundEffectsState$ - The background pipeline's state: whether
|
||||
* effects run here, and the wait for the first.
|
||||
* @param camera$ - The camera as the pipeline has it, for the preview.
|
||||
*/
|
||||
export function createCameraMenuViewModel(
|
||||
scope: ObservableScope,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
disableSwitcher$: Behavior<boolean>,
|
||||
backgroundEffectsState$: Observable<BackgroundEffectsState>,
|
||||
camera$: Observable<LocalVideoMediaTrack | null | undefined>,
|
||||
): MediaMenuViewModel {
|
||||
const { video } = muteStates;
|
||||
// Also false once the pipeline has failed to build this session.
|
||||
const supported$ = backgroundEffectsState$.pipe(
|
||||
map(({ supported }) => supported === true),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const slow = !supportsModernBackgroundProcessors();
|
||||
const offered$ = combineLatest([disableSwitcher$, supported$]).pipe(
|
||||
map(([switcherDisabled, supported]) => !switcherDisabled && supported),
|
||||
);
|
||||
// A new object for each refusal, so the same one twice is shown twice.
|
||||
const refusal$ = new BehaviorSubject<BackgroundImageRefusal | undefined>(
|
||||
undefined,
|
||||
);
|
||||
return {
|
||||
...createStaticViewModel({
|
||||
outputOptions: [],
|
||||
selectedOutput: undefined,
|
||||
selectOutput: undefined,
|
||||
}),
|
||||
enabled$: video.enabled$,
|
||||
busy$: video.syncing$,
|
||||
toggle$: scope.behavior(video.toggle$.pipe(map((t) => t ?? undefined))),
|
||||
options$: scope.behavior(
|
||||
options$(disableSwitcher$, mediaDevices.videoInput.available$),
|
||||
),
|
||||
selected$: scope.behavior(
|
||||
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
select$: constant(mediaDevices.videoInput.select),
|
||||
backgroundEffect$: scope.behavior(
|
||||
combineLatest([backgroundEffectSetting.value$, supported$]).pipe(
|
||||
map(([raw, supported]) =>
|
||||
supported ? serializeEffect(parseEffect(raw)) : "none",
|
||||
),
|
||||
),
|
||||
),
|
||||
selectBackgroundEffect$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
offered
|
||||
? (id: string): void =>
|
||||
backgroundEffectSetting.setValue(
|
||||
serializeEffect(parseEffect(id)),
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundEffects$: scope.behavior(
|
||||
addedBackgrounds.added$.pipe(
|
||||
map((added) => backgroundEffectChoices(added ?? [])),
|
||||
),
|
||||
),
|
||||
backgroundEffectNotice$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
!offered ? "unavailable" : slow ? "slow" : undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundEffectSettling$: scope.behavior(
|
||||
backgroundEffectsState$.pipe(
|
||||
map(({ wait }) => wait !== undefined),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
false,
|
||||
),
|
||||
// Kept and offered, not put on: that waits for the user to choose it.
|
||||
addBackgroundImage$: scope.behavior(
|
||||
combineLatest([offered$, addedBackgrounds.added$]).pipe(
|
||||
map(([offered, added]) =>
|
||||
offered && (added?.length ?? 0) < maxAddedBackgrounds
|
||||
? (file: File): void => {
|
||||
refusal$.next(undefined);
|
||||
addedBackgrounds.add(file).catch((e) => {
|
||||
logger.warn("Could not keep that background", e);
|
||||
refusal$.next({
|
||||
reason: e instanceof UnusableImage ? e.reason : "not-kept",
|
||||
});
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundImageRefusal$: refusal$,
|
||||
// None while muted: the call mutes its track rather than releasing it.
|
||||
cameraTrack$: scope.behavior(
|
||||
camera$.pipe(
|
||||
switchMap((camera) =>
|
||||
camera
|
||||
? camera.muted$.pipe(map((muted) => (muted ? null : camera)))
|
||||
: of(null),
|
||||
),
|
||||
),
|
||||
null,
|
||||
),
|
||||
removeBackgroundEffect$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
offered
|
||||
? (optionId: string): void => {
|
||||
const effect = parseEffect(optionId);
|
||||
if (effect.kind !== "added") return;
|
||||
// The selected effect can't be removed: that would leave no
|
||||
// effect selected.
|
||||
if (backgroundEffectSetting.getValue() === optionId) return;
|
||||
addedBackgrounds
|
||||
.remove(effect.id)
|
||||
.catch((e) =>
|
||||
logger.warn("Could not remove that background", e),
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function backgroundEffectChoices(
|
||||
added: AddedBackground[],
|
||||
): BackgroundEffectGridItem[] {
|
||||
return [
|
||||
{ id: serializeEffect({ kind: "none" }), kind: "none", removable: false },
|
||||
{ id: serializeEffect({ kind: "blur" }), kind: "blur", removable: false },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: serializeEffect({ kind: "shipped", id: background.id }),
|
||||
kind: "image" as const,
|
||||
imageUrl: background.imagePath,
|
||||
removable: false,
|
||||
})),
|
||||
...added.map((background) => ({
|
||||
id: serializeEffect({ kind: "added", id: background.id }),
|
||||
kind: "image" as const,
|
||||
imageUrl: background.url,
|
||||
removable: true,
|
||||
})),
|
||||
];
|
||||
}
|
||||
@@ -440,7 +440,7 @@ function swatch(colour: string): string {
|
||||
)}`;
|
||||
}
|
||||
|
||||
/** One's own backgrounds are removable, never the one in force. */
|
||||
/** One's own backgrounds are removable, unless one is the selected one. */
|
||||
export const AddedBackgroundsCanBeRemoved: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
|
||||
@@ -85,7 +85,7 @@ export interface MediaMuteAndSwitchButtonProps {
|
||||
backgroundEffectSettling?: boolean;
|
||||
/** Called with the file chosen from the add tile. Omit to leave it out. */
|
||||
onAddBackgroundImage?: (file: File) => void;
|
||||
/** Removes a removable effect, never the one in force. */
|
||||
/** Removes a removable effect, unless it is the selected one. */
|
||||
onRemoveBackgroundEffect?: (id: string) => void;
|
||||
/** Why the last file offered couldn't be used; a new object each time. */
|
||||
backgroundImageRefusal?: { text: string };
|
||||
|
||||
@@ -28,8 +28,10 @@ describe("SelfPreview", () => {
|
||||
expect(track.detach).toHaveBeenCalledExactlyOnceWith(a);
|
||||
});
|
||||
|
||||
it("mirrors the camera while it faces the user, and only then", () => {
|
||||
const facingMode$ = new BehaviorSubject<"user" | "environment">("user");
|
||||
it("mirrors the camera unless it faces away", () => {
|
||||
const facingMode$ = new BehaviorSubject<"user" | "environment" | undefined>(
|
||||
"user",
|
||||
);
|
||||
const { container } = render(
|
||||
<SelfPreview track={mockVideoTrack({ facingMode$ })} />,
|
||||
);
|
||||
@@ -37,6 +39,9 @@ describe("SelfPreview", () => {
|
||||
expect(video().classList.contains(styles.mirrored)).toBe(true);
|
||||
act(() => facingMode$.next("environment"));
|
||||
expect(video().classList.contains(styles.mirrored)).toBe(false);
|
||||
// As the built-in camera of a Mac, which doesn't say
|
||||
act(() => facingMode$.next(undefined));
|
||||
expect(video().classList.contains(styles.mirrored)).toBe(true);
|
||||
});
|
||||
|
||||
it("says the camera is off while there is none", () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { VideoCallOffIcon } from "@vector-im/compound-design-tokens/assets/web/i
|
||||
|
||||
import styles from "./SelfPreview.module.css";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { mirrors } from "../utils/media";
|
||||
|
||||
const noFacingMode = constant(undefined);
|
||||
|
||||
@@ -42,7 +43,7 @@ export const SelfPreview: FC<Props> = ({ track }) => {
|
||||
ref={videoRef}
|
||||
muted
|
||||
playsInline
|
||||
className={facingMode === "user" ? styles.mirrored : undefined}
|
||||
className={mirrors(facingMode) ? styles.mirrored : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -190,9 +190,12 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
props.matrixRoom,
|
||||
mediaDevices,
|
||||
props.muteStates,
|
||||
props.e2eeSystem,
|
||||
scope.behavior(observeElementSize$(rootElement)),
|
||||
reactionsReader.raisedHands$,
|
||||
reactionsReader.reactions$,
|
||||
{
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem: props.e2eeSystem,
|
||||
capture,
|
||||
backgroundEffectsState$,
|
||||
hostBridge,
|
||||
@@ -202,10 +205,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
scope,
|
||||
props.rtcSession,
|
||||
),
|
||||
windowSize$: scope.behavior(observeElementSize$(rootElement)),
|
||||
},
|
||||
reactionsReader.raisedHands$,
|
||||
reactionsReader.reactions$,
|
||||
);
|
||||
setVm(vm);
|
||||
|
||||
|
||||
@@ -7,9 +7,12 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { expect, describe, it, beforeAll } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { constant } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import { type MatrixInfo, VideoPreview } from "./VideoPreview";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { mockVideoTrack } from "../utils/test";
|
||||
import styles from "./VideoPreview.module.css";
|
||||
|
||||
describe("VideoPreview", () => {
|
||||
const matrixInfo: MatrixInfo = {
|
||||
@@ -60,4 +63,18 @@ describe("VideoPreview", () => {
|
||||
);
|
||||
expect(queryByRole("status")).toHaveTextContent("Video loading...");
|
||||
});
|
||||
|
||||
it("mirrors a camera that doesn't say which way it faces", () => {
|
||||
const { container } = render(
|
||||
<VideoPreview
|
||||
matrixInfo={matrixInfo}
|
||||
videoEnabled
|
||||
videoTrack={mockVideoTrack({ facingMode$: constant(undefined) })}
|
||||
children={<></>}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
container.querySelector("video")!.classList.contains(styles.mirror),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import styles from "./VideoPreview.module.css";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import videoPlaceholder from "../graphics/video-placeholder.gif";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { mirrors } from "../utils/media";
|
||||
|
||||
const noFacingMode = constant(undefined);
|
||||
|
||||
@@ -64,7 +65,7 @@ export const VideoPreview: FC<Props> = ({
|
||||
return (
|
||||
<div className={classNames(styles.preview)} ref={previewRef}>
|
||||
<video
|
||||
className={facingMode === "user" ? styles.mirror : undefined}
|
||||
className={mirrors(facingMode) ? styles.mirror : undefined}
|
||||
ref={videoEl}
|
||||
muted
|
||||
playsInline
|
||||
|
||||
@@ -47,6 +47,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
class="_preview_dd2178"
|
||||
>
|
||||
<video
|
||||
class="_mirror_dd2178"
|
||||
disablepictureinpicture=""
|
||||
playsinline=""
|
||||
poster="/src/graphics/video-placeholder.gif"
|
||||
@@ -279,6 +280,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
class="_preview_dd2178"
|
||||
>
|
||||
<video
|
||||
class="_mirror_dd2178"
|
||||
disablepictureinpicture=""
|
||||
playsinline=""
|
||||
poster="/src/graphics/video-placeholder.gif"
|
||||
@@ -558,6 +560,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
||||
class="_preview_dd2178"
|
||||
>
|
||||
<video
|
||||
class="_mirror_dd2178"
|
||||
disablepictureinpicture=""
|
||||
playsinline=""
|
||||
poster="/src/graphics/video-placeholder.gif"
|
||||
|
||||
@@ -57,7 +57,8 @@ export interface BackgroundEffectsOptions {
|
||||
supported: boolean;
|
||||
/** The effect chosen, as the setting stores it. */
|
||||
effect$: Behavior<string>;
|
||||
/** Stores a choice, to forget one that can't be honoured. */
|
||||
/** Saves the chosen effect. Used to reset the choice to "none" when the
|
||||
* chosen effect can't run here. */
|
||||
setEffect: (id: EffectId) => void;
|
||||
/** The backgrounds the device keeps, undefined until it has said. */
|
||||
added$: Behavior<AddedBackground[] | undefined>;
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
mockRtcMembership,
|
||||
testScope,
|
||||
} from "../../utils/test.ts";
|
||||
import { E2eeType } from "../../e2ee/e2eeType.ts";
|
||||
import {
|
||||
alice,
|
||||
aliceId,
|
||||
@@ -1465,7 +1464,6 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
},
|
||||
{
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1483,7 +1481,6 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
},
|
||||
{
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1501,7 +1498,6 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
},
|
||||
{
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1531,7 +1527,6 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
},
|
||||
{
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -185,8 +185,15 @@ export function initialPublishRequests(
|
||||
return requests;
|
||||
}
|
||||
|
||||
/**
|
||||
* How the host wants the call to behave. Every field is optional and defaults
|
||||
* to what standalone Element Call does, so `{}` is a working standalone call.
|
||||
* What the call is made of — the room, the participation, the devices, the
|
||||
* things with no sensible default — is a positional parameter of
|
||||
* {@link createCallViewModel$} instead. A test that wants to replace behaviour
|
||||
* mocks the dependency it comes from rather than adding an option here.
|
||||
*/
|
||||
export interface CallViewModelOptions {
|
||||
encryptionSystem: EncryptionSystem;
|
||||
/**
|
||||
* How our tracks are captured and encoded; the browser's and LiveKit's
|
||||
* defaults otherwise.
|
||||
@@ -222,17 +229,6 @@ export interface CallViewModelOptions {
|
||||
* Defaults to none sent.
|
||||
*/
|
||||
sentCallNotification$?: Observable<CallNotificationWrapper | null>;
|
||||
/**
|
||||
* The size of the space the call is drawn in: the page when Element Call
|
||||
* owns it, or the container a host mounted it in when it is a component.
|
||||
* The layout — whether the call is shown full size, flat, narrow or as a
|
||||
* picture-in-picture — follows this rather than the size of the window, so
|
||||
* that a component shrunk by its host adapts even though the window has not
|
||||
* changed.
|
||||
*/
|
||||
windowSize$: Behavior<{ width: number; height: number }>;
|
||||
/** Optional behavior overriding for the screensharing, for testing */
|
||||
toggleScreensharing?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -474,9 +470,19 @@ export function createCallViewModel$(
|
||||
matrixRoom: MatrixRoom,
|
||||
mediaDevices: MediaDevices,
|
||||
muteStates: MuteStates,
|
||||
options: CallViewModelOptions,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
/**
|
||||
* The size of the space the call is drawn in: the page when Element Call
|
||||
* owns it, or the container a host mounted it in when it is a component.
|
||||
* The layout — whether the call is shown full size, flat, narrow or as a
|
||||
* picture-in-picture — follows this rather than the size of the window, so
|
||||
* that a component shrunk by its host adapts even though the window has not
|
||||
* changed.
|
||||
*/
|
||||
windowSize$: Behavior<{ width: number; height: number }>,
|
||||
handsRaisedSubject$: Observable<Record<string, RaisedHandInfo>>,
|
||||
reactionsSubject$: Observable<Record<string, ReactionInfo>>,
|
||||
options: CallViewModelOptions = {},
|
||||
): CallViewModel {
|
||||
const logger = rootLogger.getChild("[CallViewModel]");
|
||||
const client = matrixRoom.client;
|
||||
@@ -702,7 +708,7 @@ export function createCallViewModel$(
|
||||
userId: member.userId,
|
||||
rtcBackendIdentity: member.rtcBackendIdentity,
|
||||
member,
|
||||
encryptionSystem: options.encryptionSystem,
|
||||
encryptionSystem,
|
||||
transport$: scope.behavior(
|
||||
member.transport$.pipe(map((transport) => transport?.raw)),
|
||||
),
|
||||
@@ -916,7 +922,7 @@ export function createCallViewModel$(
|
||||
// A guess at what the window's mode should be based on the size and shape of
|
||||
// the space we have to draw in.
|
||||
const naturalWindowMode$ = scope.behavior<WindowMode>(
|
||||
options.windowSize$.pipe(
|
||||
windowSize$.pipe(
|
||||
map(({ width, height }) => {
|
||||
if (height <= 400 && width <= 340) return "pip";
|
||||
// Our layouts for flat windows are better at adapting to a small width
|
||||
@@ -1500,8 +1506,7 @@ export function createCallViewModel$(
|
||||
* the platform cannot capture a screen, or the host hides the button.
|
||||
*/
|
||||
const toggleScreenSharing =
|
||||
options.toggleScreensharing ??
|
||||
(hideScreensharing || !("getDisplayMedia" in (navigator.mediaDevices ?? {}))
|
||||
hideScreensharing || !("getDisplayMedia" in (navigator.mediaDevices ?? {}))
|
||||
? null
|
||||
: (): void => {
|
||||
const track = screenShare$.value;
|
||||
@@ -1522,7 +1527,7 @@ export function createCallViewModel$(
|
||||
e instanceof Error ? e : new Error(String(e)),
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Tell the host and the analytics about the user's joins and leaves. The
|
||||
// participation is joined by the time the view model is built on it
|
||||
|
||||
@@ -265,18 +265,18 @@ export function withCallViewModel(mode: MatrixRTCMode) {
|
||||
room,
|
||||
mediaDevices,
|
||||
muteStates,
|
||||
{ kind: E2eeType.PER_PARTICIPANT },
|
||||
windowSize$,
|
||||
raisedHands$,
|
||||
reactions$,
|
||||
{
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
sentCallNotification$: createSentCallNotification$(
|
||||
scope,
|
||||
rtcSession.asMockedSession(),
|
||||
),
|
||||
windowSize$,
|
||||
...options,
|
||||
},
|
||||
raisedHands$,
|
||||
reactions$,
|
||||
);
|
||||
void mode;
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
type HostRequest,
|
||||
nullHostBridge,
|
||||
} from "../HostBridge.ts";
|
||||
import { E2eeType } from "../e2ee/e2eeType.ts";
|
||||
import { MatrixRTCMode } from "../config/ConfigOptions.ts";
|
||||
|
||||
it.each([[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]])(
|
||||
@@ -41,7 +40,6 @@ it.each([[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]])(
|
||||
hangUp$.next({ data: {}, reply });
|
||||
},
|
||||
{
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
hostBridge,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { alwaysShowSelf } from "../../settings/settings";
|
||||
import { platform } from "../../Platform";
|
||||
import { type MediaDevices } from "../MediaDevices";
|
||||
import { mirrors } from "../../utils/media";
|
||||
|
||||
export interface LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
local: true;
|
||||
@@ -66,10 +67,7 @@ export function createLocalUserMedia(
|
||||
return {
|
||||
...baseUserMedia,
|
||||
local: true,
|
||||
// Mirror only front-facing cameras (those that face the user)
|
||||
mirror$: scope.behavior(
|
||||
facingMode$.pipe(map((facingMode) => facingMode === "user")),
|
||||
),
|
||||
mirror$: scope.behavior(facingMode$.pipe(map(mirrors))),
|
||||
alwaysShow$: alwaysShowSelf.value$,
|
||||
setAlwaysShow: alwaysShowSelf.setValue,
|
||||
switchCamera$: scope.behavior(
|
||||
|
||||
@@ -22,3 +22,14 @@ export function findDeviceByName(
|
||||
);
|
||||
return deviceInfo?.deviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one's own camera is shown mirrored: unless it faces away. A camera
|
||||
* that doesn't say which way it faces, as the built-in one on a Mac, is taken
|
||||
* to face the user.
|
||||
*/
|
||||
export function mirrors(
|
||||
facingMode: "user" | "environment" | undefined,
|
||||
): boolean {
|
||||
return facingMode !== "environment";
|
||||
}
|
||||
|
||||
@@ -200,17 +200,17 @@ export function getBasicCallViewModelEnvironment(
|
||||
matrixRoom,
|
||||
mediaDevices,
|
||||
muteStates,
|
||||
{ kind: E2eeType.PER_PARTICIPANT },
|
||||
constant({ width: 1000, height: 800 }),
|
||||
handRaisedSubject$,
|
||||
reactionsSubject$,
|
||||
{
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
sentCallNotification$: createSentCallNotification$(
|
||||
scope,
|
||||
rtcSession.asMockedSession(),
|
||||
),
|
||||
windowSize$: constant({ width: 1000, height: 800 }),
|
||||
...callViewModelOptions,
|
||||
},
|
||||
handRaisedSubject$,
|
||||
reactionsSubject$,
|
||||
);
|
||||
const footerVm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
|
||||
Reference in New Issue
Block a user