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
+1
View File
@@ -3,6 +3,7 @@
"user_menu": "User menu"
},
"action": {
"blur_background": "Blur background",
"close": "Close",
"copy_link": "Copy link",
"edit": "Edit",
+2 -1
View File
@@ -79,6 +79,8 @@ export const Default: Story = {
toggleAudio: fn(),
toggleVideo: fn(),
toggleScreenSharing: fn(),
toggleBlur: fn(),
videoBlurEnabled: true,
hangup: fn(),
buttonSize: "lg",
showFooter: true,
@@ -234,7 +236,6 @@ export const Pip: Story = {
args: {
...Default.args,
buttonSize: "md",
showSettingsButton: false,
layoutMode: undefined,
},
play: async ({ args, canvasElement }) => {
+6 -4
View File
@@ -32,7 +32,6 @@ import { type GridMode } from "../state/CallViewModel/CallViewModel";
import {
MediaMuteAndSwitchButton,
type MenuOptions,
type ToggleOption,
} from "./MediaMuteAndSwitchButton";
import { type ViewModel } from "../state/ViewModel";
import { useBehavior } from "../useBehavior";
@@ -61,6 +60,7 @@ export interface FooterActions {
toggleAudio: (() => void) | undefined;
/** Also controls if the videoMute button is disabled */
toggleVideo: (() => void) | undefined;
toggleBlur: (() => void) | undefined;
/** Also controls if the layout button is visible */
setLayoutMode: ((mode: GridMode) => void) | undefined;
toggleScreenSharing: (() => void) | undefined;
@@ -73,6 +73,7 @@ export interface FooterActions {
export interface FooterState {
audioEnabled: boolean;
videoEnabled: boolean;
videoBlurEnabled: boolean;
showFooter: boolean;
/* This is needed for WindowMode = "flat" */
@@ -106,7 +107,6 @@ export interface FooterState {
selectedVideo: string | undefined;
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
selectVideoButtonOption: ((option: string) => void) | undefined;
videoToggles: ToggleOption[];
}
export interface FooterProps {
@@ -139,7 +139,8 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
const selectedAudio = useBehavior(vm.selectedAudio$);
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
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 showLogo = useBehavior(vm.showLogo$);
@@ -195,9 +196,10 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
enabled={videoEnabled ?? false}
onMuteClick={toggleVideo}
options={videoOptions}
toggles={videoToggles}
selectedOption={selectedVideo}
onSelect={selectVideoButtonOption}
backgroundBlurToggleClick={toggleBlur}
videoBlurEnabled={videoBlurEnabled}
/>,
);
} else {
+12 -38
View File
@@ -9,10 +9,7 @@ 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 MenuOptions } from "./MediaMuteAndSwitchButton";
import { type MediaDevices } from "../state/MediaDevices";
import { mediaDeviceLabelToString } from "../settings/DeviceSelection";
import {
@@ -67,7 +64,8 @@ function buildDeviceBehaviors(
| "videoOptions$"
| "selectedVideo$"
| "selectVideoButtonOption$"
| "videoToggles$"
| "toggleBlur$"
| "videoBlurEnabled$"
> {
return {
audioOptions$: scope.behavior(
@@ -115,40 +113,18 @@ function buildDeviceBehaviors(
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);
}
};
selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
toggleBlur$: scope.behavior(
combineLatest([backgroundBlurSettings.value$, disableSwitcher$]).pipe(
map(([current, switcherDisabled]) => {
return () =>
!switcherDisabled && supportsBackgroundProcessors()
? (): void => backgroundBlurSettings.setValue(!current)
: constant(undefined);
}),
),
),
videoToggles$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as ToggleOption[])
: backgroundBlurSettings.value$.pipe(
map((blurActive) =>
supportsBackgroundProcessors()
? [
{
id: "blur",
enabled: blurActive,
label: "Blur Background",
},
]
: [],
),
),
),
),
),
videoBlurEnabled$: backgroundBlurSettings.value$,
};
}
@@ -278,7 +254,6 @@ export function createLobbyFooterViewModel(
openSettings,
hangup,
debugTileLayout: false,
showSettingsButton: openSettings !== undefined,
showFooter: true,
toggleAudio: undefined,
toggleVideo: undefined,
@@ -298,7 +273,6 @@ export function createLobbyFooterViewModel(
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectVideoButtonOption: undefined,
videoToggles: undefined,
}),
...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices, constant(false)),
@@ -17,7 +17,7 @@ describe("MediaMuteAndSwitchButton", () => {
test("renders", () => {
const { container } = render(
<TooltipProvider>
<MediaMuteAndSwitchButton title={"Switcher"} />
<MediaMuteAndSwitchButton title={"Switcher"} iconsAndLabels={"audio"} />
</TooltipProvider>,
);
expect(container).toMatchSnapshot();
@@ -187,15 +187,14 @@ describe("MediaMuteAndSwitchButton", () => {
test("renders menu with toggle control and calls toggle callback", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const onVideoBlurToggle = vi.fn();
const { getByRole } = render(
<TooltipProvider>
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
toggles={[
{ label: "Background blur", id: "bg_blur", enabled: false },
]}
backgroundBlurToggleClick={onVideoBlurToggle}
onSelect={onSelect}
/>
</TooltipProvider>,
@@ -204,14 +203,14 @@ describe("MediaMuteAndSwitchButton", () => {
await user.click(getByRole("button", { name: "Microphone" }));
const toggle = screen.getByRole("menuitemcheckbox", {
name: "Background blur",
name: "Blur background",
});
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveAttribute("aria-checked", "false");
await user.click(toggle);
expect(onSelect).toHaveBeenCalledWith("bg_blur");
expect(onVideoBlurToggle).toHaveBeenCalled();
});
test("renders check icon to mark the selected menu item", async () => {
+19 -15
View File
@@ -30,11 +30,6 @@ export interface MenuOptions {
label: string;
id: string;
}
export interface ToggleOption {
label: string;
enabled: boolean;
id: string;
}
export interface MediaMuteAndSwitchButtonProps {
/** The title used in the Switcher modal. */
@@ -48,12 +43,8 @@ export interface MediaMuteAndSwitchButtonProps {
options?: MenuOptions[];
/** The option that will currently be rendered as the selected option */
selectedOption?: string;
/**
* The available toggles (including there current state)
* 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[];
backgroundBlurToggleClick?: () => void;
videoBlurEnabled?: boolean;
/**
* 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`
@@ -61,6 +52,8 @@ export interface MediaMuteAndSwitchButtonProps {
onSelect?: (id: string) => void;
}
const BLUR_ID = "blur";
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
title,
enabled,
@@ -68,13 +61,24 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
iconsAndLabels,
options,
selectedOption,
toggles,
videoBlurEnabled,
backgroundBlurToggleClick,
onSelect,
}) => {
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
let button;
let toggles =
backgroundBlurToggleClick === undefined
? []
: [
{
label: t("action.blur_background"),
enabled: videoBlurEnabled,
id: BLUR_ID,
},
];
switch (iconsAndLabels) {
case "video":
button = (
@@ -89,6 +93,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
data-testid="incall_videomute"
/>
);
toggles = [];
break;
case "audio":
button = (
@@ -103,7 +108,6 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
data-testid="incall_mute"
/>
);
break;
}
@@ -182,10 +186,10 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
<ToggleMenuItem
label={toggle.label}
onSelect={(e) => {
onSelect?.(toggle.id);
backgroundBlurToggleClick?.();
e.preventDefault();
}}
checked={toggle.enabled}
checked={toggle.enabled ?? false}
key={toggle.id}
/>
))}
@@ -5,15 +5,40 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = `
<div
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
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Microphone"
class="_button_1nw83_8 menuButton _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="tertiary"
data-size="lg"
data-state="closed"
id="radix-_r_0_"
id="radix-_r_5_"
role="button"
tabindex="0"
type="button"