Wire the background effects grid to the pipeline

- Footer view model exposes the chosen effect and a setter for it
- The footer names the catalogue: a view model holds no translated text
- The camera menu shows the grid in place of the blur toggle
- Shipped images are numbered, not named, since the art is a stand-in
- Withholding the setter is what renders the section disabled, as with
  the speaker section where no output can be chosen

Exploration for FEATURES_SPEC/2026-09_Background_Effects.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-09-17 23:01:54 +02:00
co-authored by Claude Opus 5
parent e823fbc85b
commit 9179b1cccb
4 changed files with 53 additions and 4 deletions
+3
View File
@@ -4,6 +4,9 @@
}, },
"action": { "action": {
"add_background_image": "Add image", "add_background_image": "Add image",
"background_effect_blur": "Blur",
"background_effect_none": "None",
"background_effect_numbered": "Background {{n}}",
"blur_background": "Blur background", "blur_background": "Blur background",
"close": "Close", "close": "Close",
"copy_link": "Copy link", "copy_link": "Copy link",
+2
View File
@@ -124,6 +124,8 @@ export const Default: Story = {
toggleScreenSharing: fn(), toggleScreenSharing: fn(),
toggleBlur: fn(), toggleBlur: fn(),
videoBlurEnabled: true, videoBlurEnabled: true,
backgroundEffect: "none",
selectBackgroundEffect: fn(),
hangup: fn(), hangup: fn(),
buttonSize: "lg", buttonSize: "lg",
showFooter: true, showFooter: true,
+31 -4
View File
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type FC, type JSX, type Ref, useMemo } from "react"; import { type FC, type JSX, type Ref, useMemo } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "react-i18next";
import LogoMark from "../icons/LogoMark.svg?react"; import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react"; import LogoType from "../icons/LogoType.svg?react";
@@ -23,9 +24,11 @@ import {
} from "../button"; } from "../button";
import styles from "./CallFooter.module.css"; import styles from "./CallFooter.module.css";
import { import {
type BackgroundEffectOption,
MediaMuteAndSwitchButton, MediaMuteAndSwitchButton,
type MenuOptions, type MenuOptions,
} from "./MediaMuteAndSwitchButton"; } from "./MediaMuteAndSwitchButton";
import { shippedBackgrounds } from "../livekit/backgroundEffects";
import { type Behavior } from "../state/Behavior"; import { type Behavior } from "../state/Behavior";
import { type ViewModel } from "../state/ViewModel"; import { type ViewModel } from "../state/ViewModel";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
@@ -57,6 +60,8 @@ export interface FooterActions {
/** Also controls if the videoMute button is disabled */ /** Also controls if the videoMute button is disabled */
toggleVideo: (() => void) | undefined; toggleVideo: (() => void) | undefined;
toggleBlur: (() => void) | undefined; toggleBlur: (() => void) | undefined;
/** Undefined where background processing is unavailable. */
selectBackgroundEffect: ((id: string) => void) | undefined;
toggleScreenSharing: (() => void) | undefined; toggleScreenSharing: (() => void) | undefined;
/** Also controls if the settings button is visible */ /** Also controls if the settings button is visible */
openSettings: (() => void) | undefined; openSettings: (() => void) | undefined;
@@ -70,6 +75,8 @@ export interface FooterState {
videoEnabled: boolean; videoEnabled: boolean;
videoBusy: boolean; videoBusy: boolean;
videoBlurEnabled: boolean; videoBlurEnabled: boolean;
/** The chosen background effect, in its stored form. */
backgroundEffect: string;
showFooter: boolean; showFooter: boolean;
/* This is needed for WindowMode = "flat" */ /* This is needed for WindowMode = "flat" */
@@ -123,6 +130,7 @@ export const CallFooter: FC<FooterProps> = ({
children, children,
vm, vm,
}) => { }) => {
const { t } = useTranslation();
const asOverlay = useBehavior(vm.asOverlay$); const asOverlay = useBehavior(vm.asOverlay$);
const showFooter = useBehavior(vm.showFooter$); const showFooter = useBehavior(vm.showFooter$);
const hideControls = useBehavior(vm.hideControls$); const hideControls = useBehavior(vm.hideControls$);
@@ -151,8 +159,26 @@ export const CallFooter: FC<FooterProps> = ({
const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$); const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$);
const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$); const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$);
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$); const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
const toggleBlur = useBehavior(vm.toggleBlur$); const backgroundEffect = useBehavior(vm.backgroundEffect$);
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$); const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
// The catalogue is named here rather than in the view model: the names are
// for reading, and a view model has no business holding translated text.
const backgroundEffects = useMemo(
(): BackgroundEffectOption[] => [
{ id: "none", kind: "none", label: t("action.background_effect_none") },
{ id: "blur", kind: "blur", label: t("action.background_effect_blur") },
...shippedBackgrounds.map((background, i) => ({
id: `image:${background.id}`,
kind: "image" as const,
// Numbered rather than named: the images are stand-ins, and naming
// them here would invent names the design has not given them.
label: t("action.background_effect_numbered", { n: i + 1 }),
imageUrl: background.imagePath,
})),
],
[t],
);
const buttonSize = useBehavior(vm.buttonSize$); const buttonSize = useBehavior(vm.buttonSize$);
const showLogo = useBehavior(vm.showLogo$); const showLogo = useBehavior(vm.showLogo$);
@@ -214,8 +240,9 @@ export const CallFooter: FC<FooterProps> = ({
options={videoOptions} options={videoOptions}
selectedOption={selectedVideo} selectedOption={selectedVideo}
onSelect={selectVideoButtonOption} onSelect={selectVideoButtonOption}
videoBlurToggleClick={toggleBlur} backgroundEffects={backgroundEffects}
videoBlurEnabled={videoBlurEnabled} selectedBackgroundEffect={backgroundEffect}
onSelectBackgroundEffect={selectBackgroundEffect}
/>, />,
); );
} else { } else {
+17
View File
@@ -14,6 +14,7 @@ import { type MenuOptions } from "./MediaMuteAndSwitchButton";
import { type MediaDevices } from "../state/MediaDevices"; import { type MediaDevices } from "../state/MediaDevices";
import { import {
backgroundBlur as backgroundBlurSettings, backgroundBlur as backgroundBlurSettings,
backgroundEffect as backgroundEffectSetting,
debugTileLayout as debugTileLayoutSetting, debugTileLayout as debugTileLayoutSetting,
} from "../settings/settings"; } from "../settings/settings";
import { type Behavior, constant } from "../state/Behavior"; import { type Behavior, constant } from "../state/Behavior";
@@ -76,6 +77,8 @@ function buildDeviceBehaviors(
| "selectVideoButtonOption$" | "selectVideoButtonOption$"
| "toggleBlur$" | "toggleBlur$"
| "videoBlurEnabled$" | "videoBlurEnabled$"
| "backgroundEffect$"
| "selectBackgroundEffect$"
> { > {
const options$ = ( const options$ = (
available$: Behavior<Map<string, MenuOptions["label"]>>, available$: Behavior<Map<string, MenuOptions["label"]>>,
@@ -128,6 +131,20 @@ function buildDeviceBehaviors(
), ),
), ),
videoBlurEnabled$: backgroundBlurSettings.value$, videoBlurEnabled$: backgroundBlurSettings.value$,
backgroundEffect$: backgroundEffectSetting.value$,
// Withholding the callback is what renders the section disabled, the same
// way the speaker section is disabled where no output can be chosen.
selectBackgroundEffect$: scope.behavior(
disableSwitcher$.pipe(
map((switcherDisabled) =>
!switcherDisabled && supportsBackgroundProcessors()
? (id: string): void => {
backgroundEffectSetting.setValue(id);
}
: undefined,
),
),
),
}; };
} }