mirror of
https://github.com/element-hq/element-call.git
synced 2026-10-10 13:57:18 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -28,6 +28,7 @@ import { backgroundEffect } from "../settings/settings";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { flushPromises, testScope } from "../utils/test";
|
||||
|
||||
const sdkSupportMock = vi.hoisted(() => vi.fn(() => true));
|
||||
const pipelines = vi.hoisted(() => ({
|
||||
built: 0,
|
||||
destroyed: 0,
|
||||
@@ -48,7 +49,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);
|
||||
@@ -181,6 +182,7 @@ describe("BackgroundEffectsProvider", () => {
|
||||
pipelines.destroyed = 0;
|
||||
pipelines.switches = [];
|
||||
platformMock.platform = "desktop";
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
backgroundEffect.setValue("none");
|
||||
});
|
||||
afterEach(() => backgroundEffect.setValue("none"));
|
||||
@@ -199,6 +201,28 @@ describe("BackgroundEffectsProvider", () => {
|
||||
expect(track.stopProcessor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("camera on with an effect chosen publishes nothing unprocessed", async () => {
|
||||
const view = render(surfaces(cameraTrack()));
|
||||
await blur(true);
|
||||
const pipeline = latest().processor;
|
||||
|
||||
// Camera off, then on again: a new track, given the pipeline as it
|
||||
// arrives, and the pipeline neither rebuilt nor switched again.
|
||||
await act(async () => {
|
||||
view.rerender(surfaces(null));
|
||||
await flushPromises();
|
||||
});
|
||||
const again = cameraTrack();
|
||||
await act(async () => {
|
||||
view.rerender(surfaces(again));
|
||||
await flushPromises();
|
||||
});
|
||||
expect(again.setProcessor).toHaveBeenCalledTimes(1);
|
||||
expect(again.setProcessor).toHaveBeenCalledWith(pipeline);
|
||||
expect(pipelines.built).toBe(1);
|
||||
expect(pipelines.switches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("runs on a phone whose browser can run it", async () => {
|
||||
platformMock.platform = "ios";
|
||||
const track = cameraTrack();
|
||||
|
||||
@@ -123,6 +123,7 @@ export function createAppBackgroundEffects(
|
||||
return createBackgroundEffects(scope, {
|
||||
supported: supportsBackgroundProcessors(),
|
||||
effect$: backgroundEffectSetting.value$,
|
||||
setEffect: backgroundEffectSetting.setValue,
|
||||
pipeline: new OneStepPipeline(transformer, "background-effect"),
|
||||
transformer,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import { type BackgroundEffectsState } from "../livekit/BackgroundEffectsContext
|
||||
import {
|
||||
type BackgroundEffect,
|
||||
blurRadius,
|
||||
type EffectId,
|
||||
imagePathFor,
|
||||
parseEffect,
|
||||
} from "../livekit/backgroundEffects";
|
||||
@@ -34,6 +35,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. */
|
||||
@@ -48,7 +51,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 {
|
||||
const choice$ = effect$.pipe(map(parseEffect));
|
||||
const wanted$ = choice$.pipe(
|
||||
@@ -56,6 +65,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);
|
||||
|
||||
Reference in New Issue
Block a user