Hold the chosen effect across calls, and forget one that can't run

- A remembered effect this browser can't run is cleared rather than kept,
  as the menu already showed no effect in force there.
- A camera turned off and on again with an effect chosen gets the same
  pipeline as its track arrives, neither rebuilt nor switched again.
- End to end, on Chromium and Firefox: the effect chosen before a call is
  still chosen, and worn, in the next call after leaving; and a second
  tab still shuts the first down with the existing "Opened in another
  tab" error while an effect runs.
- Switching camera and sharing a screen are checked by hand: they need
  two cameras and a screen, which the fake devices don't give yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-11-05 15:30:25 +01:00
committed by Timo K.
co-authored by Claude Opus 5.5
parent 4f184f504d
commit 741dab6dbb
5 changed files with 93 additions and 2 deletions
+63
View File
@@ -151,6 +151,69 @@ test.describe("background effects section", () => {
});
});
test.describe("the chosen effect", () => {
test("chosen effect survives leaving and rejoining", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Returning user", "Effect remembered");
const { camera, picture } = await chooseAndWear(page, "Background 1");
await page.getByTestId("lobby_joinCall").click();
await page.getByTestId("incall_leave").click();
await expect(page.getByRole("heading")).toContainText(
"your call has ended",
);
await page.goto("/");
await page.getByTestId("home_callName").fill("Effect remembered again");
await page.getByTestId("home_go").click();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
await page.getByRole("button", { name: "Camera", exact: true }).click();
const tile = page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "Background 1" });
await expect(tile).toHaveAttribute("aria-checked", "true");
const preview = page.locator("video").first();
await expect
.poll(async () => distance(await averageColour(preview), picture), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, picture) / 2);
});
test("second session still shows the existing error", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Two tabs", "Effect in two tabs");
await chooseAndWear(page, "Background 1");
const second = await page.context().newPage();
await second.goto("/");
await expect(
page.getByRole("heading", { name: "Opened in another tab" }),
).toBeVisible();
});
});
/** Chooses an effect in the lobby, and waits until the preview wears it. */
async function chooseAndWear(
page: Page,
name: string,
): Promise<{ camera: Colour; picture: Colour }> {
const preview = page.locator("video").first();
const camera = await cameraColour(preview);
await page.getByRole("button", { name: "Camera", exact: true }).click();
const tile = page.getByRole("menuitemradio", { name });
await tile.click();
const picture = await averageColour(tile.locator("img"));
await expect
.poll(async () => distance(await averageColour(preview), picture), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, picture) / 2);
await page.keyboard.press("Escape");
return { camera, picture };
}
test.describe("joining with a background chosen", () => {
test("publishes no frame of the room", async ({ browser }) => {
// Two first builds of the pipeline, one of them held back.
+11
View File
@@ -69,6 +69,7 @@ describe("the pipeline's state", () => {
const effects = createBackgroundEffects(testScope(), {
supported: true,
effect$: behavior(effect, { n: "none", b: "blur" }),
setEffect: vi.fn(),
pipeline: fakePipeline().pipeline,
transformer,
});
@@ -94,6 +95,7 @@ describe("the pipeline's state", () => {
describe("background effects", () => {
let effect$: BehaviorSubject<string>;
let setEffect: (raw: string) => void;
let fake: ReturnType<typeof fakePipeline>;
function build(
@@ -102,6 +104,7 @@ describe("background effects", () => {
return createBackgroundEffects(testScope(), {
supported: true,
effect$,
setEffect,
pipeline: fake.pipeline,
transformer: { onFirstFrame: undefined },
...options,
@@ -116,6 +119,7 @@ describe("background effects", () => {
beforeEach(() => {
effect$ = new BehaviorSubject("none");
setEffect = vi.fn((raw: string) => effect$.next(raw));
fake = fakePipeline();
});
@@ -169,4 +173,11 @@ describe("background effects", () => {
{ mode: "disabled" },
]);
});
it("clears a remembered effect when it cannot be honoured", () => {
effect$.next(`image:${shippedBackgrounds[0].id}`);
const effects = build({ supported: false });
expect(effect$.value).toBe("none");
expect(effects.state$.value.processor).toBeUndefined();
});
});
+15 -1
View File
@@ -27,6 +27,7 @@ import { type Behavior } from "./Behavior";
import { type ObservableScope } from "./ObservableScope";
import {
type BackgroundEffect,
type EffectId,
imagePathFor,
parseEffect,
} from "../tracks/backgroundEffectCatalog";
@@ -45,6 +46,8 @@ export interface BackgroundEffectsOptions {
supported: boolean;
/** The effect chosen, as the setting stores it. */
effect$: Behavior<string>;
/** Stores a choice, to forget one that can't be honoured. */
setEffect: (id: EffectId) => void;
/** The background processor pipeline to be switched between effects. */
pipeline: BackgroundProcessorWrapper;
/** Tells, once, that a frame carrying an effect has been drawn. */
@@ -59,7 +62,13 @@ export interface BackgroundEffects {
/** Switches the pipeline as the choice changes, for as long as the scope lasts. */
export function createBackgroundEffects(
scope: ObservableScope,
{ supported, effect$, pipeline, transformer }: BackgroundEffectsOptions,
{
supported,
effect$,
setEffect,
pipeline,
transformer,
}: BackgroundEffectsOptions,
): BackgroundEffects {
// Wrapped once, so the call and the preview see one processor throughout.
const processor = fromLivekitProcessor(pipeline);
@@ -69,6 +78,11 @@ export function createBackgroundEffects(
distinctUntilChanged(),
);
choice$.pipe(scope.bind()).subscribe((effect) => {
if (effect.kind === "none") return;
if (!supported) setEffect("none");
});
const drewAFrame$ = scope.behavior(
new Observable<boolean>((subscriber) => {
subscriber.next(false);
+3 -1
View File
@@ -21,6 +21,7 @@ import {
import { backgroundEffect } from "../settings/settings";
import { flushPromises, testScope } from "../utils/test";
const sdkSupportMock = vi.hoisted(() => vi.fn(() => true));
const pipelines = vi.hoisted(() => ({
built: 0,
destroyed: 0,
@@ -41,7 +42,7 @@ vi.mock("@livekit/track-processors", () => ({
return Promise.resolve();
});
}),
supportsBackgroundProcessors: (): boolean => true,
supportsBackgroundProcessors: (): boolean => sdkSupportMock(),
}));
const platformMock = vi.hoisted(() => ({ platform: "desktop" }));
vi.mock("../Platform", () => platformMock);
@@ -77,6 +78,7 @@ describe("BackgroundEffectsProvider", () => {
pipelines.destroyed = 0;
pipelines.switches = [];
platformMock.platform = "desktop";
sdkSupportMock.mockReturnValue(true);
backgroundEffect.setValue("none");
});
afterEach(() => backgroundEffect.setValue("none"));
+1
View File
@@ -53,6 +53,7 @@ export function createAppBackgroundEffects(
return createBackgroundEffects(scope, {
supported: supportsBackgroundProcessors(),
effect$: backgroundEffectSetting.value$,
setEffect: backgroundEffectSetting.setValue,
pipeline: new BackgroundProcessorWrapper(transformer, "background-effect"),
transformer,
});