add the CallFooterViewModel

This commit is contained in:
Timo K
2026-05-11 17:37:06 +02:00
parent 7615e146a5
commit 936dfb193f
2 changed files with 445 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
/*
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 { describe, expect, it, vi } from "vitest";
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
import { constant } from "../state/Behavior";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import type { Layout } from "../state/layout-types";
import type { SpotlightTileViewModel } from "../state/TileViewModel";
import type { DeviceLabel } from "../state/MediaDevices";
import { createCallFooterViewModel } from "./CallFooterViewModel";
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({
get platform(): string {
return platformMock();
},
}));
// Prevent supportsBackgroundProcessors from throwing in jsdom it is not
// exercised by these tests (only used in `videoToggles`, not `videoOptions`).
vi.mock("@livekit/track-processors", () => ({
supportsBackgroundProcessors: (): boolean => false,
}));
/**
* Returns the minimum set of CallViewModel fields required by
* createCallFooterViewModel, with all other properties stubbed to
* simple constant values.
*/
function buildMinimalCallViewModel(layout: Layout): CallViewModel {
return {
layout$: constant(layout),
windowMode$: constant("normal"),
showHeader$: constant(false),
hangup: (): void => {},
gridMode$: constant("grid"),
setGridMode: (): void => {},
sharingScreen$: constant(false),
toggleScreenSharing: null,
audioOutputSwitcher$: constant(null),
handsRaised$: constant({}),
reactions$: constant({}),
tileStoreGeneration$: constant(0),
} as unknown as CallViewModel;
}
/** A regular grid layout (not PiP). */
const gridLayout: Layout = {
type: "grid",
grid: [],
setVisibleTiles: (): void => {},
};
/** A PiP layout only the `type` matters for the tests. */
const pipLayout: Layout = {
type: "pip",
spotlight: {} as SpotlightTileViewModel,
};
const twoMicsAndOneCamMediaDevices = mockMediaDevices({
audioInput: {
available$: constant(
new Map<string, DeviceLabel>([
["mic1", { type: "name", name: "Microphone 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(),
},
});
describe("createCallFooterViewModel", () => {
describe("audioOptions and videoOptions", () => {
it("are empty when the layout is PiP on desktop", () => {
platformMock.mockReturnValue("desktop");
const vm = createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(pipLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* openSettings */ undefined,
/* reactionIdentifier */ undefined,
);
expect(vm.audioOptions$?.value).toEqual([]);
expect(vm.videoOptions$?.value).toEqual([]);
});
it("are empty when the platform is iOS regardless of layout", () => {
platformMock.mockReturnValue("ios");
const vm = createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* openSettings */ undefined,
/* reactionIdentifier */ undefined,
);
expect(vm.audioOptions$?.value).toEqual([]);
expect(vm.videoOptions$?.value).toEqual([]);
});
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,
/* openSettings */ undefined,
/* reactionIdentifier */ undefined,
);
expect(vm.audioOptions$?.value).toEqual([
{ id: "mic1", label: "Microphone 1" },
{ id: "mic2", label: "Microphone 2" },
]);
expect(vm.videoOptions$?.value).toEqual([
{ id: "cam1", label: "Camera 1" },
]);
});
});
});

View File

@@ -0,0 +1,302 @@
/*
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 { combineLatest, map, switchMap } from "rxjs";
import { supportsBackgroundProcessors } from "@livekit/track-processors";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import {
type MenuOptions,
type ToggleOption,
} from "./MediaMuteAndSwitchButton";
import { type MediaDevices } from "../state/MediaDevices";
import { mediaDeviceLabelToString } from "../settings/DeviceSelection";
import {
backgroundBlur as backgroundBlurSettings,
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 { type ViewModel } from "../state/ViewModel";
import { getUrlParams, HeaderStyle } from "../UrlParams";
import { platform } from "../Platform";
import { type FooterSnapshot } from "./CallFooter";
/**
* Shared helper: maps MuteStates into the audio/video enabled + toggle behaviors
* needed by FooterSnapshot.
*/
function buildMuteBehaviors(
scope: ObservableScope,
muteStates: MuteStates,
): Pick<
ViewModel<FooterSnapshot>,
"audioEnabled$" | "toggleAudio$" | "videoEnabled$" | "toggleVideo$"
> {
return {
audioEnabled$: muteStates.audio.enabled$,
toggleAudio$: scope.behavior(
muteStates.audio.toggle$.pipe(map((t) => t ?? undefined)),
),
videoEnabled$: muteStates.video.enabled$,
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, blur toggle).
*/
function buildDeviceBehaviors(
scope: ObservableScope,
mediaDevices: MediaDevices,
/** return empty arrays for audioOptions and videoOptions*/
disableSwitcher$: Behavior<boolean>,
): Pick<
ViewModel<FooterSnapshot>,
| "audioOptions$"
| "selectedAudio$"
| "selectAudioButtonOption$"
| "videoOptions$"
| "selectedVideo$"
| "selectVideoButtonOption$"
| "videoToggles$"
> {
return {
audioOptions$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: mediaDevices.audioInput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label: mediaDeviceLabelToString(
label,
(n) => "Audio Device " + n,
),
})),
),
),
),
),
),
selectedAudio$: scope.behavior(
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
),
selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
videoOptions$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: mediaDevices.videoInput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label: mediaDeviceLabelToString(
label,
(n) => "Camera " + n,
),
})),
),
),
),
),
),
selectedVideo$: scope.behavior(
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
),
selectVideoButtonOption$: scope.behavior(
backgroundBlurSettings.value$.pipe(
map((current) => {
return (option: string) => {
if (option === "blur") {
backgroundBlurSettings.setValue(!current);
} else {
mediaDevices.videoInput.select(option);
}
};
}),
),
),
videoToggles$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as ToggleOption[])
: backgroundBlurSettings.value$.pipe(
map((blurActive) =>
supportsBackgroundProcessors()
? [
{
id: "blur",
enabled: blurActive,
label: "Blur Background",
},
]
: [],
),
),
),
),
),
};
}
/**
* Creates the ViewModel for the CallFooter.
*
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
* @param vm - The root CallViewModel; provides layout, grid mode, reactions, etc.
* @param muteStates - Audio and video mute state + toggles.
* @param mediaDevices - Available and selected input devices.
* @param openSettings - Callback to open the settings modal, or undefined if the
* settings button should be hidden (e.g. when it is already shown in an app bar).
* @param reactionIdentifier - The local user's reaction identifier string, or
* undefined when reactions are not supported (hides the reaction button).
*/
export function createCallFooterViewModel(
scope: ObservableScope,
callModel: CallViewModel,
muteStates: MuteStates,
mediaDevices: MediaDevices,
openSettings: (() => void) | undefined,
reactionIdentifier: string | undefined,
): ViewModel<FooterSnapshot> {
const { showControls, header: headerStyle } = getUrlParams();
const hideLogo = headerStyle !== HeaderStyle.Standard;
const isPip$ = scope.behavior(
callModel.layout$.pipe(map((l) => l.type === "pip")),
);
const disableDeviceSwitcher$ = scope.behavior(
isPip$.pipe(map((isPip) => isPip || platform !== "desktop")),
);
return {
...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices, disableDeviceSwitcher$),
hideControls$: constant(!showControls),
asOverlay$: scope.behavior(
callModel.windowMode$.pipe(map((mode) => mode === "flat")),
),
buttonSize$: scope.behavior(
isPip$.pipe(map((pip) => (pip ? "md" : "lg") as "md" | "lg")),
),
showSettingsButton$: scope.behavior(
combineLatest([isPip$, callModel.showHeader$]).pipe(
map(
([isPip, showHeader]) =>
openSettings !== undefined &&
!isPip &&
showControls &&
!(headerStyle === HeaderStyle.AppBar && showHeader),
),
),
),
showLayoutSwitcher$: scope.behavior(
isPip$.pipe(map((l) => !isPip$ && showControls)),
),
showLogoDebugContainer$: scope.behavior(
combineLatest([isPip$, debugTileLayoutSetting.value$]).pipe(
map(([isPip, debugTile]) => !isPip || (!hideLogo && !debugTile)),
),
),
showLogo$: scope.behavior(isPip$.pipe(map((l) => !hideLogo && !isPip$))),
layoutMode$: callModel.gridMode$,
setLayoutMode$: constant(callModel.setGridMode),
sharingScreen$: callModel.sharingScreen$,
toggleScreenSharing$: constant(callModel.toggleScreenSharing ?? undefined),
audioOutputSwitcher$: scope.behavior(
callModel.audioOutputSwitcher$.pipe(
map((switcher) => switcher ?? undefined),
),
),
openSettings$: scope.behavior(
callModel.showHeader$.pipe(
map((showHeader) =>
headerStyle === HeaderStyle.AppBar && showHeader
? undefined
: openSettings,
),
),
),
hangup$: constant(callModel.hangup),
reactionIdentifier$: constant(reactionIdentifier),
reactionData$: constant(
reactionIdentifier !== undefined
? {
handsRaised$: callModel.handsRaised$,
reactions$: callModel.reactions$,
}
: undefined,
),
debugTileLayout$: debugTileLayoutSetting.value$,
tileStoreGeneration$: callModel.tileStoreGeneration$,
};
}
/**
* Creates a simplified ViewModel for the CallFooter used in the lobby
* (pre-call) screen. Unlike createCallFooterViewModel, this does not require
* a CallViewModel — it only needs mute states, device lists, and callbacks.
*
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
* @param muteStates - Audio and video mute state + toggles.
* @param mediaDevices - Available and selected input devices.
* @param openSettings - Callback to open the settings modal, or undefined.
* @param hangup - Callback to leave/cancel, or undefined (hides the button).
* @param showLogo - Whether to show the Element Call logo.
*/
export function createLobbyFooterViewModel(
scope: ObservableScope,
muteStates: MuteStates,
mediaDevices: MediaDevices,
openSettings: (() => void) | undefined,
hangup: (() => void) | undefined,
showLogo: boolean,
): ViewModel<FooterSnapshot> {
return {
...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices, constant(false)),
hideControls$: constant(false),
asOverlay$: constant(false),
buttonSize$: constant("lg"),
showSettingsButton$: constant(openSettings !== undefined),
showLayoutSwitcher$: constant(false),
showLogoDebugContainer$: constant(showLogo),
showLogo$: constant(showLogo),
layoutMode$: constant(undefined),
setLayoutMode$: constant(undefined),
sharingScreen$: constant(undefined),
toggleScreenSharing$: constant(undefined),
audioOutputSwitcher$: constant(undefined),
openSettings$: constant(openSettings),
hangup$: constant(hangup),
reactionIdentifier$: constant(undefined),
reactionData$: constant(undefined),
debugTileLayout$: constant(false),
tileStoreGeneration$: constant(0),
};
}