diff --git a/locales/en/app.json b/locales/en/app.json index de50b6506..f273f503f 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -4,6 +4,9 @@ }, "action": { "add_background_image": "Add image", + "background_effect_blur": "Blur", + "background_effect_none": "None", + "background_effect_numbered": "Background {{n}}", "blur_background": "Blur background", "close": "Close", "copy_link": "Copy link", diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index 6b94c929a..8ce9d4993 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -124,6 +124,8 @@ export const Default: Story = { toggleScreenSharing: fn(), toggleBlur: fn(), videoBlurEnabled: true, + backgroundEffect: "none", + selectBackgroundEffect: fn(), hangup: fn(), buttonSize: "lg", showFooter: true, diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index 45d346bab..7c04090b3 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -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"; @@ -23,9 +24,11 @@ import { } from "../button"; import styles from "./CallFooter.module.css"; import { + type BackgroundEffectOption, MediaMuteAndSwitchButton, type MenuOptions, } from "./MediaMuteAndSwitchButton"; +import { shippedBackgrounds } from "../livekit/backgroundEffects"; import { type Behavior } from "../state/Behavior"; import { type ViewModel } from "../state/ViewModel"; import { useBehavior } from "../useBehavior"; @@ -57,6 +60,8 @@ export interface FooterActions { /** Also controls if the videoMute button is disabled */ toggleVideo: (() => void) | undefined; toggleBlur: (() => void) | undefined; + /** Undefined where background processing is unavailable. */ + selectBackgroundEffect: ((id: string) => void) | undefined; toggleScreenSharing: (() => void) | undefined; /** Also controls if the settings button is visible */ openSettings: (() => void) | undefined; @@ -70,6 +75,8 @@ export interface FooterState { videoEnabled: boolean; videoBusy: boolean; videoBlurEnabled: boolean; + /** The chosen background effect, in its stored form. */ + backgroundEffect: string; showFooter: boolean; /* This is needed for WindowMode = "flat" */ @@ -123,6 +130,7 @@ export const CallFooter: FC = ({ children, vm, }) => { + const { t } = useTranslation(); const asOverlay = useBehavior(vm.asOverlay$); const showFooter = useBehavior(vm.showFooter$); const hideControls = useBehavior(vm.hideControls$); @@ -151,8 +159,26 @@ export const CallFooter: FC = ({ 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$); + + // 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 showLogo = useBehavior(vm.showLogo$); @@ -214,8 +240,9 @@ export const CallFooter: FC = ({ options={videoOptions} selectedOption={selectedVideo} onSelect={selectVideoButtonOption} - videoBlurToggleClick={toggleBlur} - videoBlurEnabled={videoBlurEnabled} + backgroundEffects={backgroundEffects} + selectedBackgroundEffect={backgroundEffect} + onSelectBackgroundEffect={selectBackgroundEffect} />, ); } else { diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx index 305b2a434..c6588aebb 100644 --- a/src/components/CallFooterViewModel.tsx +++ b/src/components/CallFooterViewModel.tsx @@ -14,6 +14,7 @@ import { type MenuOptions } from "./MediaMuteAndSwitchButton"; import { type MediaDevices } from "../state/MediaDevices"; import { backgroundBlur as backgroundBlurSettings, + backgroundEffect as backgroundEffectSetting, debugTileLayout as debugTileLayoutSetting, } from "../settings/settings"; import { type Behavior, constant } from "../state/Behavior"; @@ -76,6 +77,8 @@ function buildDeviceBehaviors( | "selectVideoButtonOption$" | "toggleBlur$" | "videoBlurEnabled$" + | "backgroundEffect$" + | "selectBackgroundEffect$" > { const options$ = ( available$: Behavior>, @@ -128,6 +131,20 @@ function buildDeviceBehaviors( ), ), 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, + ), + ), + ), }; }