refactor video blur toggle

This commit is contained in:
Timo K
2026-05-18 18:11:27 +02:00
parent dd79ef659c
commit d5bebcc3a5
7 changed files with 71 additions and 65 deletions

View File

@@ -3,6 +3,7 @@
"user_menu": "User menu" "user_menu": "User menu"
}, },
"action": { "action": {
"blur_background": "Blur background",
"close": "Close", "close": "Close",
"copy_link": "Copy link", "copy_link": "Copy link",
"edit": "Edit", "edit": "Edit",

View File

@@ -79,6 +79,8 @@ export const Default: Story = {
toggleAudio: fn(), toggleAudio: fn(),
toggleVideo: fn(), toggleVideo: fn(),
toggleScreenSharing: fn(), toggleScreenSharing: fn(),
toggleBlur: fn(),
videoBlurEnabled: true,
hangup: fn(), hangup: fn(),
buttonSize: "lg", buttonSize: "lg",
showFooter: true, showFooter: true,
@@ -234,7 +236,6 @@ export const Pip: Story = {
args: { args: {
...Default.args, ...Default.args,
buttonSize: "md", buttonSize: "md",
showSettingsButton: false,
layoutMode: undefined, layoutMode: undefined,
}, },
play: async ({ args, canvasElement }) => { play: async ({ args, canvasElement }) => {

View File

@@ -32,7 +32,6 @@ import { type GridMode } from "../state/CallViewModel/CallViewModel";
import { import {
MediaMuteAndSwitchButton, MediaMuteAndSwitchButton,
type MenuOptions, type MenuOptions,
type ToggleOption,
} from "./MediaMuteAndSwitchButton"; } from "./MediaMuteAndSwitchButton";
import { type ViewModel } from "../state/ViewModel"; import { type ViewModel } from "../state/ViewModel";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
@@ -61,6 +60,7 @@ export interface FooterActions {
toggleAudio: (() => void) | undefined; toggleAudio: (() => void) | undefined;
/** 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;
/** Also controls if the layout button is visible */ /** Also controls if the layout button is visible */
setLayoutMode: ((mode: GridMode) => void) | undefined; setLayoutMode: ((mode: GridMode) => void) | undefined;
toggleScreenSharing: (() => void) | undefined; toggleScreenSharing: (() => void) | undefined;
@@ -73,6 +73,7 @@ export interface FooterActions {
export interface FooterState { export interface FooterState {
audioEnabled: boolean; audioEnabled: boolean;
videoEnabled: boolean; videoEnabled: boolean;
videoBlurEnabled: boolean;
showFooter: boolean; showFooter: boolean;
/* This is needed for WindowMode = "flat" */ /* This is needed for WindowMode = "flat" */
@@ -106,7 +107,6 @@ export interface FooterState {
selectedVideo: string | undefined; selectedVideo: string | undefined;
selectAudioButtonOption: ((deviceId: string) => void) | undefined; selectAudioButtonOption: ((deviceId: string) => void) | undefined;
selectVideoButtonOption: ((option: string) => void) | undefined; selectVideoButtonOption: ((option: string) => void) | undefined;
videoToggles: ToggleOption[];
} }
export interface FooterProps { export interface FooterProps {
@@ -139,7 +139,8 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
const selectedAudio = useBehavior(vm.selectedAudio$); const selectedAudio = useBehavior(vm.selectedAudio$);
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$); const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$); const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
const videoToggles = useBehavior(vm.videoToggles$); const toggleBlur = useBehavior(vm.toggleBlur$);
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
const buttonSize = useBehavior(vm.buttonSize$); const buttonSize = useBehavior(vm.buttonSize$);
const showLogo = useBehavior(vm.showLogo$); const showLogo = useBehavior(vm.showLogo$);
@@ -195,9 +196,10 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
enabled={videoEnabled ?? false} enabled={videoEnabled ?? false}
onMuteClick={toggleVideo} onMuteClick={toggleVideo}
options={videoOptions} options={videoOptions}
toggles={videoToggles}
selectedOption={selectedVideo} selectedOption={selectedVideo}
onSelect={selectVideoButtonOption} onSelect={selectVideoButtonOption}
backgroundBlurToggleClick={toggleBlur}
videoBlurEnabled={videoBlurEnabled}
/>, />,
); );
} else { } else {

View File

@@ -9,10 +9,7 @@ import { combineLatest, map, switchMap } from "rxjs";
import { supportsBackgroundProcessors } from "@livekit/track-processors"; import { supportsBackgroundProcessors } from "@livekit/track-processors";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { import { type MenuOptions } from "./MediaMuteAndSwitchButton";
type MenuOptions,
type ToggleOption,
} from "./MediaMuteAndSwitchButton";
import { type MediaDevices } from "../state/MediaDevices"; import { type MediaDevices } from "../state/MediaDevices";
import { mediaDeviceLabelToString } from "../settings/DeviceSelection"; import { mediaDeviceLabelToString } from "../settings/DeviceSelection";
import { import {
@@ -67,7 +64,8 @@ function buildDeviceBehaviors(
| "videoOptions$" | "videoOptions$"
| "selectedVideo$" | "selectedVideo$"
| "selectVideoButtonOption$" | "selectVideoButtonOption$"
| "videoToggles$" | "toggleBlur$"
| "videoBlurEnabled$"
> { > {
return { return {
audioOptions$: scope.behavior( audioOptions$: scope.behavior(
@@ -115,40 +113,18 @@ function buildDeviceBehaviors(
selectedVideo$: scope.behavior( selectedVideo$: scope.behavior(
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)), mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
), ),
selectVideoButtonOption$: scope.behavior( selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
backgroundBlurSettings.value$.pipe( toggleBlur$: scope.behavior(
map((current) => { combineLatest([backgroundBlurSettings.value$, disableSwitcher$]).pipe(
return (option: string) => { map(([current, switcherDisabled]) => {
if (option === "blur") { return () =>
backgroundBlurSettings.setValue(!current); !switcherDisabled && supportsBackgroundProcessors()
} else { ? (): void => backgroundBlurSettings.setValue(!current)
mediaDevices.videoInput.select(option); : constant(undefined);
}
};
}), }),
), ),
), ),
videoToggles$: scope.behavior( videoBlurEnabled$: backgroundBlurSettings.value$,
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as ToggleOption[])
: backgroundBlurSettings.value$.pipe(
map((blurActive) =>
supportsBackgroundProcessors()
? [
{
id: "blur",
enabled: blurActive,
label: "Blur Background",
},
]
: [],
),
),
),
),
),
}; };
} }
@@ -278,7 +254,6 @@ export function createLobbyFooterViewModel(
openSettings, openSettings,
hangup, hangup,
debugTileLayout: false, debugTileLayout: false,
showSettingsButton: openSettings !== undefined,
showFooter: true, showFooter: true,
toggleAudio: undefined, toggleAudio: undefined,
toggleVideo: undefined, toggleVideo: undefined,
@@ -298,7 +273,6 @@ export function createLobbyFooterViewModel(
selectedVideo: undefined, selectedVideo: undefined,
selectAudioButtonOption: undefined, selectAudioButtonOption: undefined,
selectVideoButtonOption: undefined, selectVideoButtonOption: undefined,
videoToggles: undefined,
}), }),
...buildMuteBehaviors(scope, muteStates), ...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices, constant(false)), ...buildDeviceBehaviors(scope, mediaDevices, constant(false)),

View File

@@ -17,7 +17,7 @@ describe("MediaMuteAndSwitchButton", () => {
test("renders", () => { test("renders", () => {
const { container } = render( const { container } = render(
<TooltipProvider> <TooltipProvider>
<MediaMuteAndSwitchButton title={"Switcher"} /> <MediaMuteAndSwitchButton title={"Switcher"} iconsAndLabels={"audio"} />
</TooltipProvider>, </TooltipProvider>,
); );
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
@@ -187,15 +187,14 @@ describe("MediaMuteAndSwitchButton", () => {
test("renders menu with toggle control and calls toggle callback", async () => { test("renders menu with toggle control and calls toggle callback", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onSelect = vi.fn(); const onSelect = vi.fn();
const onVideoBlurToggle = vi.fn();
const { getByRole } = render( const { getByRole } = render(
<TooltipProvider> <TooltipProvider>
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher" title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled={true}
toggles={[ backgroundBlurToggleClick={onVideoBlurToggle}
{ label: "Background blur", id: "bg_blur", enabled: false },
]}
onSelect={onSelect} onSelect={onSelect}
/> />
</TooltipProvider>, </TooltipProvider>,
@@ -204,14 +203,14 @@ describe("MediaMuteAndSwitchButton", () => {
await user.click(getByRole("button", { name: "Microphone" })); await user.click(getByRole("button", { name: "Microphone" }));
const toggle = screen.getByRole("menuitemcheckbox", { const toggle = screen.getByRole("menuitemcheckbox", {
name: "Background blur", name: "Blur background",
}); });
expect(toggle).toBeInTheDocument(); expect(toggle).toBeInTheDocument();
expect(toggle).toHaveAttribute("aria-checked", "false"); expect(toggle).toHaveAttribute("aria-checked", "false");
await user.click(toggle); await user.click(toggle);
expect(onSelect).toHaveBeenCalledWith("bg_blur"); expect(onVideoBlurToggle).toHaveBeenCalled();
}); });
test("renders check icon to mark the selected menu item", async () => { test("renders check icon to mark the selected menu item", async () => {

View File

@@ -30,11 +30,6 @@ export interface MenuOptions {
label: string; label: string;
id: string; id: string;
} }
export interface ToggleOption {
label: string;
enabled: boolean;
id: string;
}
export interface MediaMuteAndSwitchButtonProps { export interface MediaMuteAndSwitchButtonProps {
/** The title used in the Switcher modal. */ /** The title used in the Switcher modal. */
@@ -48,12 +43,8 @@ export interface MediaMuteAndSwitchButtonProps {
options?: MenuOptions[]; options?: MenuOptions[];
/** The option that will currently be rendered as the selected option */ /** The option that will currently be rendered as the selected option */
selectedOption?: string; selectedOption?: string;
/** backgroundBlurToggleClick?: () => void;
* The available toggles (including there current state) videoBlurEnabled?: boolean;
* The toggle state is not stored by this component.
* It is handled externally and needs to be set by listening to the `onSelect` callback and setting the right toggle item to `enabled`
*/
toggles?: ToggleOption[];
/** /**
* For any toggle and option this method will be called. * For any toggle and option this method will be called.
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled` * So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
@@ -61,6 +52,8 @@ export interface MediaMuteAndSwitchButtonProps {
onSelect?: (id: string) => void; onSelect?: (id: string) => void;
} }
const BLUR_ID = "blur";
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
title, title,
enabled, enabled,
@@ -68,13 +61,24 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
iconsAndLabels, iconsAndLabels,
options, options,
selectedOption, selectedOption,
toggles, videoBlurEnabled,
backgroundBlurToggleClick,
onSelect, onSelect,
}) => { }) => {
const [plannedSelection, setPlannedSelection] = useState<string | null>(null); const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
let button; let button;
let toggles =
backgroundBlurToggleClick === undefined
? []
: [
{
label: t("action.blur_background"),
enabled: videoBlurEnabled,
id: BLUR_ID,
},
];
switch (iconsAndLabels) { switch (iconsAndLabels) {
case "video": case "video":
button = ( button = (
@@ -89,6 +93,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
data-testid="incall_videomute" data-testid="incall_videomute"
/> />
); );
toggles = [];
break; break;
case "audio": case "audio":
button = ( button = (
@@ -103,7 +108,6 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
data-testid="incall_mute" data-testid="incall_mute"
/> />
); );
break; break;
} }
@@ -182,10 +186,10 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
<ToggleMenuItem <ToggleMenuItem
label={toggle.label} label={toggle.label}
onSelect={(e) => { onSelect={(e) => {
onSelect?.(toggle.id); backgroundBlurToggleClick?.();
e.preventDefault(); e.preventDefault();
}} }}
checked={toggle.enabled} checked={toggle.enabled ?? false}
key={toggle.id} key={toggle.id}
/> />
))} ))}

View File

@@ -5,15 +5,40 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = `
<div <div
class="container" class="container"
> >
<button
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_0_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="switch"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
/>
</svg>
</button>
<button <button
aria-disabled="false" aria-disabled="false"
aria-expanded="false" aria-expanded="false"
aria-haspopup="menu" aria-haspopup="menu"
aria-label="Microphone"
class="_button_1nw83_8 menuButton _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 menuButton _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="tertiary" data-kind="tertiary"
data-size="lg" data-size="lg"
data-state="closed" data-state="closed"
id="radix-_r_0_" id="radix-_r_5_"
role="button" role="button"
tabindex="0" tabindex="0"
type="button" type="button"