diff --git a/locales/en/app.json b/locales/en/app.json
index a852e6505..de50b6506 100644
--- a/locales/en/app.json
+++ b/locales/en/app.json
@@ -3,6 +3,7 @@
"user_menu": "User menu"
},
"action": {
+ "add_background_image": "Add image",
"blur_background": "Blur background",
"close": "Close",
"copy_link": "Copy link",
@@ -212,6 +213,7 @@
"auto_gain_control_label": "Automatic gain control",
"background_blur_header": "Background",
"background_blur_label": "Blur the background of the video",
+ "background_effects_header": "Background effects",
"bitrate_label": "Bitrate",
"blur_not_supported_by_browser": "(Background blur is not supported by this device.)",
"camera_header": "Camera quality",
diff --git a/src/components/MediaMuteAndSwitchButton.module.css b/src/components/MediaMuteAndSwitchButton.module.css
index 4faa52ee1..0d36b8fd3 100644
--- a/src/components/MediaMuteAndSwitchButton.module.css
+++ b/src/components/MediaMuteAndSwitchButton.module.css
@@ -135,3 +135,176 @@ Please see LICENSE in the repository root for full details.
outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused);
outline-offset: calc(-1 * var(--cpd-border-width-2));
}
+
+/*
+ * The Background effects grid.
+ *
+ * Three across matches the design and keeps a thumbnail readable at a glance;
+ * with no effect, blur, two shipped images and the add tile it also settles
+ * into a tidy three-by-three at the maximum number of images.
+ *
+ * minmax(0, 1fr) rather than 1fr: a grid track will not shrink below its
+ * content's minimum width by default, and a tile carrying a label is wider
+ * than a third of the menu, so plain 1fr lays out three full-width columns and
+ * clips two of them.
+ */
+.effectGrid {
+ display: grid;
+ /* A floor, not just a share. The menu sizes itself to its content, so
+ dividing whatever width the device names happen to need makes the tiles
+ narrower the shorter those names are; this makes the menu wide enough for
+ three proper tiles instead. */
+ grid-template-columns: repeat(3, minmax(84px, 1fr));
+ /* Every tile the same height, set by the tallest — the ones carrying a
+ label. Forcing a ratio instead clips the label as soon as the menu is
+ narrow enough that the ratio cannot hold it. */
+ grid-auto-rows: 1fr;
+ gap: var(--cpd-space-2x);
+ /* The same inset a menu heading and a device row carry, so the tiles line
+ their left edge up with the "B" of the heading above them and with the
+ radio buttons above that. Matching the surrounding boxes instead puts the
+ tiles to the left of both, since a heading insets its own text. */
+ padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-3x);
+}
+
+/*
+ * A menu item lays itself out as a grid of chevron, icon and label in a row.
+ * A tile centres its glyph with the label beneath instead, so this has to
+ * outweigh that rule rather than sit beside it.
+ *
+ * The tiles are a little wider than tall, as the design draws them, and hold
+ * that shape whatever the menu measures.
+ */
+.effectGrid .effectTile {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ /* Glyph and label centre together as one group. Pinning the label to the
+ floor instead leaves the glyph riding high in the space above it. */
+ justify-content: center;
+ /* Measured against the design: the glyph sits a quarter of the tile wide,
+ with about a seventh of the tile's height between its ink and the label's.
+ The label's own leading is part of that distance, so the step here is
+ smaller than the ink gap it produces. */
+ gap: var(--cpd-space-1x);
+ block-size: auto;
+ min-block-size: 0;
+ min-inline-size: 0;
+ /* More room above and below than at the sides: the tile is wider than tall,
+ and the padding is what carries that proportion now the gap is set by the
+ design's spacing rather than by filling the box. */
+ padding: var(--cpd-space-3x) var(--cpd-space-2x);
+ overflow: hidden;
+ border: 2px solid var(--cpd-color-border-interactive-secondary);
+ border-radius: var(--cpd-space-2x);
+}
+
+/* The selection is drawn here and announced by the item's aria-checked, so it
+ never rests on colour alone. Matched to the specificity of the base tile
+ rule above, which also sets a border-color and would otherwise win.
+ The inset shadow thickens the frame without changing the tile's geometry,
+ which a wider border would, shifting every tile as the selection moves. */
+.effectGrid .effectTileSelected {
+ border-color: var(--cpd-color-border-accent);
+ box-shadow: inset 0 0 0 var(--cpd-border-width-1)
+ var(--cpd-color-border-accent);
+}
+
+.effectGrid .effectTile > * {
+ /* A menu item spaces its icon and label for a row, with the gap carried as a
+ right margin on each. Stacked in a tile that margin is dead space on one
+ side, which shifts the glyph and the label off the tile's centre. */
+ margin: 0;
+ min-inline-size: 0;
+ max-inline-size: 100%;
+ text-align: center;
+ overflow-wrap: anywhere;
+}
+
+.effectSwatch {
+ flex: 0 0 auto;
+ inline-size: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--cpd-color-icon-secondary);
+}
+
+/* An image tile is the picture: it fills the tile rather than sitting in a box
+ inside one, which is what the design draws and what makes a background
+ recognisable at this size. */
+.effectThumb {
+ position: absolute;
+ inset: 0;
+ inline-size: 100%;
+ block-size: 100%;
+ object-fit: cover;
+}
+
+/* Blur has no icon in the design system, and a hatch reads as "softened" in a
+ way a glyph does not. */
+.effectBlurGlyph {
+ /* Smaller than the glyph beside it, and than the design's own hatch measures.
+ The design's is an icon whose strokes shorten towards the corners, so it
+ leaves them empty and reads lighter; a uniform hatch fills its box and
+ reads heavier at the same size. */
+ inline-size: 18px;
+ block-size: 18px;
+ border-radius: var(--cpd-space-1x);
+ background-image: repeating-linear-gradient(
+ 135deg,
+ var(--cpd-color-icon-secondary) 0 1.2px,
+ transparent 1.2px 3.15px
+ );
+}
+
+/* The tick marking the tile in force, standing where the glyph would.
+ Positioned, so it paints above the image, which is positioned itself and
+ would otherwise cover anything left in normal flow. */
+.effectCheck {
+ position: relative;
+ color: var(--cpd-color-icon-accent-primary);
+}
+
+/* On a picture it needs its own ground: a tick alone disappears into whatever
+ the image happens to be behind it. */
+.effectCheckOnImage {
+ border-radius: 50%;
+ background: var(--cpd-color-bg-canvas-default);
+ box-shadow: 0 0 0 var(--cpd-border-width-2)
+ var(--cpd-color-bg-canvas-default);
+}
+
+/* A menu item's label is sized for a row; in a tile the design sets it a step
+ smaller, against the glyph above it. */
+.effectLabel {
+ font: var(--cpd-font-body-sm-medium);
+ /* A menu item labels itself in the secondary colour, which reads as muted
+ under a glyph the design draws at full strength. */
+ color: var(--cpd-color-text-primary);
+}
+
+/* Named for assistive technology, not drawn: an image labels itself, and the
+ design gives the add tile a plus alone. */
+.effectLabelUnseen {
+ position: absolute;
+ inline-size: 1px;
+ block-size: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+/*
+ * Unavailable. Opacity alone is not enough on an image tile: a washed-out
+ * photograph still reads as a photograph you could pick. Draining the colour
+ * as well says plainly that there is nothing to choose here.
+ */
+.effectGrid .effectTile[aria-disabled="true"] {
+ opacity: 0.55;
+}
+
+.effectGrid .effectTile[aria-disabled="true"] .effectThumb {
+ filter: grayscale(1);
+}
diff --git a/src/components/MediaMuteAndSwitchButton.stories.tsx b/src/components/MediaMuteAndSwitchButton.stories.tsx
index 474240cb0..b4b77c337 100644
--- a/src/components/MediaMuteAndSwitchButton.stories.tsx
+++ b/src/components/MediaMuteAndSwitchButton.stories.tsx
@@ -768,3 +768,142 @@ export const FocusRingCoversTheBlurToggle: Story = {
await expect(outlineWidth(toggle)).toBe(0);
},
};
+
+/**
+ * Stand-in background art, drawn here rather than imported, so these stories
+ * carry no asset of their own. The shipped images do not exist yet.
+ */
+const swatch = (from: string, to: string): string =>
+ `data:image/svg+xml;utf8,${encodeURIComponent(
+ ``,
+ )}`;
+
+const backgroundEffects = [
+ { id: "none", label: "None", kind: "none" as const },
+ { id: "blur", label: "Blur", kind: "blur" as const },
+ {
+ id: "indoor",
+ label: "Indoor",
+ kind: "image" as const,
+ imageUrl: swatch("#d8c9a8", "#8a6f4a"),
+ },
+ {
+ id: "outdoor",
+ label: "Outdoor",
+ kind: "image" as const,
+ imageUrl: swatch("#9fd0e8", "#2f6f4f"),
+ },
+];
+
+/**
+ * The camera menu's Background effects section: no effect, blur, the shipped
+ * images, and the tile for adding your own.
+ */
+export const BackgroundEffects: Story = {
+ args: {
+ title: "Camera",
+ iconsAndLabels: "video",
+ enabled: true,
+ options: [
+ { label: { type: "name", name: "Camera 1" }, id: "1" },
+ { label: { type: "name", name: "Camera 2" }, id: "2" },
+ ],
+ selectedOption: "1",
+ onSelect: fn(),
+ backgroundEffects,
+ selectedBackgroundEffect: "none",
+ onSelectBackgroundEffect: fn(),
+ onAddBackgroundImage: fn(),
+ },
+ play: async ({ args, canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
+
+ const blur = await within(document.body).findByRole("menuitemradio", {
+ name: "Blur",
+ });
+ await userEvent.click(blur);
+ await expect(args.onSelectBackgroundEffect).toHaveBeenCalledWith("blur");
+ },
+};
+
+/** An image is in force, so the grid marks it rather than no effect. */
+export const BackgroundImageChosen: Story = {
+ args: {
+ ...BackgroundEffects.args,
+ selectedBackgroundEffect: "outdoor",
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
+
+ const chosen = await within(document.body).findByRole("menuitemradio", {
+ name: "Outdoor",
+ });
+ // The selection is announced, not only drawn.
+ await expect(chosen).toHaveAttribute("aria-checked", "true");
+ },
+};
+
+/**
+ * Where the browser or device cannot run background processing. The section
+ * keeps its shape and its tiles, and none of them can be chosen.
+ */
+export const BackgroundEffectsUnavailable: Story = {
+ args: {
+ ...BackgroundEffects.args,
+ onSelectBackgroundEffect: undefined,
+ onAddBackgroundImage: undefined,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
+
+ const blur = await within(document.body).findByRole("menuitemradio", {
+ name: "Blur",
+ });
+ await waitFor(() => expect(blur).toHaveAttribute("aria-disabled", "true"));
+
+ // No effect needs no background processing, so it stays choosable.
+ const none = await within(document.body).findByRole("menuitemradio", {
+ name: "None",
+ });
+ await expect(none).not.toHaveAttribute("aria-disabled", "true");
+ },
+};
+
+/**
+ * A device name long enough to set the menu's width, so the tiles are seen at
+ * the widest the menu gets rather than only at the narrowest.
+ */
+export const BackgroundEffectsWithALongDeviceName: Story = {
+ args: {
+ ...BackgroundEffects.args,
+ options: [
+ {
+ label: {
+ type: "name",
+ name: "Logitech BRIO 4K Ultra HD Pro Business Webcam (046d:085e)",
+ },
+ id: "1",
+ },
+ { label: { type: "name", name: "Camera 2" }, id: "2" },
+ ],
+ selectedOption: "1",
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
+
+ const none = await within(document.body).findByRole("menuitemradio", {
+ name: "None",
+ });
+ // Three tiles to a row however wide the name makes the menu.
+ const grid = none.parentElement!;
+ await expect(getComputedStyle(grid).gridTemplateColumns.split(" ")).toHaveLength(3);
+ },
+};
diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx
index 836c76789..610d440bc 100644
--- a/src/components/MediaMuteAndSwitchButton.tsx
+++ b/src/components/MediaMuteAndSwitchButton.tsx
@@ -25,6 +25,9 @@ import {
ChevronUpIcon,
ChevronDownIcon,
SpinnerIcon,
+ BlockIcon,
+ PlusIcon,
+ CheckCircleSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
@@ -47,6 +50,16 @@ export interface MenuOptions {
id: string;
}
+/** One choice in the camera menu's Background effects section. */
+export interface BackgroundEffectOption {
+ id: string;
+ /** Shown under the tile, and the item's accessible name. */
+ label: string;
+ kind: "none" | "blur" | "image";
+ /** The thumbnail, for kind "image". Blur and no effect draw their own. */
+ imageUrl?: string;
+}
+
export interface MediaMuteAndSwitchButtonProps {
/**
* The accessible name of the menu. Defaults to a translated name for the
@@ -78,6 +91,21 @@ export interface MediaMuteAndSwitchButtonProps {
onSelectOutput?: (id: string) => void;
videoBlurToggleClick?: () => void;
videoBlurEnabled?: boolean;
+ /**
+ * Background effects, shown as a grid of tiles under the camera list. Camera
+ * menu only; omitted entirely for audio. An empty list leaves the section out.
+ */
+ backgroundEffects?: BackgroundEffectOption[];
+ /** The effect currently in force. */
+ selectedBackgroundEffect?: string;
+ /**
+ * Called when an effect is chosen. Undefined means no effect can be chosen
+ * here, and the section renders disabled, so the menu keeps the same shape
+ * wherever background processing is unavailable.
+ */
+ onSelectBackgroundEffect?: (id: string) => void;
+ /** Called when the add tile is chosen. Omit to leave that tile out. */
+ onAddBackgroundImage?: () => void;
/**
* 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`
@@ -125,6 +153,10 @@ export const MediaMuteAndSwitchButton: FC = ({
onSelectOutput,
videoBlurEnabled,
videoBlurToggleClick,
+ backgroundEffects,
+ selectedBackgroundEffect,
+ onSelectBackgroundEffect,
+ onAddBackgroundImage,
onSelect,
}) => {
// Which device we have asked for but not yet been given. Carries the kind as
@@ -409,6 +441,101 @@ export const MediaMuteAndSwitchButton: FC = ({
));
};
+ // The camera menu's Background effects section. Tiles rather than rows, but
+ // menuitemradio like the device list above, so the menu stays one keyboard
+ // model throughout and the selection is announced rather than only drawn.
+ const effectTiles = (): ReactElement[] => {
+ const list = backgroundEffects ?? [];
+ // Shown but not choosable where background processing is unavailable, so
+ // the menu keeps the same shape on every platform. No effect is the
+ // exception: it needs no processing, so it stays choosable and stays the
+ // one in force.
+ const unavailable = onSelectBackgroundEffect === undefined;
+ const tiles = list.map((effect) => (
+