diff --git a/playwright/background-effects.spec.ts b/playwright/background-effects.spec.ts index c77ea5faf..3947466ae 100644 --- a/playwright/background-effects.spec.ts +++ b/playwright/background-effects.spec.ts @@ -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. diff --git a/src/state/BackgroundEffects.test.ts b/src/state/BackgroundEffects.test.ts index 74856f82e..375a22384 100644 --- a/src/state/BackgroundEffects.test.ts +++ b/src/state/BackgroundEffects.test.ts @@ -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; + let setEffect: (raw: string) => void; let fake: ReturnType; 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(); + }); }); diff --git a/src/state/BackgroundEffects.ts b/src/state/BackgroundEffects.ts index a00cf3432..ecfd8b25a 100644 --- a/src/state/BackgroundEffects.ts +++ b/src/state/BackgroundEffects.ts @@ -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; + /** 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((subscriber) => { subscriber.next(false); diff --git a/src/tracks/BackgroundEffectsContext.test.ts b/src/tracks/BackgroundEffectsContext.test.ts index 71d9d7152..449bac4e5 100644 --- a/src/tracks/BackgroundEffectsContext.test.ts +++ b/src/tracks/BackgroundEffectsContext.test.ts @@ -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")); diff --git a/src/tracks/BackgroundEffectsContext.tsx b/src/tracks/BackgroundEffectsContext.tsx index d582bc0b3..d5a00d3bb 100644 --- a/src/tracks/BackgroundEffectsContext.tsx +++ b/src/tracks/BackgroundEffectsContext.tsx @@ -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, });