Offer background effects as a grid in the camera menu

- The camera menu's blur toggle becomes a Background effects section
  under the cameras: no effect, blur and the two shipped backgrounds as
  one radio choice of tiles, three across. It scrolls with the device
  list, so it stays reachable in a short call, and choosing keeps the
  menu open. The lobby gets it too, as both render the same footer.
- The footer's view model gives the options, the notice and the actions;
  the footer only names them.
- Each tile is Compound's MenuItem as a menuitemradio, styled as a tile.
  Compound makes it an item of the dropdown, so the arrow keys walk the
  tiles with the device rows, and takes its tap in the phone drawer. A
  tile that can't be chosen has no select handler, so the keyboard passes
  over it.
- Where the browser can't run effects the tiles are shown, disabled and
  explained by a notice the group is described by. No effect stays
  choosable and shows as in force. The notice is in the scrolling list,
  so a short call doesn't push the menu out of it.
- Section headings are raised, or the positioned tiles scroll over the
  one they sit under; raised within the list only, so the menu's frame
  stays over them. Tiles clip with overflow: clip; with hidden they are
  scroll containers, which focus scrolls into view without their border.
- End to end on Chromium and Firefox: a shipped background chosen in the
  lobby shows in the preview, and None after Blur draws nothing, counted
  in WebGL draws (Blur: about 1400 a second on Chromium, 2300 on
  Firefox).
- The Quick Audio Menu's test of the camera menu keeps its name and now
  finds blur among the effects rather than as a toggle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-10-08 14:35:22 +02:00
co-authored by Claude Opus 5.5
parent 5de48207d0
commit db57962c50
13 changed files with 852 additions and 109 deletions
+53 -6
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 classNames from "classnames";
import { useTranslation } from "react-i18next";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
@@ -31,6 +32,15 @@ import { type ViewModel } from "../state/ViewModel";
import { useBehavior } from "../useBehavior";
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
import { LayoutSwitch } from "../room/LayoutSwitch";
import { type BackgroundEffectOption } from "./BackgroundEffectGrid";
/** A background effect on offer, which the view names. */
export interface BackgroundEffectChoice {
/** Its option id, as `backgroundEffect` gives the one in force. */
id: string;
kind: "none" | "blur" | "image";
imageUrl?: string;
}
export interface AudioOutputSwitcher {
targetOutput: string;
@@ -56,20 +66,27 @@ export interface FooterActions {
toggleAudio: (() => void) | undefined;
/** Also controls if the videoMute button is disabled */
toggleVideo: (() => void) | undefined;
toggleBlur: (() => void) | undefined;
/** Undefined where background effects can't be chosen. */
selectBackgroundEffect: ((id: string) => void) | undefined;
toggleScreenSharing: (() => void) | undefined;
/** Also controls if the settings button is visible */
openSettings: (() => void) | undefined;
/** Also controls if the hangup button is visible */
hangup: (() => void) | undefined;
}
// 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;
videoBlurEnabled: boolean;
/** The background effect in force, as its option id. */
backgroundEffect: string;
/** Every effect on offer, in the order they are shown. */
backgroundEffects: BackgroundEffectChoice[];
/** What the user is told about effects here, if anything. */
backgroundEffectNotice: "unavailable" | undefined;
showFooter: boolean;
/* This is needed for WindowMode = "flat" */
@@ -117,12 +134,14 @@ export interface FooterProps {
children?: JSX.Element | JSX.Element[] | false;
vm: ViewModel<FooterSnapshot>;
}
export const CallFooter: FC<FooterProps> = ({
className,
ref,
children,
vm,
}) => {
const { t } = useTranslation();
const asOverlay = useBehavior(vm.asOverlay$);
const showFooter = useBehavior(vm.showFooter$);
const hideControls = useBehavior(vm.hideControls$);
@@ -151,8 +170,12 @@ export const CallFooter: FC<FooterProps> = ({
const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$);
const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$);
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
const toggleBlur = useBehavior(vm.toggleBlur$);
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
const backgroundEffect = useBehavior(vm.backgroundEffect$);
const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
const backgroundEffectNotice = useBehavior(vm.backgroundEffectNotice$);
const backgroundEffects = useBackgroundEffectLabels(
useBehavior(vm.backgroundEffects$),
);
const buttonSize = useBehavior(vm.buttonSize$);
const showLogo = useBehavior(vm.showLogo$);
@@ -214,8 +237,14 @@ export const CallFooter: FC<FooterProps> = ({
options={videoOptions}
selectedOption={selectedVideo}
onSelect={selectVideoButtonOption}
videoBlurToggleClick={toggleBlur}
videoBlurEnabled={videoBlurEnabled}
backgroundEffects={backgroundEffects}
selectedBackgroundEffect={backgroundEffect}
onSelectBackgroundEffect={selectBackgroundEffect}
backgroundEffectNotice={
backgroundEffectNotice === "unavailable"
? t("background_effects.unavailable")
: undefined
}
/>,
);
} else {
@@ -340,3 +369,21 @@ const TilesDebugInfo: FC<TilesDebugInfoProps> = ({ generation$ }) => {
const generation = useBehavior(generation$);
return `Tiles generation: ${generation}`;
};
function useBackgroundEffectLabels(
choices: BackgroundEffectChoice[],
): BackgroundEffectOption[] {
const { t } = useTranslation();
return useMemo(() => {
let pictures = 0;
return choices.map((choice) => ({
...choice,
label:
choice.kind === "none"
? t("background_effects.none")
: choice.kind === "blur"
? t("background_effects.blur")
: t("background_effects.numbered", { n: ++pictures }),
}));
}, [choices, t]);
}