Compare commits

..
Author SHA1 Message Date
Timo K. e2e504d307 Data channel lk support 2026-11-06 10:07:00 +01:00
Timo K. de16cb4e37 refactor media sdk api 2026-11-05 16:57:39 +01:00
63 changed files with 1593 additions and 2807 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ services:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:e063472b6bdf6d0bfa1d43dfa4ef7effee1efd2c9684daee665d1bedfe746a47
image: ghcr.io/element-hq/element-web:develop@sha256:a79801206c628a912d57fa6f355d5c5ab9c6c40c2d1b6208d4192678fb6e80c7
element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:e063472b6bdf6d0bfa1d43dfa4ef7effee1efd2c9684daee665d1bedfe746a47
image: ghcr.io/element-hq/element-web:develop@sha256:a79801206c628a912d57fa6f355d5c5ab9c6c40c2d1b6208d4192678fb6e80c7
-5
View File
@@ -3,7 +3,6 @@
"user_menu": "User menu"
},
"action": {
"add_background_image": "Add image",
"close": "Close",
"copy_link": "Copy link",
"edit": "Edit",
@@ -99,10 +98,6 @@
},
"disconnected_banner": "Connectivity to the server has been lost.",
"error": {
"background_animated": "Animated images cannot be used as a background",
"background_not_an_image": "That file is not a supported image",
"background_not_kept": "That background could not be saved",
"background_undecodable": "That image could not be opened",
"call_is_not_supported": "Call is not supported",
"call_not_found": "Call not found",
"call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <2>create a new one</2>.</0>",
-1
View File
@@ -97,7 +97,6 @@
"copy-to-clipboard": "^3.3.3",
"eslint-plugin-element-call": "link:eslint",
"eslint-plugin-storybook": "^10.3.6",
"fake-indexeddb": "^6.2.5",
"fetch-mock": "11.1.5",
"i18next": "^26.0.0",
"i18next-browser-languagedetector": "^8.0.0",
+23 -749
View File
@@ -5,15 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test, type Browser, type Page } from "@playwright/test";
import { expect, test, type Locator, type Page } from "@playwright/test";
import { SpaHelpers } from "./spa-helpers.ts";
import {
averageColour,
cameraColour,
type Colour,
distance,
} from "./utils/colour.ts";
// Background effects need WebGL2, and headless Firefox on a CI runner has none,
// so it rightly offers none of them.
@@ -22,13 +16,15 @@ test.skip(
"Background effects need WebGL2, which headless Firefox on CI does not have",
);
type Colour = [number, number, number];
test.describe("background effects", () => {
test("pre-join preview shows the chosen effect", async ({ page }) => {
test.slow();
await page.goto("/");
await SpaHelpers.createCall(page, "Effect user", "Background effects");
const preview = page.locator("video").first();
const camera = await cameraColour(preview);
const camera = await averageColour(preview);
await page.getByRole("button", { name: "Camera", exact: true }).click();
const tile = page
@@ -106,747 +102,25 @@ async function drawsPerSecond(page: Page): Promise<number> {
});
}
test.describe("background effects section", () => {
test("section stays within the call area at every size", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Sizes user", "Background effect sizes");
for (const size of [
{ width: 1280, height: 800 },
{ width: 800, height: 600 },
{ width: 600, height: 420 },
{ width: 480, height: 360 },
{ width: 360, height: 640 },
]) {
await page.setViewportSize(size);
await page.getByRole("button", { name: "Camera", exact: true }).click();
const menu = page.getByRole("menu");
await expect(menu).toBeVisible();
const box = (await menu.boundingBox())!;
const where = `at ${size.width}x${size.height}`;
expect(box.x, where).toBeGreaterThanOrEqual(0);
expect(box.y, where).toBeGreaterThanOrEqual(0);
expect(box.x + box.width, where).toBeLessThanOrEqual(size.width);
expect(box.y + box.height, where).toBeLessThanOrEqual(size.height);
const last = page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio")
.last();
await last.scrollIntoViewIfNeeded();
await expect(last, where).toBeInViewport({ ratio: 1 });
await last.click();
await expect(last, where).toHaveAttribute("aria-checked", "true");
// Back to no effect for the next size, and close.
await page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "None" })
.click();
await page.keyboard.press("Escape");
await expect(menu).toBeHidden();
}
});
});
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("a background of one's own", () => {
test("adds an image from the device", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Adding user", "Own background");
const preview = page.locator("video").first();
const camera = await cameraColour(preview);
const red = await redImage(page);
await addImage(page, red);
const mine = page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "Background 3" });
await expect(mine).toBeVisible();
await expect(mine).toHaveAttribute("aria-checked", "false");
await mine.click();
await expect
.poll(async () => distance(await averageColour(preview), RED), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, RED) / 2);
});
test("adding an image makes no upload", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Private user", "Own background kept");
const preview = page.locator("video").first();
const camera = await cameraColour(preview);
const red = await redImage(page);
const sent: { request: string; body: Buffer | null }[] = [];
page.on("request", (request) =>
sent.push({
request: `${request.method()} ${request.url()}`,
body: request.postDataBuffer(),
}),
);
await addImage(page, red);
await page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "Background 3" })
.click();
// Kept, chosen and in force: anything that would send it has had its turn.
await expect
.poll(async () => distance(await averageColour(preview), RED), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, RED) / 2);
// The file as given, and the copy kept, which is encoded anew.
const samples = [red, await keptBytes(page)].map((bytes) =>
bytes.subarray(bytes.length - 64),
);
const carriesIt = sent.filter(
({ request, body }) =>
request.includes("/_matrix/media/") ||
samples.some((sample) => body?.includes(sample)),
);
expect(carriesIt.map(({ request }) => request)).toEqual([]);
});
test("added image survives rejoining and is gone after storage is cleared", async ({
page,
}) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Keeping user", "Own background again");
const preview = page.locator("video").first();
const camera = await cameraColour(preview);
const red = await redImage(page);
await addImage(page, red);
const mine = page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "Background 3" });
await mine.click();
await page.keyboard.press("Escape");
await page.reload();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
await page.getByRole("button", { name: "Camera", exact: true }).click();
await expect(mine).toHaveAttribute("aria-checked", "true");
await page.keyboard.press("Escape");
await expect
.poll(async () => distance(await averageColour(preview), RED), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, RED) / 2);
await page.evaluate(
async () =>
new Promise<void>((resolve, reject) => {
const request = indexedDB.deleteDatabase(
"element-call-background-images",
);
request.onsuccess = (): void => resolve();
request.onerror = (): void => reject(request.error);
request.onblocked = (): void => resolve();
}),
);
await page.reload();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
await page.getByRole("button", { name: "Camera", exact: true }).click();
await expect(
page.getByRole("group", { name: "Background effects" }),
).toBeVisible();
await expect(mine).toHaveCount(0);
await page.keyboard.press("Escape");
await addImage(page, red);
await expect(mine).toBeVisible();
});
test("a removed image stays removed", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(
page,
"Removing user",
"Own background removed",
);
await addImage(page, await redImage(page));
const mine = page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "Background 3" });
await expect(mine).toHaveAttribute("aria-keyshortcuts", "Delete");
await mine.focus();
await page.keyboard.press("Delete");
await expect(mine).toHaveCount(0);
await page.reload();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
await page.getByRole("button", { name: "Camera", exact: true }).click();
await expect(
page.getByRole("group", { name: "Background effects" }),
).toBeVisible();
await expect(mine).toHaveCount(0);
expect(await keptImages(page)).toEqual([]);
});
});
test.describe("what can be added", () => {
test("refuses an animated image", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Refused user", "Animated refused");
await addImage(page, ANIMATED_GIF, {
name: "moving.gif",
type: "image/gif",
});
await expect(
page.getByText("Animated images cannot be used as a background"),
).toBeVisible();
const section = page.getByRole("group", { name: "Background effects" });
await expect(
section.getByRole("menuitemradio", { name: "Background 3" }),
).toHaveCount(0);
await expect(
section.getByRole("menuitemradio", { name: "None" }),
).toHaveAttribute("aria-checked", "true");
expect(await keptImages(page)).toEqual([]);
});
test("reduces an oversized image", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Large user", "Oversized reduced");
await addImage(page, await pngImage(page, { width: 4000, height: 3000 }));
await expect(
page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "Background 3" }),
).toBeVisible();
await expect(page.getByRole("alert")).toHaveCount(0);
const [kept] = await keptImages(page);
expect(kept).toMatchObject({ width: 1920, height: 1440 });
});
test("lays every image on an opaque ground", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Logo user", "Transparent made opaque");
await addImage(
page,
await pngImage(page, { width: 320, height: 180, transparent: true }),
);
await expect(
page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "Background 3" }),
).toBeVisible();
const [kept] = await keptImages(page);
expect(kept.minAlpha).toBe(255);
});
});
/** Two frames of one pixel, red then blue. */
const ANIMATED_GIF = Buffer.from(
"R0lGODlhAQABAPEAAP8AAAAA/wAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQACgAAACwAAAAAAQABAAACAkQBACH5BAAKAAAALAAAAAABAAEAAAICTAEAOw==",
"base64",
);
/** A red PNG of the given size; transparent leaves its right half empty. */
async function pngImage(
page: Page,
size: { width: number; height: number; transparent?: boolean },
): Promise<Buffer> {
const dataUrl = await page.evaluate(({ width, height, transparent }) => {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d")!;
context.fillStyle = "rgb(255, 0, 0)";
context.fillRect(0, 0, transparent ? width / 2 : width, height);
return canvas.toDataURL("image/png");
}, size);
return Buffer.from(dataUrl.split(",")[1], "base64");
}
/** The images the device keeps, as it keeps them. */
async function keptImages(
page: Page,
): Promise<{ width: number; height: number; minAlpha: number }[]> {
return page.evaluate(async () => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("element-call-background-images");
request.onsuccess = (): void => resolve(request.result);
request.onerror = (): void => reject(request.error);
});
if (!db.objectStoreNames.contains("backgrounds")) return [];
const kept = await new Promise<{ image: Blob }[]>((resolve, reject) => {
const request = db
.transaction("backgrounds")
.objectStore("backgrounds")
.getAll();
request.onsuccess = (): void => resolve(request.result);
request.onerror = (): void => reject(request.error);
});
db.close();
return Promise.all(
kept.map(async ({ image }) => {
const bitmap = await createImageBitmap(image);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext("2d")!;
context.drawImage(bitmap, 0, 0);
const { data } = context.getImageData(
0,
0,
bitmap.width,
bitmap.height,
);
let minAlpha = 255;
for (let i = 3; i < data.length; i += 4)
minAlpha = Math.min(minAlpha, data[i]);
return { width: bitmap.width, height: bitmap.height, minAlpha };
}),
);
});
}
/** The bytes of the one image the device keeps. */
async function keptBytes(page: Page): Promise<Buffer> {
const base64 = await page.evaluate(
async () =>
new Promise<string>((resolve, reject) => {
const open = indexedDB.open("element-call-background-images");
open.onerror = (): void => reject(open.error);
open.onsuccess = (): void => {
const all = open.result
.transaction("backgrounds")
.objectStore("backgrounds")
.getAll();
all.onerror = (): void => reject(all.error);
all.onsuccess = (): void => {
const reader = new FileReader();
reader.onload = (): void =>
resolve((reader.result as string).split(",")[1]);
reader.readAsDataURL((all.result[0] as { image: Blob }).image);
};
};
}),
);
return Buffer.from(base64, "base64");
}
const RED: Colour = [255, 0, 0];
/** A solid red PNG, unlike the camera and the shipped pictures. */
async function redImage(page: Page): Promise<Buffer> {
const dataUrl = await page.evaluate(() => {
const canvas = document.createElement("canvas");
canvas.width = 320;
canvas.height = 180;
const context = canvas.getContext("2d")!;
context.fillStyle = "rgb(255, 0, 0)";
context.fillRect(0, 0, canvas.width, canvas.height);
return canvas.toDataURL("image/png");
});
return Buffer.from(dataUrl.split(",")[1], "base64");
}
/** Adds an image from the camera menu's add tile, leaving the menu open. */
async function addImage(
page: Page,
image: Buffer,
{ name, type } = { name: "mine.png", type: "image/png" },
): Promise<void> {
if (!(await page.getByRole("menu").isVisible()))
await page.getByRole("button", { name: "Camera", exact: true }).click();
const chooser = page.waitForEvent("filechooser");
await page.getByRole("menuitem", { name: "Add image" }).click();
await (await chooser).setFiles({ name, mimeType: type, buffer: image });
}
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.
test.setTimeout(180_000);
const hostContext = await browser.newContext();
const host = await hostContext.newPage();
await recordIncomingFrames(host);
await host.goto("/");
await SpaHelpers.createCall(host, "Host", "Join with a background", true);
await expect(host.getByTestId("incall_videomute")).toBeEnabled({
timeout: 10_000,
});
const inviteLink = await SpaHelpers.getCallInviteLink(host);
await host.keyboard.press("Escape");
const guestContext = await browser.newContext();
const guest = await guestContext.newPage();
await guest.goto(inviteLink);
await guest.getByTestId("joincall_displayName").fill("Guest");
await guest.getByTestId("joincall_joincall").click();
const preview = guest.locator("video").first();
await expect(preview).toBeVisible();
const camera = await cameraColour(preview);
await guest.getByRole("button", { name: "Camera", exact: true }).click();
const tile = guest.getByRole("menuitemradio", { name: "Background 1" });
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);
// Were the pipeline built again for the call's track, it would take as
// long as on the slower route. Routing bypasses the cache, so it waits.
let heldBack = 0;
await guest.route("**/*.tflite*", async (route) => {
heldBack += 1;
await new Promise((resolve) => setTimeout(resolve, 15_000));
await route.continue();
});
await guest.keyboard.press("Escape");
await guest.getByTestId("lobby_joinCall").click();
const guestTile = host
.getByTestId("videoTile")
.filter({ has: host.getByTestId("name_tag").getByText("Guest") });
await expect(guestTile.locator("video")).toBeVisible({ timeout: 60_000 });
// Visible can come before the stream is attached.
let trackId: string | undefined;
await expect
.poll(async () => {
trackId = await guestTile
.locator("video")
.evaluate(
(video: HTMLVideoElement) =>
(video.srcObject as MediaStream | null)?.getVideoTracks()[0]?.id,
);
return trackId;
})
.toBeDefined();
await expectNoFrameOfTheRoom(host, trackId!, 0, camera, picture);
// The join adopts the lobby's pipeline, so the model is never fetched again.
expect(heldBack).toBe(0);
await hostContext.close();
await guestContext.close();
});
});
test.describe("the first build", () => {
test("survives the camera turned off and on while it runs", async ({
page,
}) => {
test.setTimeout(120_000);
const failures: string[] = [];
page.on("console", (m) => {
if (m.text().includes("Failed to attach video processor"))
failures.push(m.text());
});
await page.goto("/");
await SpaHelpers.createCall(page, "Toggling user", "Toggle while building");
const preview = page.locator("video").first();
const camera = await cameraColour(preview);
const held = await holdBackTheModel(page);
await page.getByRole("button", { name: "Camera", exact: true }).click();
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
await tile.click();
const picture = await averageColour(tile.locator("img"));
await page.keyboard.press("Escape");
// New preview tracks, each attached while the first is still building.
for (let i = 0; i < 2; i++) {
await page.getByTestId("incall_videomute").click();
await page.getByTestId("incall_videomute").click();
}
await expect
.poll(async () => distance(await averageColour(preview), picture), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, picture) / 2);
expect(failures).toEqual([]);
expect(held()).toBeGreaterThan(0);
});
});
test.describe("turning the camera on with a background chosen", () => {
test("publishes no frame of the room when the camera started off", async ({
browser,
}) => {
test.setTimeout(120_000);
const { host, guest, close } = await hostAndGuest(browser);
const camera = await cameraColour(guest.locator("video").first());
await guest.getByTestId("incall_videomute").click();
await guest.getByTestId("lobby_joinCall").click();
const picture = await chooseBackground(guest);
const held = await holdBackTheModel(guest);
await guest.getByTestId("incall_videomute").click();
const trackId = await incomingTrackId(host);
await expectNoFrameOfTheRoom(host, trackId, 0, camera, picture);
expect(held()).toBeGreaterThan(0);
await close();
});
test("publishes no frame of the room when the camera was turned off", async ({
browser,
}) => {
test.setTimeout(120_000);
const { host, guest, close } = await hostAndGuest(browser);
const camera = await cameraColour(guest.locator("video").first());
await guest.getByTestId("lobby_joinCall").click();
const trackId = await incomingTrackId(host);
await expect
.poll(async () => (await framesOf(host, trackId)).length, {
timeout: 20_000,
})
.toBeGreaterThan(0);
await guest.getByTestId("incall_videomute").click();
const picture = await chooseBackground(guest);
const held = await holdBackTheModel(guest);
const before = (await framesOf(host, trackId)).length;
await guest.getByTestId("incall_videomute").click();
await expectNoFrameOfTheRoom(host, trackId, before, camera, picture);
expect(held()).toBeGreaterThan(0);
await close();
});
});
/** A host in a call, recording what it receives, and a guest in its lobby. */
async function hostAndGuest(browser: Browser): Promise<{
host: Page;
guest: Page;
close: () => Promise<void>;
}> {
const hostContext = await browser.newContext();
const host = await hostContext.newPage();
await recordIncomingFrames(host);
await host.goto("/");
await SpaHelpers.createCall(host, "Host", "Camera with a background", true);
await expect(host.getByTestId("incall_videomute")).toBeEnabled({
timeout: 10_000,
});
const inviteLink = await SpaHelpers.getCallInviteLink(host);
await host.keyboard.press("Escape");
const guestContext = await browser.newContext();
const guest = await guestContext.newPage();
await guest.goto(inviteLink);
await guest.getByTestId("joincall_displayName").fill("Guest");
await guest.getByTestId("joincall_joincall").click();
await expect(guest.locator("video").first()).toBeVisible();
return {
host,
guest,
close: async (): Promise<void> => {
await hostContext.close();
await guestContext.close();
},
};
}
/** Chooses the first shipped picture in the call, and answers its colour. */
async function chooseBackground(guest: Page): Promise<Colour> {
// The lobby has a camera button of its own, so wait for the call's.
await expect(guest.getByTestId("incall_leave")).toBeVisible({
timeout: 30_000,
});
await expect(guest.getByTestId("incall_videomute")).toBeEnabled({
timeout: 10_000,
});
await guest.getByRole("button", { name: "Camera", exact: true }).click();
const tile = guest.getByRole("menuitemradio", { name: "Background 1" });
await tile.click();
await expect(tile).toHaveAttribute("aria-checked", "true");
const picture = await averageColour(tile.locator("img"));
await guest.keyboard.press("Escape");
return picture;
}
/** Makes the pipeline's first build as slow as on the slower route. */
/** Answers how often it held the model back, which a check should see. */
async function holdBackTheModel(page: Page): Promise<() => number> {
let held = 0;
// Routing bypasses the cache, so it waits.
await page.route("**/*.tflite*", async (route) => {
held += 1;
await new Promise((resolve) => setTimeout(resolve, 5_000));
await route.continue();
});
return () => held;
}
/** The id of the guest's camera track, as the host receives it. */
async function incomingTrackId(host: Page): Promise<string> {
const guestTile = host
.getByTestId("videoTile")
.filter({ has: host.getByTestId("name_tag").getByText("Guest") });
await expect(guestTile.locator("video")).toBeVisible({ timeout: 60_000 });
// Visible can come before the stream is attached.
let trackId: string | undefined;
await expect
.poll(async () => {
trackId = await guestTile
.locator("video")
.evaluate(
(video: HTMLVideoElement) =>
(video.srcObject as MediaStream | null)?.getVideoTracks()[0]?.id,
);
return trackId;
})
.toBeDefined();
return trackId!;
}
/** Waits for the picture to arrive, then finds no frame of the room since. */
async function expectNoFrameOfTheRoom(
host: Page,
trackId: string,
since: number,
camera: Colour,
picture: Colour,
): Promise<void> {
await expect
.poll(
async () => {
const frames = (await framesOf(host, trackId)).slice(since);
// A black frame, as a track starts, is nearer the picture than the
// camera, but shows neither.
return frames.some(
(f) =>
distance(f, picture) <
Math.min(distance(f, camera), distance(f, [0, 0, 0])),
);
},
{ timeout: 60_000 },
)
.toBe(true);
const frames = (await framesOf(host, trackId)).slice(since);
expect(
frames.filter((f) => distance(f, camera) < distance(f, picture)),
).toEqual([]);
}
/**
* Records the mean colour of every frame of every video track the page
* receives, from the moment it arrives: a tile may attach it later than its
* first frame.
*/
async function recordIncomingFrames(page: Page): Promise<void> {
await page.addInitScript(() => {
const frames: Record<string, [number, number, number][]> = {};
(window as unknown as { receivedFrames: typeof frames }).receivedFrames =
frames;
const record = (track: MediaStreamTrack): void => {
if (track.kind !== "video" || frames[track.id]) return;
const seen: [number, number, number][] = (frames[track.id] = []);
const video = document.createElement("video");
video.muted = true;
video.srcObject = new MediaStream([track]);
/** The mean colour of a video's current frame or an image, drawn small. */
async function averageColour(element: Locator): Promise<Colour> {
return element.evaluate(
async (source: HTMLVideoElement | HTMLImageElement) => {
if (source instanceof HTMLImageElement) await source.decode();
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 16;
const context = canvas.getContext("2d", { willReadFrequently: true })!;
const step = (): void => {
context.drawImage(video, 0, 0, 16, 16);
const { data } = context.getImageData(0, 0, 16, 16);
const sum = [0, 0, 0];
for (let i = 0; i < data.length; i += 4)
for (let c = 0; c < 3; c++) sum[c] += data[i + c];
seen.push(sum.map((v) => v / (data.length / 4)) as Colour);
video.requestVideoFrameCallback(step);
};
video.requestVideoFrameCallback(step);
void video.play();
};
const Connection = window.RTCPeerConnection;
window.RTCPeerConnection = class extends Connection {
public constructor(...args: ConstructorParameters<typeof Connection>) {
super(...args);
this.addEventListener("track", (e) => record(e.track));
}
};
});
}
async function framesOf(page: Page, trackId: string): Promise<Colour[]> {
return page.evaluate(
(id) =>
(
window as unknown as {
receivedFrames: Record<string, [number, number, number][]>;
}
).receivedFrames[id] ?? [],
trackId,
canvas.width = canvas.height = 32;
const context = canvas.getContext("2d")!;
context.drawImage(source, 0, 0, 32, 32);
const { data } = context.getImageData(0, 0, 32, 32);
const sum = [0, 0, 0];
for (let i = 0; i < data.length; i += 4)
for (let c = 0; c < 3; c++) sum[c] += data[i + c];
const pixels = data.length / 4;
return sum.map((s) => s / pixels) as Colour;
},
);
}
function distance(a: Colour, b: Colour): number {
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
}
@@ -1,85 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, type Locator, type Page, test } from "@playwright/test";
import { createUserAndRoom, resizeContainer, startHarness } from "./harness.ts";
import { averageColour, cameraColour, expectWearing } from "../utils/colour.ts";
// Background effects need WebGL2, and headless Firefox on a CI runner has none,
// so it rightly offers none of them.
test.skip(
({ browserName }) => browserName === "firefox",
"Background effects need WebGL2, which headless Firefox on CI does not have",
);
/**
* Background effects with Element Call as a component in a host page: the
* pipeline's model and WebAssembly have to load from the component's bundle,
* and the portalled menu has to be sized against the call, not the window.
* Driven from the lobby, which builds the same menu and pipeline.
*/
// Sign-in, crypto setup and sync happen twice before anything shows.
test.describe.configure({ timeout: 180_000 });
test("puts a picture on the call's preview", async ({ page }) => {
const { username, roomId } = await createUserAndRoom("effects");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
await resizeContainer(pane.getByTestId("call-container"), {
width: 900,
height: 480,
});
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000,
});
const preview = pane.locator("video").first();
const camera = await cameraColour(preview);
const tile = await openEffect(page, pane, "Background 1");
const picture = await averageColour(tile.locator("img"));
await tile.click();
await expect(tile).toHaveAttribute("aria-checked", "true");
await expectWearing(preview, picture, camera);
});
test("keeps every effect reachable in a small container", async ({ page }) => {
const { username, roomId } = await createUserAndRoom("effectsreach");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
const container = pane.getByTestId("call-container");
await resizeContainer(container, { width: 400, height: 360 });
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000,
});
const last = await openEffect(page, pane, "Background 2");
const list = page.locator("[role='menu'] div[role='none']").first();
expect((await list.boundingBox())!.height).toBeLessThanOrEqual(
(await container.boundingBox())!.height,
);
await last.scrollIntoViewIfNeeded();
await expect(last).toBeInViewport({ ratio: 1 });
await last.click();
await expect(last).toHaveAttribute("aria-checked", "true");
});
/** Opens a component's camera menu and returns one of its effects. */
async function openEffect(
page: Page,
pane: Locator,
name: string,
): Promise<Locator> {
await pane
.getByRole("button", { name: "Camera", exact: true })
.click({ timeout: 60_000 });
// Portalled out of the pane, so found on the page.
return page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name });
}
+26 -3
View File
@@ -109,7 +109,15 @@ export const widgetTest = test.extend<MyFixtures>({
await TestHelpers.dismissInviteUnknownUserModal(ewPage1);
// Accept the invite
await TestHelpers.openRoomInvite(ewPage2, "Welcome Room");
await TestHelpers.closeReleaseAnnouncement(
ewPage2,
"Introducing Sections",
);
await TestHelpers.expandAllSections(ewPage2);
await expect(
TestHelpers.roomListItem(ewPage2, "Welcome Room"),
).toBeVisible();
await TestHelpers.roomListItem(ewPage2, "Welcome Room").click();
await ewPage2.getByRole("button", { name: "Accept" }).click();
await expect(
ewPage2
@@ -117,7 +125,17 @@ export const widgetTest = test.extend<MyFixtures>({
.getByRole("heading", { name: "Welcome Room" }),
).toBeVisible();
} else if (callType === "dm") {
await TestHelpers.chooseFromRoomListNewMenu(ewPage1, "Start chat");
await TestHelpers.closeReleaseAnnouncement(
ewPage1,
"Introducing Sections",
);
await ewPage1
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New", exact: true })
.click();
await ewPage1.getByRole("menuitem", { name: "Start chat" }).click();
await ewPage1.getByRole("textbox", { name: "Search" }).click();
await ewPage1.getByRole("textbox", { name: "Search" }).fill(whistlerMxId);
await ewPage1.getByRole("button", { name: "Go" }).click();
@@ -139,7 +157,12 @@ export const widgetTest = test.extend<MyFixtures>({
).toBeVisible();
// Accept the DM invite from brooks
await TestHelpers.openRoomInvite(ewPage2, brooksDisplayName);
await TestHelpers.closeReleaseAnnouncement(
ewPage2,
"Introducing Sections",
);
await TestHelpers.expandAllSections(ewPage2);
await TestHelpers.roomListItem(ewPage2, brooksDisplayName).click();
await ewPage2.getByRole("button", { name: "Start chatting" }).click();
}
+13 -4
View File
@@ -11,18 +11,25 @@ import { startPair, tileOf } from "./harness";
test.describe.configure({ timeout: 300_000 });
test("a message sent beside the media reaches the peer, and only the peer", async ({
test("a message sent on a data track reaches the peer, and only the peer", async ({
browser,
}) => {
const { pages, users } = await startPair(browser, "sdkdata");
const [sender, receiver] = pages;
// Both are in the call before anything is sent, so the receiver knows the
// sender as a member and does not drop the message
// sender as a member
for (const page of pages)
await expect(page.getByTestId("member")).toHaveCount(2, {
timeout: 60_000,
});
await expect(tileOf(receiver, users[0])).toBeVisible();
// The receiver listens on the sender's track, published with the join
await expect(tileOf(receiver, users[0])).toHaveAttribute(
"data-chat",
"true",
{
timeout: 30_000,
},
);
await sender.getByPlaceholder("message").fill("hello from the SDK");
await sender.getByRole("button", { name: "Send" }).click();
@@ -34,7 +41,9 @@ test("a message sent beside the media reaches the peer, and only the peer", asyn
timeout: 30_000,
},
);
await expect(message).toHaveAttribute("data-topic", "chat");
await expect(message).toHaveAttribute("data-name", "chat");
// Decrypted with the sender's key, at the key index the stream carried
await expect(message).toHaveAttribute("data-encrypted", "true");
await expect(message).toHaveAttribute("data-user-id", users[0].userId);
// Our own messages are not echoed back
await expect(sender.getByTestId("message")).toHaveCount(0);
-55
View File
@@ -1,55 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, type Locator } from "@playwright/test";
export type Colour = [number, number, number];
/** The camera's colour, once the preview is showing it rather than nothing. */
export async function cameraColour(preview: Locator): Promise<Colour> {
await expect
.poll(async () => Math.max(...(await averageColour(preview))))
.toBeGreaterThan(40);
return averageColour(preview);
}
/** The mean colour of a video's current frame or an image, drawn small. */
export async function averageColour(element: Locator): Promise<Colour> {
return element.evaluate(
async (source: HTMLVideoElement | HTMLImageElement) => {
if (source instanceof HTMLImageElement) await source.decode();
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 32;
const context = canvas.getContext("2d")!;
context.drawImage(source, 0, 0, 32, 32);
const { data } = context.getImageData(0, 0, 32, 32);
const sum = [0, 0, 0];
for (let i = 0; i < data.length; i += 4)
for (let c = 0; c < 3; c++) sum[c] += data[i + c];
const pixels = data.length / 4;
return sum.map((s) => s / pixels) as Colour;
},
);
}
export function distance(a: Colour, b: Colour): number {
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
}
/** Waits until a preview shows a picture rather than the camera. */
export async function expectWearing(
preview: Locator,
picture: Colour,
camera: Colour,
): Promise<void> {
// Long enough for the first effect of a session to be built.
await expect
.poll(async () => distance(await averageColour(preview), picture), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, picture) / 2);
}
@@ -1,43 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "@playwright/test";
import { widgetTest } from "../fixtures/widget-user.ts";
import { TestHelpers } from "./test-helpers.ts";
import { averageColour, cameraColour, expectWearing } from "../utils/colour.ts";
// Background effects need WebGL2, and headless Firefox on a CI runner has none,
// so it rightly offers none of them.
test.skip(
({ browserName }) => browserName === "firefox",
"Background effects need WebGL2, which headless Firefox on CI does not have",
);
widgetTest(
"puts a picture on the preview inside Element Web",
async ({ asWidget }) => {
test.slow();
const { brooks } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
const frame = brooks.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("lobby_joinCall")).toBeVisible();
const preview = frame.locator("video").first();
const camera = await cameraColour(preview);
await frame.getByRole("button", { name: "Camera", exact: true }).click();
const tile = frame
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "Background 1" });
const picture = await averageColour(tile.locator("img"));
await tile.click();
await expect(tile).toHaveAttribute("aria-checked", "true");
await expectWearing(preview, picture, camera);
},
);
+19 -79
View File
@@ -157,34 +157,25 @@ export class TestHelpers {
// Toast can be stacked, and only the top one can be dismiss, so just look at what is on top and
// dismiss (if part of expected toats)
public static async dismissStartupToasts(page: Page): Promise<void> {
const expectedToasts: {
title: string;
button: string;
confirm?: string;
}[] = [
const expectedToasts = [
{ title: "Failed to load service worker", button: "OK" },
{ title: "Back up your chats", button: "Not now" },
{
title: "Turn on key storage",
button: "Dismiss",
confirm: "Yes, dismiss",
},
{ title: "Notifications", button: "Dismiss" },
{ title: "Turn on key storage", button: "Dismiss" },
{ title: "Element does not support this browser", button: "Dismiss" },
];
const toast = page.locator(".mx_Toast_toast");
const toastTitle = toast.locator(".mx_Toast_title h2");
// eslint-disable-next-line no-constant-condition
while (true) {
try {
await toast.waitFor({ state: "visible", timeout: 700 });
const title = await toastTitle.textContent();
if (!title) break;
const title = await toast.locator(".mx_Toast_title h2").textContent();
// Find the matching toast config
const toastConfig = expectedToasts.find((t) => title.includes(t.title));
const toastConfig = expectedToasts.find((t) =>
title?.includes(t.title),
);
if (toastConfig) {
// Bounded so that a renamed button leaves the toast in place and the
@@ -192,16 +183,6 @@ export class TestHelpers {
await toast
.getByRole("button", { name: toastConfig.button })
.click({ timeout: 5000 });
if (toastConfig.confirm) {
const dialog = page.getByRole("dialog");
await dialog
.getByRole("button", { name: toastConfig.confirm })
.click({ timeout: 5000 });
await expect(dialog).toBeHidden({ timeout: 5000 });
}
// The same element shows the next toast a moment after the click, so
// reading it straight away acts on the dismissed one a second time
await expect(toastTitle).not.toHaveText(title, { timeout: 5000 });
} else {
// Unknown toast. We don't want to act on unknown toasts
break;
@@ -213,16 +194,6 @@ export class TestHelpers {
}
}
/**
* Clears what Element Web stacks over the room list after sign-in. The
* release announcement is held back while any toast is up, so it is closed
* after the toasts, not before.
*/
public static async clearRoomListOverlays(page: Page): Promise<void> {
await TestHelpers.dismissStartupToasts(page);
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
}
public static async closeReleaseAnnouncement(
page: Page,
name: string,
@@ -252,7 +223,16 @@ export class TestHelpers {
page: Page,
andInvite: string[] = [],
): Promise<void> {
await TestHelpers.chooseFromRoomListNewMenu(page, "New Room");
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New", exact: true })
.click();
await page.getByRole("menuitem", { name: "New Room" }).click({
timeout: 5000,
});
await page.getByRole("textbox", { name: "Name" }).fill(name);
await page.getByRole("button", { name: "Create room" }).click();
await expect(page.getByText("You created this room.")).toBeVisible({
@@ -279,25 +259,6 @@ export class TestHelpers {
}
}
/**
* Opens the room list's "New" menu and picks the given item.
*
* While the sections announcement is anchored to the "New" button, the
* button has no menu at all, so the overlays go first. Driven by keyboard,
* as a toast arriving afterwards sits over the button and swallows clicks.
*/
public static async chooseFromRoomListNewMenu(
page: Page,
itemName: string,
): Promise<void> {
await TestHelpers.clearRoomListOverlays(page);
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New", exact: true })
.press("Enter");
await page.getByRole("menuitem", { name: itemName }).press("Enter");
}
/**
* Accepts a room invite using the room name.
* Locatest the invite in the room list.
@@ -307,7 +268,9 @@ export class TestHelpers {
roomName: string,
page: Page,
): Promise<void> {
await TestHelpers.openRoomInvite(page, roomName);
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
await TestHelpers.expandAllSections(page);
await TestHelpers.roomListItem(page, roomName).click({ timeout: 10000 });
await page.getByRole("button", { name: "Accept" }).click({
timeout: 5000,
});
@@ -413,29 +376,6 @@ export class TestHelpers {
return page.locator(`[aria-label^="Open room ${roomName}"]`);
}
/**
* Opens a pending invite from the room list by its room name.
*
* Element Web collapses the Invites section every time it appears, so it is
* expanded here once the invite has arrived.
*/
public static async openRoomInvite(
page: Page,
roomName: string,
): Promise<void> {
const invitesSection = page.getByRole("button", {
name: "Toggle Invites section",
});
await expect(invitesSection).toBeVisible({ timeout: 10000 });
// Toasts and the announcement sit over the top of the room list, where
// this header is
await TestHelpers.clearRoomListOverlays(page);
if ((await invitesSection.getAttribute("aria-expanded")) !== "true") {
await invitesSection.click();
}
await TestHelpers.roomListItem(page, roomName).click({ timeout: 10000 });
}
/**
* Switches to a room in the room list by its name.
* @param page - The EW page
-9
View File
@@ -160,9 +160,6 @@ importers:
eslint-plugin-storybook:
specifier: ^10.3.6
version: 10.6.2(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
fake-indexeddb:
specifier: ^6.2.5
version: 6.2.5
fetch-mock:
specifier: 11.1.5
version: 11.1.5(node-fetch@2.7.0)
@@ -4051,10 +4048,6 @@ packages:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
fake-indexeddb@6.2.5:
resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==}
engines: {node: '>=18'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -9719,8 +9712,6 @@ snapshots:
expect-type@1.4.0: {}
fake-indexeddb@6.2.5: {}
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
+54 -14
View File
@@ -16,11 +16,25 @@ Please see LICENSE in the repository root for full details.
*/
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { EMPTY, filter, map, type Observable, of, switchMap, take } from "rxjs";
import {
distinct,
distinctUntilChanged,
EMPTY,
filter,
map,
mergeMap,
type Observable,
of,
switchMap,
take,
takeUntil,
} from "rxjs";
import {
type Behavior,
createRTCSlot,
type DataTrack,
E2eeType,
type LocalDataTrack,
type LocalRTCMember,
ObservableScope,
type RemoteRTCMember,
@@ -45,7 +59,7 @@ import { getUrlParams } from "../src/UrlParams";
import { observeElementSize$ } from "../src/utils/elementSize";
import { initializeWidget } from "../src/widget";
/** The data channel topic this bundle has always used, so that old and new builds talk. */
/** The name of our data track: the topic this bundle has always used, so that old and new builds talk. */
const TOPIC = "matrixRTC";
export interface MatrixRTCSdk {
@@ -59,9 +73,9 @@ export interface MatrixRTCSdk {
localMember$: Behavior<LocalRTCMember | null>;
/** The other members of the slot, with the media the participation carries for them. */
remoteMembers$: Behavior<RemoteRTCMember[]>;
/** What other members sent with `sendData`. */
/** What other members sent with `sendData`, on their data track. */
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
/** Sends to every member on our transport. Rejects while not joined. */
/** Sends on our data track, published with the first message. Rejects while not joined. */
sendData: (data: unknown) => Promise<void>;
sendRoomMessage: (message: string) => Promise<void>;
}
@@ -100,6 +114,9 @@ export async function createMatrixRTCSdk(
id,
});
let vm: CallViewModel | undefined;
let dataTrack: Promise<LocalDataTrack> | undefined;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const failed =
(what: string) =>
(e: unknown): void =>
@@ -115,6 +132,8 @@ export async function createMatrixRTCSdk(
),
);
const remoteMembers$ = over((p) => p.remoteMembers$, []);
return {
join: (): void => {
if (slot.participation$.value !== null) return;
@@ -122,6 +141,7 @@ export async function createMatrixRTCSdk(
widget.api
.setAlwaysOnScreen(true)
.catch(failed("Failed to make the widget sticky"));
dataTrack = undefined;
const participation = slot.join({
...rtcParticipationOptions(urlParams),
publish: initialPublishRequests(muteStates, mediaDevices, capture),
@@ -169,20 +189,40 @@ export async function createMatrixRTCSdk(
false,
),
localMember$: over((p) => p.localMember$, null),
remoteMembers$: over((p) => p.remoteMembers$, []),
// Attested members only: the SDK has matched the sender
data$: slot.participation$.pipe(
switchMap((p) => p?.data$ ?? EMPTY),
filter(({ topic }) => topic === TOPIC),
map(({ member, text }) => ({
rtcBackendIdentity: member.rtcBackendIdentity,
data: text,
})),
remoteMembers$,
data$: remoteMembers$.pipe(
// Each member once, for as long as it stays: a data track is only
// subscribed while observed, so it is not dropped on every list change
mergeMap((members) => members),
distinct(),
mergeMap((member) =>
member.tracks$.pipe(
map((tracks) =>
tracks?.find(
(t): t is DataTrack => t.kind === "data" && t.name === TOPIC,
),
),
distinctUntilChanged(),
switchMap((track) => track?.data$ ?? EMPTY),
map((payload) => ({
rtcBackendIdentity: member.rtcBackendIdentity,
data: decoder.decode(payload),
})),
takeUntil(
remoteMembers$.pipe(filter((members) => !members.includes(member))),
),
),
),
),
sendData: async (data): Promise<void> => {
const participation = slot.participation$.value;
if (participation === null) throw new Error("Not joined");
await participation.sendData(TOPIC, JSON.stringify(data));
dataTrack ??= participation.publish({ source: "data", name: TOPIC });
const track = await dataTrack.catch((e: unknown) => {
dataTrack = undefined;
throw e;
});
await track.send(encoder.encode(JSON.stringify(data)));
},
sendRoomMessage: async (message): Promise<void> => {
await client.sendTextMessage(room.roomId, message);
+93 -41
View File
@@ -25,7 +25,7 @@ Call's `src/` to get there is listed in [`SdkMigration.md`](./SdkMigration.md).
│ │
│ public API, all Behaviors: │
│ join() leave() state$ │
│ localMember$ remoteMembers$ memberCount$ sendData() data$ │
│ localMember$ remoteMembers$ memberCount$ publish(data) │
│ keyRotationSuppressed$ connectedTransports$ │
│ │
│ per member RTCMember { id userId deviceId membership$ displayName$ │
@@ -48,7 +48,7 @@ Call's `src/` to get there is listed in [`SdkMigration.md`](./SdkMigration.md).
│ │ (LocalMembership) │ local.publish, │ Publisher: local tracks, │ │
│ │ transport registry │ setPublishing▶│ upstream paused until │ │
│ │ (TransportMetadata) │ ◀─ connections$ │ joined │ │
│ │ data packet → member │ ◀─ data$ │ LivekitMemberMedia: │ │
│ │ │ │ LivekitMemberMedia: │ │
│ │ │ │ participant → tracks │ │
│ └───────────────────────────┘ └───────────────────────────┘ │
│ │
@@ -146,12 +146,12 @@ the sections below still describe `MatrixRTCClient` until that lands.
| Word | Meaning here | Elsewhere |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| slot | an `RTCSlot`: the namespace in a room that memberships join into, `application` plus `id`. It exists while the room does, with nobody in it too. Holds `members$`, its open or closed `status$`, and `join()` | the js-sdk `MatrixRTCSession` is one per slot; the MSC4143 slot event |
| participation | an `RTCParticipation`: the span between our `join()` of a slot and our `leave()`. Holds the connection and its `state$`, `localMember$`, `remoteMembers$`, the connected transports and the data channel. Made by `slot.join()`, never reused | "session" is not used here, because in the js-sdk it means the slot |
| participation | an `RTCParticipation`: the span between our `join()` of a slot and our `leave()`. Holds the connection and its `state$`, `localMember$`, `remoteMembers$`, and the connected transports. Made by `slot.join()`, never reused | "session" is not used here, because in the js-sdk it means the slot |
| member | an `RTCMember`: one membership in a slot with what the SDK derives from it, `rtcBackendIdentity`, `userId`, `deviceId`, `memberId`, `displayName$`, `avatarUrl$`, `transport$` and `applicationData$`. No media. What the slot hands out in `members$` | the js-sdk `CallMembership` behind it, which never leaves the SDK; Element Call's old "matrixLivekitMember" |
| local member, remote member | `LocalRTCMember` and `RemoteRTCMember`: an `RTCMember` plus its `MemberMedia`, which only a participation can carry. The local one's tracks carry the controls. What the participation hands out in `localMember$` and `remoteMembers$` | LiveKit `LocalParticipant` and `RemoteParticipant`, which never leave the SDK |
| media | a `MemberMedia`: what a member sends once it has arrived on its transport, `tracks$`, null while waiting, and `encryptionError$` beside it. The media backend supplies exactly these two fields per member | Element Call's `MediaViewModel` is a view of it |
| track | a `MediaTrack`: one publication of a member, audio or video, with `source`, `kind`, `id` and the behaviors on it. `LocalAudioMediaTrack` and `LocalVideoMediaTrack` add the controls over our own | LiveKit `TrackPublication` and `Track`, which never leave the SDK |
| publish request | a `PublishRequest`: a source, where to capture it from and how to encode it. Given at the join in `publish`, or later to `publish` on the participation | |
| track | a `Track`: one publication of a member. A `MediaTrack` is audio or video, with `source`, `kind`, `id` and the behaviors on it; a `DataTrack` is a named channel of bytes. The `Local…` variants add the controls over our own, and `send` for data | LiveKit `TrackPublication` and `Track`, which never leave the SDK |
| publish request | a `PublishRequest`: a source, where to capture it from and how to encode it. Given at the join in `publish`, or later to `publish` on the participation. A `DataPublishRequest` names a data track, and only goes to `publish` | |
| attach, detach | handing a `<video>` or `<audio>` element to a track and taking it back. The SDK owns the stream and the observers on the element | LiveKit `Track.attach` |
| transport | an `RTCTransport`: where media is exchanged, a LiveKit service url today. Carries the raw js-sdk `Transport` from the membership and, in `resolved$`, what the backend fetched to connect to it | MSC4143 "focus" |
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation, chosen with `backend` in the join options; LiveKit under `sdk/src/media-backend/livekit/` is the one that exists | "SFU", a LiveKit room, a peer connection |
@@ -340,28 +340,6 @@ export interface MatrixRTCClient {
* the browser's choice, never calls it.
*/
setAudioOutputDeviceId(deviceId: string): Promise<void>;
/**
* Sends a short text to every member on the local transport, over a
* reliable data channel beside the media. A packet holds about 15 KiB;
* anything larger belongs in a room event. Rejects while the local
* transport is not connected. Encrypted on the wire like the media, but
* not end to end with the media key.
*/
sendData(topic: string, text: string): Promise<void>;
/**
* What remote members sent with `sendData`, on every transport the client
* is connected to. A message from an identity that is not a member is
* dropped, so a host only ever hears from attested members.
*/
data$: Observable<DataMessage>;
}
/** One `sendData` call as it arrives at a remote member. */
export interface DataMessage {
member: RemoteRTCMember;
topic: string;
text: string;
}
```
@@ -452,11 +430,15 @@ export interface LocalRTCMember extends RTCMember {
* user closing the picker. One publication per source: publishing a source
* again unmutes it.
*/
publish(request: DataPublishRequest): Promise<LocalDataTrack>;
publish(
request: PublishRequest,
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
/** Removes one of our tracks; a screen share takes its audio with it. */
unpublish(id: string): Promise<void>;
/**
* Removes one of our tracks, as `publish` handed it out; a screen share
* takes its audio with it. The track's controls and `send` reject from then on.
*/
unpublish(track: LocalTrack): Promise<void>;
}
```
@@ -557,8 +539,6 @@ export interface MediaBackend {
membership$: Behavior<RTCMembership>,
): MemberMediaFields;
readonly connections$: Behavior<BackendConnection[]>; // transport, state, resolved; diagnostics
sendData(topic: string, text: string): Promise<void>;
readonly data$: Observable<DataPacket>;
}
```
@@ -568,8 +548,7 @@ LiveKit backend only, a last resort of another member's SFU that lets us
publish (`findRemotePublishLivekitTransport`, wired in by `RTCParticipation` rather
than by the backend interface, since it is an MSC4195 matter), the membership
state machine in `LocalMembership.ts` and its combination with
`local.connectionState$` into `state$`, the choice of delayed leave timings,
mapping a data packet's sender to a member, and the transport registry, which keys on the raw
`local.connectionState$` into `state$`, the choice of delayed leave timings, and the transport registry, which keys on the raw
transport serialised with sorted keys and derives `connectedTransports$` and each
`TransportMetadata.resolved$` from `connections$`.
@@ -613,6 +592,43 @@ the local transport, and keys its connections on the transport plus the server
name where the transport has a `url`. A JWT service is reached directly, so
members of several homeservers advertising the same one share a connection.
### Data tracks on LiveKit
A data track is a track like the media, in `tracks$`, so the backend carries it
the way it carries the media: `local.publish` takes a `DataPublishRequest`, and
`mediaFor` lists a remote member's data tracks after its media. Its sender is
attested by construction: a track only exists on a member. The session never
sees a key, a frame or a stream.
Every data track is one LiveKit data track, whose publication announces it: a
receiver sees the track before its first message, and sees it go. LiveKit's
data tracks are lossy only, so the LiveKit name says how the payloads travel
(`dataTrackName.ts`):
| Request | LiveKit name | Payloads |
| ----------------- | ----------------- | --------------------------------------------------------------- |
| `reliable: false` | `lossy:<name>` | frames on the data track: unordered, no retransmits |
| `reliable: true` | `reliable:<name>` | one byte stream per `send` on topic `reliable:<name>`, in order |
A track without a prefix, from a plain LiveKit publisher, is read as lossy. A
room takes one byte stream handler per topic, so `ByteStreams.ts` shares it
between every reliable track on that topic and filters by sender identity.
Encryption is LiveKit's. The rooms are built with `encryption` rather than
`e2ee`, which encrypts the data channel as well as the media with the same
E2EE manager and our key provider. The sender encrypts with its current key
and puts the key index and IV on the wire: in each frame's E2EE extension for a
lossy track, in each packet's `EncryptedPacket` for a stream. The receiver
decrypts with the sender's key at that index. A frame that does not decrypt is
dropped. A stream cannot be checked: LiveKit 2.23 sends an encrypted packet
without its encryption type, so every received stream reads as unencrypted,
and a cleartext stream injected on the way would be accepted as the sender's.
A local track is published on LiveKit once publishing is allowed, like the
media, and again on every new connection to the local transport; `publish`
resolves with that first publication. The object a host holds outlives the
connections.
## Media: `tracks$` and `MediaTrack`
Every member has media. Today, in Element Call, that media _is_ a `livekit-client`
@@ -707,14 +723,52 @@ export interface LocalVideoMediaTrack extends VideoMediaTrack, LocalMediaTrack {
setProcessor(processor: VideoProcessor | undefined): Promise<void>;
}
/** A named channel of bytes a member publishes beside its media. */
export interface DataPublishRequest {
source: "data";
/** Unique per member; what receivers find the track by. */
name: string;
/** Whether every message arrives, in order. Default true. */
reliable?: boolean;
}
/** One data track of a member; it exists only on a member, so its sender is attested. */
export interface DataTrack {
source: "data";
kind: "data";
id: string;
name: string;
reliable: boolean;
/** Whether payloads are end-to-end encrypted with the media key. */
encrypted: boolean;
/** What the member sends from now on, subscribed while observed. */
data$: Observable<Uint8Array>;
}
/** Our own data track; its `data$` replays what we sent. */
export interface LocalDataTrack extends DataTrack {
send(payload: Uint8Array): Promise<void>;
}
export type Track = AudioMediaTrack | VideoMediaTrack | DataTrack;
export type LocalTrack =
LocalAudioMediaTrack | LocalVideoMediaTrack | LocalDataTrack;
export type EncryptionError = "MissingKey" | "InvalidKey";
/** The member's first track of a source; undefined while there is none, or no media yet. */
export function trackBySource$<S extends MediaSource>(
scope: ObservableScope,
tracks$: Behavior<MediaTrack[] | null>,
tracks$: Behavior<readonly (MediaTrack | DataTrack)[] | null>,
source: S,
): Behavior<TrackOfSource<S> | undefined>;
/** The member's data track of this name; undefined while there is none. */
export function dataTrackByName$(
scope: ObservableScope,
tracks$: Behavior<readonly (MediaTrack | DataTrack)[] | null>,
name: string,
): Behavior<DataTrack | undefined>;
```
### Rendering: `attach` and `detach`
@@ -822,13 +876,11 @@ public API only.
- **Developer panel.** `resolved$` on each connected transport covers what the
panel shows today. Anything beyond that (LiveKit room state, connection
quality) needs an opaque `debug$` per transport.
- **Data beside the media.** `sendData`/`data$` ride on LiveKit's reliable data
packets: topic-agnostic, matched to members by identity, received on every
connection but sent only on the local transport, so a member on another
transport does not hear us. They are transport-encrypted, not end to end:
LiveKit can encrypt data packets with the media key, but that is not wired
through the key provider yet. A host that needs either reach or end-to-end
encryption uses room events.
- **Data before a receiver listens.** Nothing is replayed: a message sent
before a receiver has subscribed to the track, which it does once it has
seen the track published, does not reach it. Old builds of the legacy
widget bundle, which sent bare packets, do not hear new builds or the other
way round.
- **Unencrypted rooms with per-participant keys.** Keys only reach devices the
crypto tracks, which are the members of encrypted rooms. If the SDK is to work
in an unencrypted room, tracking the members' devices becomes its job.
+67 -21
View File
@@ -12,12 +12,22 @@ Please see LICENSE in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/lib/logger";
import { combineLatest, type Observable, of, switchMap } from "rxjs";
import {
combineLatest,
EMPTY,
map,
type Observable,
of,
switchMap,
} from "rxjs";
import {
type AudioMediaTrack,
createRTCSlot,
dataTrackByName$,
E2eeType,
type LocalAudioMediaTrack,
type LocalRTCMember,
type LocalVideoMediaTrack,
MatrixRTCMode,
ObservableScope,
type RemoteRTCMember,
@@ -82,7 +92,7 @@ async function start(
state.kind === "failed" ? `Error: ${state.error.message}` : state.kind;
});
showMembers(scope, rtcParticipation);
showMessages(scope, rtcParticipation);
showMessages(rtcParticipation);
toggle(buttons.microphone, async (enabled) =>
setPublished(rtcParticipation, "microphone", enabled),
@@ -120,10 +130,11 @@ async function setPublished(
enabled: boolean,
): Promise<boolean> {
const track = rtcParticipation.localMember$.value?.tracks$.value?.find(
(t) => t.source === source,
(t): t is LocalAudioMediaTrack | LocalVideoMediaTrack =>
t.source === source,
);
if (track && source === "screenShare") {
if (!enabled) await rtcParticipation.unpublish(source);
if (!enabled) await rtcParticipation.unpublish(track);
return enabled;
}
if (track) return track.setEnabled(enabled);
@@ -145,30 +156,63 @@ function toggle(
};
}
/** Sends what the form holds on the "chat" topic, and lists what arrives. */
function showMessages(
scope: ObservableScope,
rtcParticipation: RTCParticipation,
): void {
/**
* Publishes our "chat" data track with the join, so that the others hear the
* first message, and sends what the form holds on it. What arrives is listed
* per member, in `memberTile`.
*/
function showMessages(rtcParticipation: RTCParticipation): void {
const encoder = new TextEncoder();
const chat = rtcParticipation.publish({ source: "data", name: "chat" });
chat.catch((e: unknown) => {
status.textContent = `Error: ${e}`;
});
dataForm.hidden = false;
dataForm.onsubmit = (event): void => {
event.preventDefault();
const text = new FormData(dataForm).get("text") as string;
rtcParticipation.sendData("chat", text).then(
() => dataForm.reset(),
(e: unknown) => {
status.textContent = `Error: ${e}`;
},
);
chat
.then(async (track) => track.send(encoder.encode(text)))
.then(
() => dataForm.reset(),
(e: unknown) => {
status.textContent = `Error: ${e}`;
},
);
};
rtcParticipation.data$
.pipe(scope.bind())
.subscribe(({ member, topic, text }) => {
}
/**
* Lists what a remote member sends on its "chat" data track, and marks its
* tile while it has one.
*/
function listMessages(
scope: ObservableScope,
member: RemoteRTCMember,
tile: HTMLElement,
): void {
const decoder = new TextDecoder();
const chat$ = dataTrackByName$(scope, member.tracks$, "chat");
chat$.pipe(scope.bind()).subscribe((track) => {
tile.dataset.chat = String(track !== undefined);
});
chat$
.pipe(
switchMap(
(track) =>
track?.data$.pipe(
map((payload) => ({ payload, encrypted: track.encrypted })),
) ?? EMPTY,
),
scope.bind(),
)
.subscribe(({ payload, encrypted }) => {
const line = messages.appendChild(document.createElement("li"));
line.dataset.testid = "message";
line.dataset.topic = topic;
line.dataset.name = "chat";
line.dataset.encrypted = String(encrypted);
line.dataset.userId = member.userId;
line.textContent = `${member.displayName$.value}: ${text}`;
line.textContent = `${member.displayName$.value}: ${decoder.decode(payload)}`;
});
}
@@ -218,12 +262,14 @@ function memberTile(
tile.appendChild(document.createElement("video")),
);
// Our own microphone would only echo
if (!member.local)
if (!member.local) {
render(
scope,
trackBySource$(scope, member.tracks$, "microphone"),
tile.appendChild(document.createElement("audio")),
);
listMessages(scope, member, tile);
}
return tile;
}
+5 -1
View File
@@ -55,7 +55,11 @@ export {
export * from "./src/api";
export * from "./src/media-api";
export * from "./src/media-backend/api";
export { trackBySource$, type TrackOfSource } from "./src/utils/tracks";
export {
dataTrackByName$,
trackBySource$,
type TrackOfSource,
} from "./src/utils/tracks";
export { createRTCSlot } from "./src/RTCSlot";
export {
createLivekitBackend,
+28 -27
View File
@@ -14,11 +14,10 @@ import {
type Transport,
type UnstableLivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, EMPTY, Subject } from "rxjs";
import { BehaviorSubject, EMPTY } from "rxjs";
import { describe, expect, it, type Mock, vi } from "vitest";
import {
type DataMessage,
type ParticipationState,
type RTCMember,
type RTCParticipationOptions,
@@ -29,13 +28,16 @@ import { defaultSessionTimings, MatrixRTCMode } from "./config";
import { E2eeType } from "./encryption";
import { MatrixRTCError } from "./errors";
import {
type DataPacket,
type MediaBackend,
type MediaBackendContext,
MediaConnectionState,
type MediaKey,
} from "./media-backend/api";
import { type MemberMedia } from "./media-api";
import {
type LocalDataTrack,
type LocalVideoMediaTrack,
type MemberMedia,
} from "./media-api";
import { constant } from "./reactive/Behavior";
import { ObservableScope } from "./reactive/ObservableScope";
import { createRTCParticipation } from "./RTCParticipation";
@@ -103,7 +105,6 @@ interface FakeBackend {
context: MediaBackendContext;
prepared: PromiseWithResolvers<{ canDelegateDelayedLeave: boolean }>;
connectionState$: BehaviorSubject<MediaConnectionState | Error>;
data$: Subject<DataPacket>;
setPublishing: Mock<(publish: boolean) => void>;
delegateDelayedLeave: Mock<(delayId: string) => Promise<void>>;
mediaFor: Mock<(scope: ObservableScope, member: RTCMember) => MemberMedia>;
@@ -151,7 +152,6 @@ function setup(
connectionState$: new BehaviorSubject<MediaConnectionState | Error>(
MediaConnectionState.Initialized,
),
data$: new Subject<DataPacket>(),
setPublishing: vi.fn(),
delegateDelayedLeave: vi.fn(async () => Promise.resolve()),
mediaFor: vi.fn(() => ({
@@ -179,8 +179,6 @@ function setup(
mediaFor: fake.mediaFor,
connections$: constant([]),
setAudioOutputDeviceId: vi.fn(),
sendData: vi.fn(),
data$: fake.data$,
};
fake.context = context;
fake.backend = backend;
@@ -414,27 +412,30 @@ describe("createRTCParticipation", () => {
scope.end();
});
it("delivers data from a member with its member and drops the rest", () => {
const { participation, members$, fake, scope } = setup();
members$.next([
member({
userId: "@bob:example.org",
memberId: "bob-member",
backendIdentities: ["bob-hashed", "bob-member"],
}),
]);
const messages: DataMessage[] = [];
participation.data$.subscribe((message) => messages.push(message));
it("hands publications to the backend, data tracks too", async () => {
const { participation, fake, scope } = setup();
const chat = { kind: "data", name: "chat" } as LocalDataTrack;
const screenShare = {
kind: "video",
source: "screenShare",
} as LocalVideoMediaTrack;
fake.data$.next({ senderId: "bob-hashed", topic: "chat", text: "hi" });
fake.data$.next({ senderId: "stranger", topic: "chat", text: "boo" });
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
topic: "chat",
text: "hi",
member: { memberId: "bob-member" },
await participation.publish({ source: "data", name: "chat" });
await participation.publish({ source: "camera" });
expect(fake.backend.local.publish).toHaveBeenNthCalledWith(1, {
source: "data",
name: "chat",
});
expect(fake.backend.local.publish).toHaveBeenNthCalledWith(2, {
source: "camera",
});
await participation.unpublish(chat);
await participation.unpublish(screenShare);
expect(fake.backend.local.unpublish).toHaveBeenNthCalledWith(1, chat);
expect(fake.backend.local.unpublish).toHaveBeenNthCalledWith(
2,
screenShare,
);
scope.end();
});
+23 -22
View File
@@ -20,7 +20,6 @@ import { v4 as uuidv4 } from "uuid";
import {
combineLatest,
distinctUntilChanged,
filter,
from,
fromEvent,
map,
@@ -35,7 +34,6 @@ import { type ObservableScope } from "./reactive/ObservableScope";
import { generateItems } from "./reactive/observable";
import { mapScoped } from "./utils/mapScoped";
import {
type DataMessage,
type LocalRTCMember,
type ParticipationState,
type RemoteRTCMember,
@@ -45,6 +43,14 @@ import {
type RTCSlot,
type RTCSlotOptions,
} from "./api";
import {
type DataPublishRequest,
type LocalAudioMediaTrack,
type LocalDataTrack,
type LocalTrack,
type LocalVideoMediaTrack,
type PublishRequest,
} from "./media-api";
import {
type MediaBackend,
type MediaBackendContext,
@@ -245,22 +251,6 @@ export function createRTCParticipation(
).pipe(map((member) => member ?? null)),
);
// A packet from an identity that is not a member is dropped, so a host only
// ever hears from attested members
const data$ = backend.data$.pipe(
map(({ senderId, topic, text }): DataMessage | null => {
const member = remoteMembers$.value.find((m) =>
m.backendIdentities.includes(senderId),
);
if (member === undefined) {
logger.warn(`Dropping data from ${senderId}: not a member`);
return null;
}
return { member, topic, text };
}),
filter((message) => message !== null),
);
// Nothing leaves this device while it may already have been dropped from
// the session: the member would show as away while still being heard
localMembership.state$
@@ -285,6 +275,19 @@ export function createRTCParticipation(
),
);
function publish(request: DataPublishRequest): Promise<LocalDataTrack>;
function publish(
request: PublishRequest,
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
async function publish(
request: DataPublishRequest | PublishRequest,
): Promise<LocalTrack> {
// Narrowed for the overloads; both go to the backend alike
return request.source === "data"
? backend.local.publish(request)
: backend.local.publish(request);
}
const leave = (): void => {
if (state$.value.kind === "left") return;
localMembership.leave();
@@ -299,14 +302,12 @@ export function createRTCParticipation(
state$,
localMember$,
remoteMembers$,
publish: async (request) => backend.local.publish(request),
unpublish: async (source) => backend.local.unpublish(source),
publish,
unpublish: async (track) => backend.local.unpublish(track),
keyRotationSuppressed$: createKeyRotationSuppressed$(scope, jsSdkSession),
connectedTransports$: transports.connected$,
setAudioOutputDeviceId: async (deviceId) =>
backend.setAudioOutputDeviceId(deviceId),
sendData: async (topic, text) => backend.sendData(topic, text),
data$,
},
};
}
+12 -30
View File
@@ -15,15 +15,15 @@ import {
type RTCNotificationType,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import { type Observable } from "rxjs";
import { type Behavior } from "./reactive/Behavior";
import {
type DataPublishRequest,
type LocalAudioMediaTrack,
type LocalDataTrack,
type LocalMedia,
type LocalMemberMedia,
type LocalTrack,
type LocalVideoMediaTrack,
type MediaSource,
type MemberMedia,
type PublishRequest,
} from "./media-api";
@@ -176,15 +176,19 @@ export interface RTCParticipation {
remoteMembers$: Behavior<RemoteRTCMember[]>;
/**
* Publishes a source and resolves with its track, which also shows up in
* `localMember$.tracks$`. Works from the moment `join()` returns: before the
* transport is connected.
* Publishes a source, or a data track, and resolves with its track, which
* also shows up in `localMember$.tracks$`. Works from the moment `join()`
* returns: before the transport is connected.
*/
publish(request: DataPublishRequest): Promise<LocalDataTrack>;
publish(
request: PublishRequest,
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
/** Removes one of our publications; a screen share takes its audio with it. */
unpublish(source: MediaSource): Promise<void>;
/**
* Removes one of our publications; a screen share takes its audio with it.
* The track's object is finished: its controls and `send` reject from then on.
*/
unpublish(track: LocalTrack): Promise<void>;
/**
* Whether the slot has grown large enough that MatrixRTC has stopped
@@ -201,28 +205,6 @@ export interface RTCParticipation {
* the browser's choice, never calls it.
*/
setAudioOutputDeviceId(deviceId: string): Promise<void>;
/**
* Sends a short text to every member on the local transport, over a
* reliable data channel beside the media. A packet holds about 15 KiB;
* anything larger belongs in a room event. Rejects while the local
* transport is not connected. Encrypted on the wire like the media, but
* not end to end with the media key.
*/
sendData(topic: string, text: string): Promise<void>;
/**
* What remote members sent with `sendData`, on every transport the
* participation is connected to. A message from an identity that is not a
* member is dropped, so a host only ever hears from attested members.
*/
data$: Observable<DataMessage>;
}
/** One `sendData` call as it arrives at a remote member. */
export interface DataMessage {
member: RemoteRTCMember;
topic: string;
text: string;
}
// ---------------------------------------------------------------------------
+65 -7
View File
@@ -69,6 +69,21 @@ export type PublishRequest =
capture?: VideoCaptureSettings;
};
/** A named channel of bytes a member publishes beside its media. */
export interface DataPublishRequest {
source: "data";
/**
* What receivers find the track by, with `dataTrackByName$`. Unique per
* member: publishing a name that is published already rejects.
*/
name: string;
/**
* Whether every message arrives, in order. Default true. Lossy delivery is
* for high-rate state where only the newest value matters, such as cursors.
*/
reliable?: boolean;
}
export type MediaSource =
| "microphone"
| "camera"
@@ -82,7 +97,7 @@ export type MediaStreamStats =
| RTCOutboundRtpStreamStats
| undefined;
/** One published track of a member. */
/** One published audio or video track of a member. */
export interface MediaTrack {
source: MediaSource;
kind: "audio" | "video";
@@ -163,6 +178,48 @@ export interface LocalVideoMediaTrack extends VideoMediaTrack, LocalMediaTrack {
setProcessor(processor: VideoProcessor | undefined): Promise<void>;
}
/**
* One data track of a member. It exists only on a member of the slot, so
* whatever arrives on it comes from an attested member.
*/
export interface DataTrack {
source: "data";
kind: "data";
/** Stable for the life of the track. */
id: string;
/** As the publisher named it in its `DataPublishRequest`. */
name: string;
reliable: boolean;
/** Whether payloads are end-to-end encrypted with the media key. */
encrypted: boolean;
/**
* What the member sends from now on. The track is subscribed while this is
* observed and not at all otherwise; nothing sent earlier is replayed.
*/
data$: Observable<Uint8Array>;
}
/**
* Our own data track. Its `data$` replays what we sent: each payload once
* `send` has handed it to the transport, so a host renders its own messages
* from the same stream as a remote member's.
*/
export interface LocalDataTrack extends DataTrack {
/**
* Sends to every member subscribed to this track. Rejects while the local
* transport is not connected, and where the payload is larger than the
* backend carries.
*/
send(payload: Uint8Array): Promise<void>;
}
/** Any track of a member: its media and its data. */
export type Track = AudioMediaTrack | VideoMediaTrack | DataTrack;
export type LocalTrack =
| LocalAudioMediaTrack
| LocalVideoMediaTrack
| LocalDataTrack;
export type EncryptionError = "MissingKey" | "InvalidKey";
/**
@@ -172,18 +229,19 @@ export type EncryptionError = "MissingKey" | "InvalidKey";
*/
export interface MemberMedia {
/**
* The member's tracks, in publication order, once it has shown up on its
* transport; null until then ("waiting for media"). An entry stays the same
* object for as long as the same publication is behind it. Which track is
* which is in its `source`; `trackBySource$` picks one out.
* The member's tracks, media and data, in publication order, once it has
* shown up on its transport; null until then ("waiting for media"). An
* entry stays the same object for as long as the same publication is
* behind it. Which track is which is in its `source`: `trackBySource$`
* picks a media track out, `dataTrackByName$` a data track.
*/
tracks$: Behavior<(AudioMediaTrack | VideoMediaTrack)[] | null>;
tracks$: Behavior<Track[] | null>;
/** Emits when the SFU reports a key problem for this member. */
encryptionError$: Observable<EncryptionError>;
}
export interface LocalMemberMedia extends MemberMedia {
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>;
tracks$: Behavior<LocalTrack[] | null>;
}
/**
+10 -20
View File
@@ -19,11 +19,13 @@ import { type Observable } from "rxjs";
import { type ResolvedTransport, type RTCMember } from "../api";
import {
type DataPublishRequest,
type LocalAudioMediaTrack,
type LocalDataTrack,
type LocalMedia,
type LocalMemberMedia,
type LocalTrack,
type LocalVideoMediaTrack,
type MediaSource,
type MemberMedia,
type PublishRequest,
} from "../media-api";
@@ -106,13 +108,6 @@ export interface BackendConnection {
resolved: ResolvedTransport | undefined;
}
export interface DataPacket {
/** The backend identity of the sender. */
senderId: string;
topic: string;
text: string;
}
export interface MediaBackend {
/**
* The membership transport types this backend serves, ordered decreasingly
@@ -158,14 +153,6 @@ export interface MediaBackend {
* exists keeps the device it was created with.
*/
setAudioOutputDeviceId(deviceId: string): Promise<void>;
/**
* A text channel beside the media: sent on the local transport, received on every one.
* This could well be just another track kind. Not needed for element call. just for the sdk target.
* Keeping it on the top level for visibility. Should be tackled in a future refactor.
*/
sendData(topic: string, text: string): Promise<void>;
readonly data$: Observable<DataPacket>;
}
/** `tracks$` is null until the connection carries a local participant. */
@@ -187,14 +174,17 @@ export interface LocalMediaBackend extends LocalMemberMedia {
readonly publishError$: Behavior<Error | null>;
/**
* Publishes a source and resolves with its track once it is in `tracks$`.
* Remembered across connections, so a request before the
* Publishes a source, or a data track, and resolves with its track once it
* is in `tracks$`. Remembered across connections, so a request before the
* transport is up is applied once it is, and republished after a
* reconnection. Rejects where the device could not be used.
* reconnection. Rejects where the device could not be used. A data track
* is end-to-end encrypted by the backend wherever the media is; the session
* never sees how.
*/
publish(request: DataPublishRequest): Promise<LocalDataTrack>;
publish(
request: PublishRequest,
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
/** Removes one of our publications; a screen share takes its audio with it. */
unpublish(source: MediaSource): Promise<void>;
unpublish(track: LocalTrack): Promise<void>;
}
@@ -0,0 +1,85 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type ByteStreamHandler,
type ByteStreamReader,
type Room as LivekitRoom,
} from "livekit-client";
import { describe, expect, it, vi } from "vitest";
import { byteStreams$, type ReceivedBytes } from "./ByteStreams";
import { flushPromises } from "../../utils/test";
function fakeRoom(): {
room: LivekitRoom;
handlers: Map<string, ByteStreamHandler>;
} {
const handlers = new Map<string, ByteStreamHandler>();
const room = {
registerByteStreamHandler: vi.fn(
(topic: string, handler: ByteStreamHandler) => {
if (handlers.has(topic)) throw new Error("already registered");
handlers.set(topic, handler);
},
),
unregisterByteStreamHandler: vi.fn((topic: string) =>
handlers.delete(topic),
),
} as unknown as LivekitRoom;
return { room, handlers };
}
function reader(chunks: number[][]): {
reader: ByteStreamReader;
finish: () => void;
} {
const done = Promise.withResolvers<Uint8Array[]>();
return {
reader: {
readAll: async () => done.promise,
} as unknown as ByteStreamReader,
finish: (): void => done.resolve(chunks.map((c) => new Uint8Array(c))),
};
}
describe("byteStreams$", () => {
it("shares one handler per topic, released with the last observer", () => {
const { room, handlers } = fakeRoom();
const first = byteStreams$(room, "reliable:chat").subscribe();
const second = byteStreams$(room, "reliable:chat").subscribe();
expect(room.registerByteStreamHandler).toHaveBeenCalledTimes(1);
first.unsubscribe();
expect(handlers.has("reliable:chat")).toBe(true);
second.unsubscribe();
expect(handlers.has("reliable:chat")).toBe(false);
});
it("joins the chunks and keeps the order the streams started in", async () => {
const { room, handlers } = fakeRoom();
const received: ReceivedBytes[] = [];
const subscription = byteStreams$(room, "reliable:chat").subscribe((r) =>
received.push(r),
);
const handler = handlers.get("reliable:chat")!;
const one = reader([[1, 2], [3]]);
const two = reader([[4]]);
handler(one.reader, { identity: "alice" });
handler(two.reader, { identity: "bob" });
two.finish();
await flushPromises();
expect(received).toEqual([]);
one.finish();
await flushPromises();
expect(received).toEqual([
{ identity: "alice", payload: new Uint8Array([1, 2, 3]) },
{ identity: "bob", payload: new Uint8Array([4]) },
]);
subscription.unsubscribe();
});
});
@@ -0,0 +1,82 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
type ByteStreamReader,
type Room as LivekitRoom,
} from "livekit-client";
import { catchError, concatMap, EMPTY, from, Observable, share } from "rxjs";
/** One byte stream, read to its end. */
export interface ReceivedBytes {
/** The participant identity of the sender. */
identity: string;
payload: Uint8Array;
}
const byTopicByRoom = new WeakMap<
LivekitRoom,
Map<string, Observable<ReceivedBytes>>
>();
/**
* The byte streams a room receives on a topic, in the order they started. A
* room takes one handler per topic, so every observer of a topic shares it:
* it is registered with the first subscription and released with the last.
*/
export function byteStreams$(
room: LivekitRoom,
topic: string,
): Observable<ReceivedBytes> {
let byTopic = byTopicByRoom.get(room);
if (byTopic === undefined) {
byTopic = new Map();
byTopicByRoom.set(room, byTopic);
}
let streams$ = byTopic.get(topic);
if (streams$ === undefined) {
streams$ = new Observable<{ reader: ByteStreamReader; identity: string }>(
(subscriber) => {
room.registerByteStreamHandler(topic, (reader, { identity }) =>
subscriber.next({ reader, identity }),
);
return (): void => room.unregisterByteStreamHandler(topic);
},
).pipe(
concatMap(({ reader, identity }) =>
from(readStream(reader, identity)).pipe(
catchError((e: unknown) => {
rootLogger
.getChild("[ByteStreams]")
.warn(`Dropping a stream on ${topic} from ${identity}`, e);
return EMPTY;
}),
),
),
share(),
);
byTopic.set(topic, streams$);
}
return streams$;
}
async function readStream(
reader: ByteStreamReader,
identity: string,
): Promise<ReceivedBytes> {
const chunks = await reader.readAll();
const payload = new Uint8Array(
chunks.reduce((length, chunk) => length + chunk.length, 0),
);
let offset = 0;
for (const chunk of chunks) {
payload.set(chunk, offset);
offset += chunk.length;
}
return { identity, payload };
}
@@ -77,6 +77,7 @@ describe("LivekitConnectionFactory", () => {
resolution: expect.objectContaining({ height: 720 }),
});
expect(options.audioOutput).toEqual({ deviceId: undefined });
expect(options.encryption).toBeUndefined();
expect(options.e2ee).toBeUndefined();
});
@@ -19,6 +19,7 @@ import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/En
import { BehaviorSubject, combineLatest, map } from "rxjs";
import { type ResolvedTransport } from "../../api";
import { watchRemoteDataTracks } from "./RemoteDataTracks";
import { type MediaQuality } from "../../config";
import { type PublishRequest } from "../../media-api";
import { type Behavior } from "../../reactive/Behavior";
@@ -128,12 +129,14 @@ export class LivekitConnectionFactory implements ConnectionFactory {
client: this.client,
scope,
livekitRoomFactory: () =>
new LivekitRoom(
roomOptions(
this.publish,
this.audioOutputDeviceId$.value,
this.keyProvider,
this.mediaQuality,
watchRemoteDataTracks(
new LivekitRoom(
roomOptions(
this.publish,
this.audioOutputDeviceId$.value,
this.keyProvider,
this.mediaQuality,
),
),
),
ownMembershipIdentity,
@@ -169,8 +172,10 @@ function roomOptions(
},
audioOutput: { deviceId: audioOutputDeviceId },
// Every room needs a worker of its own: one gets confused by streams from
// several rooms
e2ee: keyProvider && { keyProvider, worker: new E2EEWorker() },
// several rooms. `encryption` rather than `e2ee`, so that LiveKit encrypts
// the data channel too, which carries the reliable data tracks; media is
// encrypted the same either way
encryption: keyProvider && { keyProvider, worker: new E2EEWorker() },
};
}
@@ -1,143 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { EventEmitter } from "events";
import {
ConnectionState as LivekitConnectionState,
type RemoteParticipant,
RoomEvent,
} from "livekit-client";
import { BehaviorSubject } from "rxjs";
import { describe, expect, it, vi } from "vitest";
import { Epoch } from "../../reactive/ObservableScope";
import { constant } from "../../reactive/Behavior";
import { type DataPacket } from "../api";
import { type Connection, type ConnectionState } from "./Connection";
import { ConnectionMap } from "./ConnectionMap";
import { createDataChannel$ } from "./DataChannel";
import { testScope } from "../../utils/test";
import { exampleTransport } from "./test";
const encode = (text: string): Uint8Array => new TextEncoder().encode(text);
const connected: ConnectionState = {
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
};
function fakeConnection(
url: string,
state: ConnectionState = connected,
): {
connection: Connection;
room: EventEmitter;
publishData: ReturnType<typeof vi.fn>;
} {
const room = new EventEmitter();
const publishData = vi.fn(async () => Promise.resolve());
const connection = {
transport: { ...exampleTransport, livekit_service_url: url },
serverName: "example.org",
state$: constant(state),
livekitRoom: Object.assign(room, { localParticipant: { publishData } }),
} as unknown as Connection;
return { connection, room, publishData };
}
function setup(connections: Connection[]): {
received: DataPacket[];
connections$: BehaviorSubject<Epoch<ConnectionMap>>;
sendData: (topic: string, text: string) => Promise<void>;
} {
const data = new ConnectionMap();
for (const connection of connections) data.add(connection, []);
const connections$ = new BehaviorSubject(new Epoch(data));
const { data$, sendData } = createDataChannel$({
scope: testScope(),
connectionMap$: connections$,
connection$: constant(connections[0] ?? null),
});
const received: DataPacket[] = [];
data$.subscribe((message) => received.push(message));
return { received, connections$, sendData };
}
describe("data$", () => {
it("delivers a packet with its sender, topic and text", () => {
const { connection, room } = fakeConnection("https://sfu");
const { received } = setup([connection]);
room.emit(
RoomEvent.DataReceived,
encode("hello"),
{ identity: "alice" } as RemoteParticipant,
undefined,
"chat",
);
expect(received).toEqual([
{ senderId: "alice", topic: "chat", text: "hello" },
]);
});
it("drops packets from the server", () => {
const { connection, room } = fakeConnection("https://sfu");
const { received } = setup([connection]);
room.emit(RoomEvent.DataReceived, encode("server"), undefined);
expect(received).toEqual([]);
});
it("listens on every connection, including ones added later", () => {
const first = fakeConnection("https://sfu-1");
const second = fakeConnection("https://sfu-2");
const { received, connections$ } = setup([first.connection]);
const data = new ConnectionMap();
data.add(first.connection, []);
data.add(second.connection, []);
connections$.next(new Epoch(data, 1));
first.room.emit(RoomEvent.DataReceived, encode("one"), {
identity: "alice",
} as RemoteParticipant);
second.room.emit(RoomEvent.DataReceived, encode("two"), {
identity: "bob",
} as RemoteParticipant);
expect(received.map((m) => [m.senderId, m.text])).toEqual([
["alice", "one"],
["bob", "two"],
]);
});
});
describe("sendData", () => {
it("publishes the text as a reliable packet on the local connection", async () => {
const { connection, publishData } = fakeConnection("https://sfu");
const { sendData } = setup([connection]);
await sendData("chat", "hello");
expect(publishData).toHaveBeenCalledWith(encode("hello"), {
reliable: true,
topic: "chat",
});
});
it("rejects while the local transport is not connected", async () => {
const { connection, publishData } = fakeConnection("https://sfu", {
state: "initialized",
});
const { sendData } = setup([connection]);
await expect(sendData("chat", "hello")).rejects.toThrow("not connected");
expect(publishData).not.toHaveBeenCalled();
});
});
@@ -1,119 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
ConnectionState as LivekitConnectionState,
type RemoteParticipant,
type Room as LivekitRoom,
RoomEvent,
} from "livekit-client";
import {
distinctUntilChanged,
filter,
fromEvent,
map,
merge,
type Observable,
share,
switchMap,
} from "rxjs";
import { type Behavior } from "../../reactive/Behavior";
import {
type Epoch,
type ObservableScope,
} from "../../reactive/ObservableScope";
import { type DataPacket } from "../api";
import { type Connection } from "./Connection";
import { type ConnectionMap } from "./ConnectionMap";
interface Props {
scope: ObservableScope;
connectionMap$: Behavior<Epoch<ConnectionMap>>;
/** The local member's connection, which carries what we send. */
connection$: Behavior<Connection | null>;
}
export interface DataChannel {
data$: Observable<DataPacket>;
sendData: (topic: string, text: string) => Promise<void>;
}
/**
* A text channel beside the media: LiveKit's reliable data packets, received
* on every connection the client holds and sent on the local member's. The
* sender is reported by its identity; the packet's own claim of who sent it
* never reaches the host.
*/
export function createDataChannel$({
scope,
connectionMap$,
connection$,
}: Props): DataChannel {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const rooms$ = connectionMap$.pipe(
map(({ value }) => value.getConnections().map((c) => c.livekitRoom)),
distinctUntilChanged(
(a, b) => a.length === b.length && a.every((room, i) => room === b[i]),
),
);
const data$ = rooms$.pipe(
switchMap((rooms) => merge(...rooms.map(receivedOn))),
map(({ payload, participant, topic }): DataPacket | null =>
// Only the server sends without a participant; a host has no use for it
participant === undefined
? null
: {
senderId: participant.identity,
topic: topic ?? "",
text: decoder.decode(payload),
},
),
filter((packet) => packet !== null),
scope.bind(),
share(),
);
const sendData = async (topic: string, text: string): Promise<void> => {
const connection = connection$.value;
const state = connection?.state$.value;
if (
connection === null ||
state?.state !== "authenticated" ||
state.livekitState !== LivekitConnectionState.Connected
)
throw new Error("Cannot send data: the local transport is not connected");
await connection.livekitRoom.localParticipant.publishData(
encoder.encode(text),
{ reliable: true, topic },
);
};
return { data$, sendData };
}
interface Received {
payload: Uint8Array;
participant: RemoteParticipant | undefined;
topic: string | undefined;
}
function receivedOn(room: LivekitRoom): Observable<Received> {
return fromEvent(
room,
RoomEvent.DataReceived,
(
payload: Uint8Array,
participant?: RemoteParticipant,
_kind?: unknown,
topic?: string,
): Received => ({ payload, participant, topic }),
);
}
@@ -58,7 +58,6 @@ import {
ResolvedConnection,
} from "./ConnectionFactory";
import { toMediaConnectionState } from "./connectionState";
import { createDataChannel$ } from "./DataChannel";
import { createKeyProvider } from "./KeyProvider";
import {
createLivekitMemberMedia,
@@ -263,7 +262,12 @@ export function createLivekitBackend(
`${member.rtcBackendIdentity}: LiveKit participant missing on ${transportString}`,
);
});
return createLivekitMemberMedia(memberScope, source$, encryptionSystem);
return createLivekitMemberMedia(
memberScope,
source$,
encryptionSystem,
logger,
);
};
const connections$ = scope.behavior<BackendConnection[]>(
@@ -276,12 +280,6 @@ export function createLivekitBackend(
),
);
const { data$, sendData } = createDataChannel$({
scope,
connectionMap$,
connection$: localMedia_.connection$,
});
return {
transportTypes: ["m.livekit", "livekit"],
prepareLocalTransport,
@@ -290,8 +288,6 @@ export function createLivekitBackend(
mediaFor,
connections$,
setAudioOutputDeviceId: localMedia_.setAudioOutputDeviceId,
sendData,
data$,
};
}
@@ -0,0 +1,124 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/lib/logger";
import {
type ByteStreamHandler,
type ByteStreamReader,
type DataTrackFrame,
type RemoteDataTrack,
type Room as LivekitRoom,
} from "livekit-client";
import { describe, expect, it, vi } from "vitest";
import { createLivekitDataTrack } from "./LivekitDataTrack";
import { flushPromises } from "../../utils/test";
function remoteTrack(
name: string,
usesE2ee = true,
): {
track: RemoteDataTrack;
frames: ReadableStreamDefaultController<DataTrackFrame>[];
cancel: ReturnType<typeof vi.fn>;
} {
const frames: ReadableStreamDefaultController<DataTrackFrame>[] = [];
const cancel = vi.fn();
const track = {
info: { sid: "DT_1", name, usesE2ee },
publisherIdentity: "alice",
subscribe: vi.fn(
() =>
new ReadableStream<DataTrackFrame>({
start: (controller): void => {
frames.push(controller);
},
cancel,
}),
),
} as unknown as RemoteDataTrack;
return { track, frames, cancel };
}
function roomWithStreams(): {
room: LivekitRoom;
send: (identity: string, bytes: number[]) => void;
} {
const handlers = new Map<string, ByteStreamHandler>();
return {
room: {
registerByteStreamHandler: (topic: string, h: ByteStreamHandler) =>
handlers.set(topic, h),
unregisterByteStreamHandler: (topic: string) => handlers.delete(topic),
} as unknown as LivekitRoom,
send: (identity, bytes): void =>
handlers.get("reliable:chat")!(
{
readAll: async () => Promise.resolve([new Uint8Array(bytes)]),
} as unknown as ByteStreamReader,
{ identity },
),
};
}
describe("a remote data track", () => {
it("names itself without the LiveKit prefix", () => {
const { track } = remoteTrack("reliable:chat", false);
expect(
createLivekitDataTrack(roomWithStreams().room, track, logger),
).toMatchObject({
source: "data",
kind: "data",
id: "DT_1",
name: "chat",
reliable: true,
encrypted: false,
});
});
it("subscribes a lossy track while observed, once for all observers", async () => {
const { track, frames, cancel } = remoteTrack("lossy:cursors");
const dataTrack = createLivekitDataTrack(
roomWithStreams().room,
track,
logger,
);
expect(track.subscribe).not.toHaveBeenCalled();
const received: Uint8Array[] = [];
const first = dataTrack.data$.subscribe((p) => received.push(p));
const second = dataTrack.data$.subscribe();
expect(track.subscribe).toHaveBeenCalledTimes(1);
frames[0].enqueue({ payload: new Uint8Array([7]) });
await flushPromises();
expect(received).toEqual([new Uint8Array([7])]);
first.unsubscribe();
expect(cancel).not.toHaveBeenCalled();
second.unsubscribe();
expect(cancel).toHaveBeenCalled();
});
it("takes a reliable track's streams from its publisher only", async () => {
const { room, send } = roomWithStreams();
const dataTrack = createLivekitDataTrack(
room,
remoteTrack("reliable:chat").track,
logger,
);
const received: Uint8Array[] = [];
const subscription = dataTrack.data$.subscribe((p) => received.push(p));
send("bob", [1]);
send("alice", [2]);
await flushPromises();
// Bob's is not this track's
expect(received).toEqual([new Uint8Array([2])]);
subscription.unsubscribe();
});
});
@@ -0,0 +1,74 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type RemoteDataTrack, type Room as LivekitRoom } from "livekit-client";
import { filter, map, Observable, share } from "rxjs";
import { type DataTrack } from "../../media-api";
import { byteStreams$ } from "./ByteStreams";
import { fromLivekitDataTrackName } from "./dataTrackName";
/**
* A remote member's data track. Decryption is LiveKit's: its E2EE manager
* reads the key index and IV off each frame or stream and decrypts with the
* sender's key, so nothing here sees a key. A frame of an encrypted track that
* does not decrypt is dropped by LiveKit. A stream is not checked: LiveKit
* 2.23 sends an encrypted packet without its encryption type, so a received
* stream reads as unencrypted either way, and a cleartext one would pass.
*/
export function createLivekitDataTrack(
room: LivekitRoom,
track: RemoteDataTrack,
logger: Logger,
): DataTrack {
const { name, reliable } = fromLivekitDataTrackName(track.info.name);
const encrypted = track.info.usesE2ee;
const data$ = reliable
? byteStreams$(room, track.info.name).pipe(
filter(({ identity }) => identity === track.publisherIdentity),
map(({ payload }) => payload),
)
: framesOf(track, logger);
return {
source: "data",
kind: "data",
id: track.info.sid,
name,
reliable,
encrypted,
data$,
};
}
/** The track's frames, subscribed at the SFU while observed. */
function framesOf(
track: RemoteDataTrack,
logger: Logger,
): Observable<Uint8Array> {
return new Observable<Uint8Array>((subscriber) => {
const reader = track.subscribe().getReader();
let cancelled = false;
void (async (): Promise<void> => {
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
subscriber.next(value.payload);
}
} catch (e) {
// A failed subscription ends the stream; a host's pipeline survives it
if (!cancelled) logger.warn(`Data track ${track.info.name} ended`, e);
}
subscriber.complete();
})();
return (): void => {
cancelled = true;
reader.cancel().catch(() => {});
};
}).pipe(share());
}
@@ -6,9 +6,12 @@ Please see LICENSE in the repository root for full details.
*/
import { EventEmitter } from "events";
import { logger } from "matrix-js-sdk/lib/logger";
import {
ParticipantEvent,
type RemoteDataTrack,
type RemoteParticipant,
RoomEvent,
type Room as LivekitRoom,
Track,
type TrackPublication,
@@ -27,6 +30,7 @@ describe("createLivekitMemberMedia", () => {
testScope(),
constant({ participants: [participant], room }),
{ kind: E2eeType.NONE },
logger,
);
expect(media.tracks$.value).toEqual([]);
@@ -54,6 +58,7 @@ describe("createLivekitMemberMedia", () => {
testScope(),
constant({ participants: [participant], room }),
{ kind: E2eeType.NONE },
logger,
);
emitter.emit(
ParticipantEvent.TrackPublished,
@@ -85,6 +90,44 @@ function publish(
return publication;
}
describe("a member's data tracks", () => {
it("follow the room's data track events, after the media", () => {
const { participant, publications, room, emitter } = fakes();
const media = createLivekitMemberMedia(
testScope(),
constant({ participants: [participant], room }),
{ kind: E2eeType.NONE },
logger,
);
const camera = publish(publications, "TR_1", Track.Source.Camera);
emitter.emit(ParticipantEvent.TrackPublished, camera);
const chat = {
info: { sid: "DT_1", name: "reliable:chat", usesE2ee: true },
publisherIdentity: participant.identity,
} as unknown as RemoteDataTrack;
(room as unknown as EventEmitter).emit(RoomEvent.DataTrackPublished, chat);
const [video, data] = media.tracks$.value!;
expect(video.source).toBe("camera");
expect(data).toMatchObject({
kind: "data",
name: "chat",
reliable: true,
encrypted: true,
});
// An unrelated media event keeps the same object
emitter.emit(ParticipantEvent.TrackMuted, camera);
expect(media.tracks$.value![1]).toBe(data);
(room as unknown as EventEmitter).emit(
RoomEvent.DataTrackUnpublished,
"DT_1",
);
expect(media.tracks$.value).toEqual([video]);
});
});
function fakes(): {
participant: RemoteParticipant;
publications: Map<string, TrackPublication>;
@@ -9,6 +9,7 @@ import {
observeParticipantMedia,
roomEventSelector,
} from "@livekit/components-core";
import { type Logger } from "matrix-js-sdk/lib/logger";
import {
type LocalParticipant,
type Participant,
@@ -31,11 +32,14 @@ import { generateItems } from "../../reactive/observable";
import { E2eeType, type EncryptionSystem } from "../../encryption";
import { type Behavior } from "../../reactive/Behavior";
import {
type DataTrack,
type EncryptionError,
type LocalMemberMedia,
type MediaSource,
type MemberMedia,
} from "../../media-api";
import { createLivekitDataTrack } from "./LivekitDataTrack";
import { remoteDataTracks$ } from "./RemoteDataTracks";
import {
createLivekitMediaTrack,
createLocalLivekitMediaTrack,
@@ -70,13 +74,21 @@ export function createLivekitMemberMedia(
scope: ObservableScope,
source$: Behavior<ParticipantSource>,
encryptionSystem: EncryptionSystem,
logger: Logger,
): MemberMedia {
const media$ = memberTracks$(
scope,
source$,
(trackScope, participant, publication, room) =>
createLivekitMediaTrack(trackScope, participant, publication, room),
);
const data$ = memberDataTracks$(scope, source$, logger);
return {
tracks$: memberTracks$(
scope,
source$,
(trackScope, participant, publication, room) =>
createLivekitMediaTrack(trackScope, participant, publication, room),
// The data tracks after the media, both null while there is no participant
tracks$: scope.behavior(
combineLatest([media$, data$], (media, data) =>
media === null ? null : [...media, ...data],
),
),
encryptionError$: encryptionErrors$(source$, encryptionSystem),
};
@@ -145,6 +157,40 @@ function memberTracks$<P extends Participant, T>(
);
}
/**
* One data track per LiveKit data track of the member's participants, each
* living as long as it is published.
*/
function memberDataTracks$(
scope: ObservableScope,
source$: Behavior<ParticipantSource>,
logger: Logger,
): Behavior<DataTrack[]> {
return scope.behavior(
source$.pipe(
switchMap((source) => {
if (source === null) return of([]);
const { participants, room } = source;
const identities = new Set(participants.map((p) => p.identity));
return remoteDataTracks$(room).pipe(
map((tracks) =>
tracks.filter((t) => identities.has(t.publisherIdentity)),
),
generateItems(
`${[...identities].join(",")} dataTracks$`,
function* (tracks) {
for (const track of tracks)
yield { keys: [track.info.sid], data: track };
},
(_trackScope, track$) =>
createLivekitDataTrack(room, track$.value, logger),
),
);
}),
),
);
}
function encryptionErrors$(
source$: Behavior<ParticipantSource>,
encryptionSystem: EncryptionSystem,
@@ -0,0 +1,195 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
ConnectionState as LivekitConnectionState,
type LocalDataTrack as LivekitLocalDataTrack,
type LocalParticipant,
} from "livekit-client";
import { type Logger } from "matrix-js-sdk/lib/logger";
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
first,
map,
type Observable,
of,
startWith,
Subject,
switchMap,
} from "rxjs";
import { type DataPublishRequest, type LocalDataTrack } from "../../media-api";
import { type Behavior } from "../../reactive/Behavior";
import { ObservableScope } from "../../reactive/ObservableScope";
import { type Connection } from "./Connection";
import { toLivekitDataTrackName } from "./dataTrackName";
interface Props {
scope: ObservableScope;
/** The connection the local member publishes on. */
connection$: Behavior<Connection | null>;
/** Whether anything may leave this device: joined and the homeserver reachable. */
publishing$: Observable<boolean>;
/** Whether the rooms carry a key provider, so LiveKit encrypts what we send. */
encrypted: boolean;
logger: Logger;
}
export interface LocalDataTracks {
tracks$: Behavior<LocalDataTrack[]>;
/**
* Resolves once the track is published on LiveKit, which waits for the
* join. Rejects where a track of that name is published already, or where
* LiveKit refuses it.
*/
publish(request: DataPublishRequest): Promise<LocalDataTrack>;
unpublish(track: LocalDataTrack): void;
}
interface Published {
track: LivekitLocalDataTrack;
participant: LocalParticipant;
}
interface Entry {
track: LocalDataTrack;
finish: () => void;
}
/**
* The local member's data tracks. Each is one LiveKit data track, published
* on every connection to the local transport while publishing is allowed, so
* a track requested before the join goes out once we are joined. The object a
* host holds outlives the connections. Encryption is LiveKit's: its E2EE
* manager encrypts with our current key and writes the key index and IV into
* each frame, or each stream for a reliable track.
*/
export function createLocalDataTracks({
scope,
connection$,
publishing$,
encrypted,
logger,
}: Props): LocalDataTracks {
const entries = new Map<string, Entry>();
const tracks$ = new BehaviorSubject<LocalDataTrack[]>([]);
const update = (): void =>
tracks$.next([...entries.values()].map(({ track }) => track));
scope.onEnd(() => {
for (const entry of entries.values()) entry.finish();
});
// Where to publish: the local connection once it has connected, while
// publishing is allowed
const target$ = scope.behavior(
combineLatest([connection$, publishing$]).pipe(
switchMap(([connection, publishing]) =>
connection === null || !publishing
? of(null)
: connection.state$.pipe(
first(
(s) =>
s.state === "authenticated" &&
s.livekitState === LivekitConnectionState.Connected,
),
map(() => connection),
startWith(null),
),
),
distinctUntilChanged(),
),
);
const publish = async ({
name,
reliable = true,
}: DataPublishRequest): Promise<LocalDataTrack> => {
if (entries.has(name))
throw new Error(`A data track named ${name} is published already`);
const firstPublished = Promise.withResolvers<void>();
const livekitName = toLivekitDataTrackName(name, reliable);
const trackScope = new ObservableScope();
const published$ = new BehaviorSubject<Published | null>(null);
const sent$ = new Subject<Uint8Array>();
let finished = false;
trackScope.reconcile(target$, async (connection) => {
if (connection === null) return;
const participant = connection.livekitRoom.localParticipant;
let track: LivekitLocalDataTrack;
try {
track = await participant.publishDataTrack({ name: livekitName });
} catch (e) {
logger.error(`Failed to publish the data track ${name}`, e);
firstPublished.reject(e);
return;
}
published$.next({ track, participant });
firstPublished.resolve();
return async (): Promise<void> => {
published$.next(null);
await track.unpublish().catch((e: unknown) => {
logger.warn(`Failed to unpublish the data track ${name}`, e);
});
};
});
const track: LocalDataTrack = {
source: "data",
kind: "data",
id: `data:${name}`,
name,
reliable,
encrypted,
data$: sent$.asObservable(),
send: async (payload): Promise<void> => {
if (finished) throw new Error(`The data track ${name} is unpublished`);
const published = published$.value;
if (published === null)
throw new Error(
`Cannot send on ${name}: not published on the transport yet`,
);
if (reliable)
await published.participant.sendBytes(payload, {
topic: livekitName,
});
else
await published.track.tryPush({ payload: new Uint8Array(payload) });
sent$.next(payload);
},
};
entries.set(name, {
track,
finish: (): void => {
finished = true;
trackScope.end();
sent$.complete();
},
});
update();
try {
await firstPublished.promise;
} catch (e) {
unpublish(track);
throw e;
}
return track;
};
const unpublish = (track: LocalDataTrack): void => {
const entry = entries.get(track.name);
if (entry?.track !== track) return;
entries.delete(track.name);
entry.finish();
update();
};
return { tracks$: scope.behavior(tracks$), publish, unpublish };
}
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
import {
ConnectionState as LivekitConnectionState,
type LocalDataTrack as LivekitLocalDataTrack,
type LocalParticipant,
type LocalTrack,
type LocalTrackPublication,
@@ -28,6 +29,10 @@ import { type Connection, type ConnectionState } from "./Connection";
import { ConnectionMap } from "./ConnectionMap";
import { createLivekitLocalMedia } from "./LocalMedia";
import { exampleTransport } from "./test";
import {
type LocalAudioMediaTrack,
type LocalDataTrack,
} from "../../media-api";
const localTransport: TransportLocator = {
transport: exampleTransport,
@@ -270,9 +275,8 @@ describe("createLivekitLocalMedia", () => {
media.setPublishing(true);
await flushPromises();
await expect(media.tracks$.value![0].setEnabled(false)).resolves.toBe(
false,
);
const microphone = media.tracks$.value![0] as LocalAudioMediaTrack;
await expect(microphone.setEnabled(false)).resolves.toBe(false);
expect(first.participant.setMicrophoneEnabled).toHaveBeenLastCalledWith(
false,
);
@@ -293,10 +297,16 @@ describe("createLivekitLocalMedia", () => {
await flushPromises();
await media.publish({ source: "screenShare" });
await media.unpublish("screenShareAudio");
await media.unpublish({
kind: "audio",
source: "screenShareAudio",
} as LocalAudioMediaTrack);
expect(participant.setScreenShareEnabled).toHaveBeenLastCalledWith(false);
await media.unpublish("microphone");
await media.unpublish({
kind: "audio",
source: "microphone",
} as LocalAudioMediaTrack);
expect(participant.unpublishTrack).toHaveBeenCalledOnce();
expect(media.tracks$.value).toEqual([]);
});
@@ -385,6 +395,116 @@ function fakeLocalTrack(source: Track.Source): LocalTrack {
return t as unknown as LocalTrack;
}
describe("local data tracks", () => {
function joined(): ReturnType<typeof setup> & FakeConnection {
const fixture = setup();
const fake = fakeConnection();
fixture.connectionMap$.next(mapOf(fake.connection));
fixture.localTransport$.next(localTransport);
return { ...fixture, ...fake };
}
it("publishes on LiveKit only once publishing is allowed, and again on a new connection", async () => {
const { media, participant, connectionMap$ } = joined();
let published = false;
const publishing = media
.publish({ source: "data", name: "chat" })
.then((track) => {
published = true;
return track;
});
await flushPromises();
expect(participant.publishDataTrack).not.toHaveBeenCalled();
expect(published).toBe(false);
// Listed from the request on, like a media publication
const listed = media.tracks$.value!.find((t) => t.kind === "data");
expect(listed).toMatchObject({ name: "chat", reliable: true });
media.setPublishing(true);
const chat = await publishing;
expect(chat).toBe(listed);
expect(participant.publishDataTrack).toHaveBeenCalledWith({
name: "reliable:chat",
});
const next = fakeConnection();
connectionMap$.next(mapOf(next.connection));
await flushPromises();
expect(next.participant.publishDataTrack).toHaveBeenCalledWith({
name: "reliable:chat",
});
// The host's object outlives the connection
expect(media.tracks$.value).toContain(chat);
});
it("sends reliable data as a byte stream and lossy data as frames, replaying what was sent", async () => {
const { media, participant } = joined();
media.setPublishing(true);
const chat = await media.publish({ source: "data", name: "chat" });
const cursors = await media.publish({
source: "data",
name: "cursors",
reliable: false,
});
await flushPromises();
const sent: Uint8Array[] = [];
chat.data$.subscribe((p) => sent.push(p));
await chat.send(new Uint8Array([1]));
expect(participant.sendBytes).toHaveBeenCalledWith(new Uint8Array([1]), {
topic: "reliable:chat",
});
expect(sent).toEqual([new Uint8Array([1])]);
await cursors.send(new Uint8Array([2]));
const livekitCursors = await vi.mocked(participant.publishDataTrack).mock
.results[1].value;
expect(livekitCursors.tryPush).toHaveBeenCalledWith({
payload: new Uint8Array([2]),
});
});
it("rejects a second track of the same name, or one LiveKit refuses", async () => {
const { media, participant } = joined();
media.setPublishing(true);
await media.publish({ source: "data", name: "chat" });
await expect(
media.publish({ source: "data", name: "chat", reliable: false }),
).rejects.toThrow("published already");
vi.mocked(participant.publishDataTrack).mockRejectedValueOnce(
new Error("DuplicateName"),
);
await expect(
media.publish({ source: "data", name: "cursors" }),
).rejects.toThrow("DuplicateName");
expect(
media.tracks$.value!.filter((t) => t.kind === "data").map((t) => t.name),
).toEqual(["chat"]);
});
it("finishes a track on unpublish", async () => {
const { media, participant } = joined();
media.setPublishing(true);
const chat: LocalDataTrack = await media.publish({
source: "data",
name: "chat",
});
await flushPromises();
let completed = false;
chat.data$.subscribe({ complete: () => (completed = true) });
await media.unpublish(chat);
await flushPromises();
const livekitChat = await vi.mocked(participant.publishDataTrack).mock
.results[0].value;
expect(livekitChat.unpublish).toHaveBeenCalled();
expect(completed).toBe(true);
expect(media.tracks$.value).not.toContain(chat);
await expect(chat.send(new Uint8Array([1]))).rejects.toThrow("unpublished");
});
});
/**
* The slice of a local participant the publisher and the local tracks use:
* enabling a source publishes a fake track at once, muting an existing one
@@ -442,6 +562,14 @@ function fakeLocalParticipant(): LocalParticipant {
const participant = {
...emitter,
publishDataTrack: vi.fn(async ({ name }: { name: string }) =>
Promise.resolve({
name,
tryPush: vi.fn(async () => Promise.resolve()),
unpublish: vi.fn(async () => Promise.resolve()),
} as unknown as LivekitLocalDataTrack),
),
sendBytes: vi.fn(async () => Promise.resolve({})),
identity: "@alice:example.org:DEVICE",
isLocal: true,
isSpeaking: false,
+49 -11
View File
@@ -26,13 +26,16 @@ import {
} from "rxjs";
import {
type DataPublishRequest,
type LocalAudioMediaTrack,
type LocalDataTrack,
type LocalMedia,
type LocalTrack,
type LocalVideoMediaTrack,
type MediaSource,
type PublishRequest,
} from "../../media-api";
import { type EncryptionSystem } from "../../encryption";
import { E2eeType, type EncryptionSystem } from "../../encryption";
import { FailToStartLivekitConnection, toMatrixRTCError } from "../../errors";
import { type Behavior } from "../../reactive/Behavior";
import {
@@ -50,6 +53,7 @@ import {
} from "./LivekitMemberMedia";
import { livekitSources } from "./LivekitMediaTrack";
import { type DesiredMedia, Publisher } from "./Publisher";
import { createLocalDataTracks } from "./LocalDataTracks";
import { toMediaConnectionState } from "./connectionState";
interface Props {
@@ -67,7 +71,7 @@ interface Props {
}
export interface LivekitLocalMedia extends LocalMediaBackend {
/** The connection the local member publishes on; the data channel sends on it. */
/** The connection the local member publishes on. */
connection$: Behavior<Connection | null>;
setAudioOutputDeviceId(deviceId: string): Promise<void>;
}
@@ -171,14 +175,42 @@ export function createLivekitLocalMedia({
return result;
};
const { tracks$, encryptionError$ } = createLocalLivekitMemberMedia(
const { tracks$: mediaTracks$, encryptionError$ } =
createLocalLivekitMemberMedia(
scope,
participantAndRoom$(scope, participant$, connection$),
encryptionSystem,
setEnabled,
);
const dataTracks = createLocalDataTracks({
scope,
participantAndRoom$(scope, participant$, connection$),
encryptionSystem,
setEnabled,
connection$,
publishing$,
encrypted: encryptionSystem.kind !== E2eeType.NONE,
logger,
});
// The data tracks after the media, both null while there is no participant
const tracks$ = scope.behavior<LocalTrack[] | null>(
combineLatest([mediaTracks$, dataTracks.tracks$], (media, data) =>
media === null ? null : [...media, ...data],
),
);
const publish = async (
function publish(request: DataPublishRequest): Promise<LocalDataTrack>;
function publish(
request: PublishRequest,
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
async function publish(
request: DataPublishRequest | PublishRequest,
): Promise<LocalTrack> {
if (request.source === "data")
return dataTracks.publish(request).catch((e: unknown) => {
throw toMatrixRTCError(e);
});
return publishMedia(request);
}
const publishMedia = async (
request: PublishRequest,
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack> => {
desired.set(request.source, { request, enabled: true });
@@ -195,8 +227,9 @@ export function createLivekitLocalMedia({
}
};
const unpublish = async (source: MediaSource): Promise<void> => {
const published = publishedSource(source);
const unpublish = async (track: LocalTrack): Promise<void> => {
if (track.kind === "data") return dataTracks.unpublish(track);
const published = publishedSource(track.source);
if (published === undefined) return;
desired.delete(published);
await publisher$.value?.unpublish(published);
@@ -259,11 +292,16 @@ function publication$(
}
function trackWithId$(
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>,
tracks$: Behavior<LocalTrack[] | null>,
id: string,
): Observable<LocalAudioMediaTrack | LocalVideoMediaTrack> {
return tracks$.pipe(
map((tracks) => tracks?.find((track) => track.id === id)),
map((tracks) =>
tracks?.find(
(track): track is LocalAudioMediaTrack | LocalVideoMediaTrack =>
track.kind !== "data" && track.id === id,
),
),
filter((track) => track !== undefined),
);
}
+5 -3
View File
@@ -72,9 +72,11 @@ export class Publisher {
private readonly media?: LocalMedia,
) {
this.room = room;
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
this.logger.error("Failed to enable E2EE on the room", e);
});
room
.setE2EEEnabled(room.options.encryption !== undefined)
?.catch((e: Error) => {
this.logger.error("Failed to enable E2EE on the room", e);
});
this.onLocalTrackPublished = this.onLocalTrackPublished.bind(this);
room.localParticipant.on(
ParticipantEvent.LocalTrackPublished,
@@ -0,0 +1,39 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { EventEmitter } from "events";
import {
type RemoteDataTrack,
type Room as LivekitRoom,
RoomEvent,
} from "livekit-client";
import { describe, expect, it } from "vitest";
import { remoteDataTracks$, watchRemoteDataTracks } from "./RemoteDataTracks";
const track = (sid: string): RemoteDataTrack =>
({ info: { sid }, publisherIdentity: "alice" }) as RemoteDataTrack;
describe("remoteDataTracks$", () => {
it("keeps what the room announced before anyone observed it", () => {
const emitter = new EventEmitter();
const room = watchRemoteDataTracks(emitter as unknown as LivekitRoom);
// As in a join response: announced before any member asks
emitter.emit(RoomEvent.DataTrackPublished, track("DT_1"));
emitter.emit(RoomEvent.DataTrackPublished, track("DT_2"));
emitter.emit(RoomEvent.DataTrackUnpublished, "DT_1");
const seen: string[][] = [];
remoteDataTracks$(room).subscribe((tracks) =>
seen.push(tracks.map((t) => t.info.sid)),
);
expect(seen).toEqual([["DT_2"]]);
emitter.emit(RoomEvent.Disconnected);
expect(seen.at(-1)).toEqual([]);
});
});
@@ -0,0 +1,60 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type RemoteDataTrack,
type Room as LivekitRoom,
RoomEvent,
} from "livekit-client";
import { BehaviorSubject, type Observable } from "rxjs";
const tracksByRoom = new WeakMap<
LivekitRoom,
BehaviorSubject<readonly RemoteDataTrack[]>
>();
/**
* Starts keeping the room's remote data tracks, from its own events. Called
* as the room is created: LiveKit announces the tracks in the join response
* before the participants that publish them exist, so a participant's
* `dataTracks` misses them, and it adds a track to that map only after
* announcing it.
*/
export function watchRemoteDataTracks(room: LivekitRoom): LivekitRoom {
remoteDataTracksOf(room);
return room;
}
/** Every remote data track the room knows of, as LiveKit announced them. */
export function remoteDataTracks$(
room: LivekitRoom,
): Observable<readonly RemoteDataTrack[]> {
return remoteDataTracksOf(room);
}
function remoteDataTracksOf(
room: LivekitRoom,
): BehaviorSubject<readonly RemoteDataTrack[]> {
let tracks$ = tracksByRoom.get(room);
if (tracks$ === undefined) {
const subject$ = new BehaviorSubject<readonly RemoteDataTrack[]>([]);
room.on(RoomEvent.DataTrackPublished, (track: RemoteDataTrack) =>
subject$.next([
...subject$.value.filter((t) => t.info.sid !== track.info.sid),
track,
]),
);
room.on(RoomEvent.DataTrackUnpublished, (sid: string) =>
subject$.next(subject$.value.filter((t) => t.info.sid !== sid)),
);
// A new session announces its tracks again
room.on(RoomEvent.Disconnected, () => subject$.next([]));
tracksByRoom.set(room, subject$);
tracks$ = subject$;
}
return tracks$;
}
@@ -0,0 +1,28 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import {
fromLivekitDataTrackName,
toLivekitDataTrackName,
} from "./dataTrackName";
describe("data track names on LiveKit", () => {
it.each([true, false])("round-trips reliable=%s", (reliable) => {
expect(
fromLivekitDataTrackName(toLivekitDataTrackName("chat:room", reliable)),
).toEqual({ name: "chat:room", reliable });
});
it("reads a name without a prefix as lossy", () => {
expect(fromLivekitDataTrackName("telemetry")).toEqual({
name: "telemetry",
reliable: false,
});
});
});
@@ -0,0 +1,36 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* How a data track is named on LiveKit. Every data track of a member is one
* LiveKit data track, which announces it; the prefix says how its payloads
* travel. A lossy track pushes them as frames on the data track itself. A
* reliable one sends each as a byte stream on a topic of the same name,
* because LiveKit's data tracks are lossy only.
*/
const lossyPrefix = "lossy:";
const reliablePrefix = "reliable:";
export function toLivekitDataTrackName(
name: string,
reliable: boolean,
): string {
return (reliable ? reliablePrefix : lossyPrefix) + name;
}
/** A track without a prefix, from a plain LiveKit publisher, is lossy under its own name. */
export function fromLivekitDataTrackName(livekitName: string): {
name: string;
reliable: boolean;
} {
if (livekitName.startsWith(reliablePrefix))
return { name: livekitName.slice(reliablePrefix.length), reliable: true };
if (livekitName.startsWith(lossyPrefix))
return { name: livekitName.slice(lossyPrefix.length), reliable: false };
return { name: livekitName, reliable: false };
}
+39 -2
View File
@@ -8,9 +8,9 @@ Please see LICENSE in the repository root for full details.
import { BehaviorSubject } from "rxjs";
import { describe, expect, it } from "vitest";
import { type MediaTrack } from "../media-api";
import { type DataTrack, type MediaTrack } from "../media-api";
import { testScope } from "./test";
import { trackBySource$ } from "./tracks";
import { dataTrackByName$, trackBySource$ } from "./tracks";
const camera = { source: "camera", kind: "video", id: "1" } as MediaTrack;
const microphone = {
@@ -39,3 +39,40 @@ describe("trackBySource$", () => {
expect(emissions).toEqual([camera]);
});
});
const chat = {
source: "data",
kind: "data",
id: "3",
name: "chat",
} as DataTrack;
const cursors = {
source: "data",
kind: "data",
id: "4",
name: "cursors",
} as DataTrack;
describe("dataTrackByName$", () => {
it("picks the data track of a name and follows the list", () => {
const tracks$ = new BehaviorSubject<(MediaTrack | DataTrack)[] | null>(
null,
);
const chat$ = dataTrackByName$(testScope(), tracks$, "chat");
expect(chat$.value).toBeUndefined();
tracks$.next([camera, cursors, chat]);
expect(chat$.value).toBe(chat);
tracks$.next([camera, cursors]);
expect(chat$.value).toBeUndefined();
});
it("does not emit when the list changes elsewhere", () => {
const tracks$ = new BehaviorSubject<(MediaTrack | DataTrack)[]>([chat]);
const chat$ = dataTrackByName$(testScope(), tracks$, "chat");
const emissions: unknown[] = [];
chat$.subscribe((t) => emissions.push(t));
tracks$.next([chat, camera]);
tracks$.next([microphone, chat, cursors]);
expect(emissions).toEqual([chat]);
});
});
+20 -1
View File
@@ -11,6 +11,7 @@ import { type Behavior } from "../reactive/Behavior";
import { type ObservableScope } from "../reactive/ObservableScope";
import {
type AudioMediaTrack,
type DataTrack,
type MediaSource,
type MediaTrack,
type VideoMediaTrack,
@@ -27,7 +28,7 @@ export type TrackOfSource<S extends MediaSource> = S extends
/** The member's first track of a source; undefined while there is none, or no media yet. */
export function trackBySource$<S extends MediaSource>(
scope: ObservableScope,
tracks$: Behavior<MediaTrack[] | null>,
tracks$: Behavior<readonly (MediaTrack | DataTrack)[] | null>,
source: S,
): Behavior<TrackOfSource<S> | undefined> {
return scope.behavior(
@@ -42,3 +43,21 @@ export function trackBySource$<S extends MediaSource>(
),
);
}
/** The member's data track of this name; undefined while there is none, or no media yet. */
export function dataTrackByName$(
scope: ObservableScope,
tracks$: Behavior<readonly (MediaTrack | DataTrack)[] | null>,
name: string,
): Behavior<DataTrack | undefined> {
return scope.behavior(
tracks$.pipe(
map((tracks) =>
tracks?.find(
(t): t is DataTrack => t.kind === "data" && t.name === name,
),
),
distinctUntilChanged(),
),
);
}
@@ -90,42 +90,3 @@ Please see LICENSE in the repository root for full details.
text-align: center;
overflow-wrap: anywhere;
}
.removable {
position: relative;
display: flex;
min-inline-size: 0;
}
.removable > .tile {
flex: 1;
}
/* Clear of the target for choosing the tile. */
.remove {
position: absolute;
inset-block-start: 0;
inset-inline-end: 0;
translate: 20% -20%;
display: none;
align-items: center;
justify-content: center;
inline-size: 28px;
block-size: 28px;
border-radius: 50%;
background: var(--cpd-color-bg-canvas-default);
color: var(--cpd-color-icon-critical-primary);
box-shadow: 0 1.2px 2.4px 0 rgb(0 0 0 / 15%);
cursor: pointer;
}
.remove:hover {
background: var(--cpd-color-bg-critical-subtle-hovered);
box-shadow: 0 0 0 var(--cpd-border-width-1)
var(--cpd-color-border-critical-subtle);
}
.removable:hover .remove,
.removable:focus-within .remove {
display: flex;
}
+9 -82
View File
@@ -5,16 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, type KeyboardEvent, type ReactElement } from "react";
import { type FC, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { InlineSpinner, MenuItem, Tooltip } from "@vector-im/compound-web";
import { InlineSpinner, MenuItem } from "@vector-im/compound-web";
import {
BlockIcon,
BlurIcon,
CheckCircleSolidIcon,
CloseIcon,
PlusIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./BackgroundEffectGrid.module.css";
@@ -29,8 +27,6 @@ export interface BackgroundEffectGridItem {
kind: "none" | "blur" | "image";
/** For kind "image". */
imageUrl?: string;
/** Whether it is the user's to remove. */
removable?: boolean;
}
interface Props {
@@ -42,12 +38,6 @@ interface Props {
settling?: boolean;
/** Id of the text saying what the user should know before choosing. */
describedBy?: string;
/** Offers to add a background of one's own. Omit to leave that tile out. */
onAdd?: () => void;
addLabel?: string;
/** Removes a removable one, never the one in force. Omit to offer none. */
onRemove?: (id: string) => void;
removeLabel?: string;
}
/**
@@ -61,10 +51,6 @@ export const BackgroundEffectGrid: FC<Props> = ({
onSelect,
settling = false,
describedBy,
onAdd,
addLabel,
onRemove,
removeLabel,
}) => {
const { t } = useTranslation();
const choose = (id: string): void => {
@@ -80,30 +66,7 @@ export const BackgroundEffectGrid: FC<Props> = ({
: t("background_effects.numbered", { n: ++pictures });
const checked = effect.id === selected;
const disabled = onSelect === undefined && effect.kind !== "none";
const remove =
effect.removable && !checked && onRemove !== undefined
? (): void => onRemove(effect.id)
: undefined;
const removeProps =
remove === undefined
? {}
: {
"aria-keyshortcuts": "Delete",
onKeyDown: (e: KeyboardEvent): void => {
if (e.key !== "Delete" && e.key !== "Backspace") return;
e.preventDefault();
remove();
},
};
const content = (
<TileContent
effect={effect}
label={label}
checked={checked}
settling={checked && settling}
/>
);
const tile = (
return (
<MenuItem
key={effect.id}
role="menuitemradio"
@@ -125,57 +88,21 @@ export const BackgroundEffectGrid: FC<Props> = ({
choose(effect.id);
}
}
{...removeProps}
>
{content}
<TileContent
effect={effect}
label={label}
checked={checked}
settling={checked && settling}
/>
</MenuItem>
);
if (remove === undefined) return tile;
// Beside the item, not in it: inside, the item takes the press first and
// the tile is chosen instead.
return (
<div key={effect.id} className={styles.removable}>
{tile}
<Tooltip label={removeLabel ?? ""}>
{/* Not a control of its own, so the menu keeps one keyboard model:
the keyboard reaches the same action through Delete. */}
<span aria-hidden className={styles.remove} onClick={remove}>
<CloseIcon width={20} height={20} />
</span>
</Tooltip>
</div>
);
});
// A command rather than a choice, so an item beside the radios.
const addContent = (
<>
<span aria-hidden className={styles.swatch}>
<PlusIcon width={24} height={24} />
</span>
<VisuallyHidden>{addLabel}</VisuallyHidden>
</>
);
const addTile =
onAdd === undefined ? null : (
<MenuItem
label={null}
hideChevron
className={styles.tile}
onSelect={(e) => {
e.preventDefault();
onAdd();
}}
>
{addContent}
</MenuItem>
);
return (
// A group of its own, as the section's can't carry the description.
<div role="group" aria-describedby={describedBy} className={styles.grid}>
{tiles}
{addTile}
</div>
);
};
+2 -6
View File
@@ -127,20 +127,16 @@ export const Default: Story = {
selectBackgroundEffect: fn(),
backgroundEffect: "blur",
backgroundEffects: [
{ id: "none", kind: "none", removable: false },
{ id: "blur", kind: "blur", removable: false },
{ id: "none", kind: "none" },
{ id: "blur", kind: "blur" },
...shippedBackgrounds.map((background) => ({
id: `image:${background.id}`,
kind: "image" as const,
imageUrl: background.imagePath,
removable: false,
})),
],
backgroundEffectNotice: undefined,
backgroundEffectSettling: false,
backgroundImageRefusal: undefined,
addBackgroundImage: fn(),
removeBackgroundEffect: fn(),
hangup: fn(),
buttonSize: "lg",
showFooter: true,
-31
View File
@@ -33,11 +33,6 @@ import { useBehavior } from "../useBehavior";
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
import { LayoutSwitch } from "../room/LayoutSwitch";
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
import { type UnusableReason } from "../tracks/backgroundImages";
export interface BackgroundImageRefusal {
reason: UnusableReason | "not-kept";
}
export interface AudioOutputSwitcher {
targetOutput: string;
@@ -65,10 +60,6 @@ export interface FooterActions {
toggleVideo: (() => void) | undefined;
/** Undefined where background effects can't be chosen. */
selectBackgroundEffect: ((id: string) => void) | undefined;
/** Undefined where no background can be added. */
addBackgroundImage: ((file: File) => void) | undefined;
/** Removes an added background by its option id; never the one in force. */
removeBackgroundEffect: ((id: string) => void) | undefined;
toggleScreenSharing: (() => void) | undefined;
/** Also controls if the settings button is visible */
openSettings: (() => void) | undefined;
@@ -90,8 +81,6 @@ export interface FooterState {
backgroundEffectNotice: "unavailable" | "slow" | undefined;
/** Whether the first effect chosen is still being prepared. */
backgroundEffectSettling: boolean;
/** Why the last file offered couldn't be kept, if it couldn't. */
backgroundImageRefusal: BackgroundImageRefusal | undefined;
showFooter: boolean;
/* This is needed for WindowMode = "flat" */
@@ -179,23 +168,6 @@ export const CallFooter: FC<FooterProps> = ({
const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
const backgroundEffectNotice = useBehavior(vm.backgroundEffectNotice$);
const backgroundEffectSettling = useBehavior(vm.backgroundEffectSettling$);
const addBackgroundImage = useBehavior(vm.addBackgroundImage$);
const removeBackgroundEffect = useBehavior(vm.removeBackgroundEffect$);
const refusal = useBehavior(vm.backgroundImageRefusal$);
const refusalMessage = useMemo(() => {
switch (refusal?.reason) {
case undefined:
return undefined;
case "not-an-image":
return { text: t("error.background_not_an_image") };
case "animated":
return { text: t("error.background_animated") };
case "undecodable":
return { text: t("error.background_undecodable") };
case "not-kept":
return { text: t("error.background_not_kept") };
}
}, [refusal, t]);
const backgroundEffects = useBehavior(vm.backgroundEffects$);
const buttonSize = useBehavior(vm.buttonSize$);
const showLogo = useBehavior(vm.showLogo$);
@@ -262,9 +234,6 @@ export const CallFooter: FC<FooterProps> = ({
selectedBackgroundEffect={backgroundEffect}
onSelectBackgroundEffect={selectBackgroundEffect}
backgroundEffectSettling={backgroundEffectSettling}
onAddBackgroundImage={addBackgroundImage}
onRemoveBackgroundEffect={removeBackgroundEffect}
backgroundImageRefusal={refusalMessage}
backgroundEffectNotice={
backgroundEffectNotice === "unavailable"
? t("background_effects.unavailable")
+4 -101
View File
@@ -8,12 +8,7 @@ Please see LICENSE in the repository root for full details.
import { afterEach, describe, expect, it, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import {
flushPromises,
testScope,
mockMuteStates,
mockMediaDevices,
} from "../utils/test";
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
import { constant } from "../state/Behavior";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import type { Layout } from "../state/layout-types";
@@ -26,13 +21,6 @@ import {
import { HeaderStyle } from "../UrlParams";
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
import type * as BackgroundImages from "../tracks/backgroundImages";
import {
type AddedBackground,
addedBackgrounds,
maxAddedBackgrounds,
UnusableImage,
} from "../tracks/backgroundImages";
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({
@@ -49,22 +37,6 @@ vi.mock("@livekit/track-processors", () => ({
supportsModernBackgroundProcessors: (): boolean => modernRouteMock(),
}));
const store = vi.hoisted(() => ({
add: vi.fn<(file: Blob) => Promise<string>>(),
remove: vi.fn<(id: string) => Promise<void>>(),
}));
vi.mock("../tracks/backgroundImages", async (original) => {
const { BehaviorSubject } = await import("rxjs");
return {
...(await original<typeof BackgroundImages>()),
addedBackgrounds: {
added$: new BehaviorSubject<AddedBackground[] | undefined>([]),
add: store.add,
remove: store.remove,
},
};
});
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
vi.mock("livekit-client", () => ({
supportsAudioOutputSelection: (): boolean => outputSelectionMock(),
@@ -291,7 +263,6 @@ describe("createCallFooterViewModel", () => {
it("offers nothing where the browser itself cannot run them", () => {
sdkSupportMock.mockReturnValue(false);
expect(lobbyOn("desktop").selectBackgroundEffect$.value).toBeUndefined();
expect(lobbyOn("desktop").addBackgroundImage$.value).toBeUndefined();
});
it("puts no effect in force where the browser cannot run them", () => {
@@ -322,29 +293,17 @@ describe("createCallFooterViewModel", () => {
expect(lobbyOn("desktop").backgroundEffectNotice$.value).toBeUndefined();
});
it("offers every effect in order, the added ones removable", () => {
it("offers every effect in order", () => {
sdkSupportMock.mockReturnValue(true);
const added$ = addedBackgrounds.added$ as BehaviorSubject<
AddedBackground[] | undefined
>;
added$.next([{ id: "mine", url: "blob:mine" }]);
expect(lobbyOn("desktop").backgroundEffects$.value).toEqual([
{ id: "none", kind: "none", removable: false },
{ id: "blur", kind: "blur", removable: false },
{ id: "none", kind: "none" },
{ id: "blur", kind: "blur" },
...shippedBackgrounds.map((background) => ({
id: `image:${background.id}`,
kind: "image",
imageUrl: background.imagePath,
removable: false,
})),
{
id: "added:mine",
kind: "image",
imageUrl: "blob:mine",
removable: true,
},
]);
added$.next([]);
});
it("says they are unavailable where they cannot be chosen", () => {
@@ -371,62 +330,6 @@ describe("createCallFooterViewModel", () => {
expect(vm.backgroundEffectSettling$.value).toBe(false);
});
it("adding is unavailable at the limit", () => {
sdkSupportMock.mockReturnValue(true);
const added$ = addedBackgrounds.added$ as BehaviorSubject<
AddedBackground[] | undefined
>;
const vm = lobbyOn("desktop");
expect(vm.addBackgroundImage$.value).toBeDefined();
added$.next(
Array.from({ length: maxAddedBackgrounds }, (_, i) => ({
id: `${i}`,
url: `blob:${i}`,
})),
);
expect(vm.addBackgroundImage$.value).toBeUndefined();
added$.next(added$.value!.slice(1));
expect(vm.addBackgroundImage$.value).toBeDefined();
added$.next([]);
});
it("says why each refused file was refused", async () => {
sdkSupportMock.mockReturnValue(true);
const vm = lobbyOn("desktop");
store.add.mockRejectedValue(new UnusableImage("animated"));
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
await flushPromises();
const first = vm.backgroundImageRefusal$.value;
expect(first).toEqual({ reason: "animated" });
// The same again is a refusal of its own, so it is shown again.
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
await flushPromises();
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "animated" });
expect(vm.backgroundImageRefusal$.value).not.toBe(first);
store.add.mockRejectedValue(new Error("quota"));
vm.addBackgroundImage$.value!(new File(["x"], "b.png"));
await flushPromises();
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "not-kept" });
store.add.mockResolvedValue("kept");
vm.addBackgroundImage$.value!(new File(["x"], "c.png"));
await flushPromises();
expect(vm.backgroundImageRefusal$.value).toBeUndefined();
});
it("never removes the background in force", () => {
sdkSupportMock.mockReturnValue(true);
store.remove.mockResolvedValue();
const vm = lobbyOn("desktop");
backgroundEffectSetting.setValue("added:worn");
vm.removeBackgroundEffect$.value!("added:worn");
expect(store.remove).not.toHaveBeenCalled();
vm.removeBackgroundEffect$.value!("added:spare");
expect(store.remove).toHaveBeenCalledWith("spare");
});
it("availability is the same before and during a call", () => {
for (const supported of [true, false]) {
sdkSupportMock.mockReturnValue(supported);
+5 -71
View File
@@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details.
*/
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
map,
@@ -18,19 +17,12 @@ import {
supportsBackgroundProcessors,
supportsModernBackgroundProcessors,
} from "@livekit/track-processors";
import { logger } from "matrix-js-sdk/lib/logger";
import {
parseEffect,
serializeEffect,
shippedBackgrounds,
} from "../tracks/backgroundEffectCatalog";
import {
type AddedBackground,
addedBackgrounds,
maxAddedBackgrounds,
UnusableImage,
} from "../tracks/backgroundImages";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
@@ -45,7 +37,7 @@ import { type MuteStates } from "../state/MuteStates";
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
import { HeaderStyle } from "../UrlParams";
import { platform } from "../Platform";
import { type BackgroundImageRefusal, type FooterSnapshot } from "./CallFooter";
import { type FooterSnapshot } from "./CallFooter";
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
/**
@@ -104,9 +96,6 @@ function buildDeviceBehaviors(
| "backgroundEffects$"
| "backgroundEffectNotice$"
| "backgroundEffectSettling$"
| "addBackgroundImage$"
| "backgroundImageRefusal$"
| "removeBackgroundEffect$"
> {
const options$ = (
available$: Behavior<Map<string, MenuOptions["label"]>>,
@@ -128,10 +117,6 @@ function buildDeviceBehaviors(
const offered$ = disableSwitcher$.pipe(
map((switcherDisabled) => !switcherDisabled && supported),
);
// A new object for each refusal, so the same one twice is shown twice.
const refusal$ = new BehaviorSubject<BackgroundImageRefusal | undefined>(
undefined,
);
return {
audioOptions$: scope.behavior(options$(mediaDevices.audioInput.available$)),
selectedAudio$: scope.behavior(
@@ -173,11 +158,7 @@ function buildDeviceBehaviors(
),
),
),
backgroundEffects$: scope.behavior(
addedBackgrounds.added$.pipe(
map((added) => backgroundEffectChoices(added ?? [])),
),
),
backgroundEffects$: constant(backgroundEffectChoices()),
backgroundEffectNotice$: scope.behavior(
offered$.pipe(
map((offered) =>
@@ -189,44 +170,6 @@ function buildDeviceBehaviors(
backgroundEffectSettling$.pipe(distinctUntilChanged()),
false,
),
// Kept and offered, not put on: that waits for the user to choose it.
addBackgroundImage$: scope.behavior(
combineLatest([offered$, addedBackgrounds.added$]).pipe(
map(([offered, added]) =>
offered && (added?.length ?? 0) < maxAddedBackgrounds
? (file: File): void => {
refusal$.next(undefined);
addedBackgrounds.add(file).catch((e) => {
logger.warn("Could not keep that background", e);
refusal$.next({
reason: e instanceof UnusableImage ? e.reason : "not-kept",
});
});
}
: undefined,
),
),
),
backgroundImageRefusal$: refusal$,
// Never the one in force: taking it away would leave nothing chosen.
removeBackgroundEffect$: scope.behavior(
offered$.pipe(
map((offered) =>
offered
? (optionId: string): void => {
const effect = parseEffect(optionId);
if (effect.kind !== "added") return;
if (backgroundEffectSetting.getValue() === optionId) return;
addedBackgrounds
.remove(effect.id)
.catch((e) =>
logger.warn("Could not remove that background", e),
);
}
: undefined,
),
),
),
};
}
@@ -394,23 +337,14 @@ export function createLobbyFooterViewModel(
};
}
function backgroundEffectChoices(
added: AddedBackground[],
): BackgroundEffectGridItem[] {
function backgroundEffectChoices(): BackgroundEffectGridItem[] {
return [
{ id: serializeEffect({ kind: "none" }), kind: "none", removable: false },
{ id: serializeEffect({ kind: "blur" }), kind: "blur", removable: false },
{ id: serializeEffect({ kind: "none" }), kind: "none" },
{ id: serializeEffect({ kind: "blur" }), kind: "blur" },
...shippedBackgrounds.map((background) => ({
id: serializeEffect({ kind: "shipped", id: background.id }),
kind: "image" as const,
imageUrl: background.imagePath,
removable: false,
})),
...added.map((background) => ({
id: serializeEffect({ kind: "added", id: background.id }),
kind: "image" as const,
imageUrl: background.url,
removable: true,
})),
];
}
@@ -93,7 +93,3 @@ Please see LICENSE in the repository root for full details.
flex-shrink: 0;
color: var(--cpd-color-icon-secondary);
}
.refusal {
margin: var(--cpd-space-2x) var(--cpd-space-4x) 0;
}
@@ -198,7 +198,6 @@ export const VideoUnmute: Story = {
backgroundEffects: effects,
selectedBackgroundEffect: "none",
onSelectBackgroundEffect: fn(),
onAddBackgroundImage: fn(),
},
};
@@ -242,11 +241,6 @@ export const BackgroundEffects: Story = {
true,
);
const add = within(section).getByRole("menuitem", { name: "Add image" });
await expect(
tiles[3].compareDocumentPosition(add) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
await userEvent.click(menu.getByRole("menuitemradio", { name: "Blur" }));
await expect(args.onSelectBackgroundEffect).toHaveBeenCalledWith("blur");
await expect(menu.getByRole("menu")).toBeVisible();
@@ -347,160 +341,6 @@ export const BackgroundEffectsSettling: Story = {
},
};
/** A file that can't be used: said where it was chosen, and nothing changes. */
export const BackgroundImageRefused: Story = {
args: {
...VideoUnmute.args,
selectedBackgroundEffect: "blur",
backgroundEffectNotice:
"Background effects run slowly on this platform, which may cause your video to stutter.",
backgroundImageRefusal: { text: "That file is not a supported image" },
},
decorators: [
(Story): JSX.Element => (
<WithACallArea blockSize={300}>
<Story />
</WithACallArea>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const menu = within(document.body);
const message = await menu.findByText("That file is not a supported image");
const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`,
)!;
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
const scrollport = list.getBoundingClientRect();
const shown = message.getBoundingClientRect();
await expect(shown.bottom).toBeLessThanOrEqual(scrollport.bottom + 1);
await expect(shown.top).toBeGreaterThanOrEqual(scrollport.top - 1);
await expect(menu.queryByText(/run slowly/)).toBeNull();
await expect(
menu.getByRole("menuitemradio", { name: "Blur" }),
).toHaveAttribute("aria-checked", "true");
},
};
/** A flat picture, for backgrounds of one's own. */
function swatch(colour: string): string {
return `data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="9"><rect width="16" height="9" fill="${colour}"/></svg>`,
)}`;
}
/** One's own backgrounds are removable, never the one in force. */
export const AddedBackgroundsCanBeRemoved: Story = {
args: {
...VideoUnmute.args,
backgroundEffects: [
...effects,
{
id: "added:one",
kind: "image",
imageUrl: swatch("#c2410c"),
removable: true,
},
{
id: "added:two",
kind: "image",
imageUrl: swatch("#1d4ed8"),
removable: true,
},
],
selectedBackgroundEffect: "added:one",
onRemoveBackgroundEffect: fn(),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const body = within(document.body);
const inForce = await body.findByRole("menuitemradio", {
name: "Background 3",
});
await expect(inForce).not.toHaveAttribute("aria-keyshortcuts");
const other = body.getByRole("menuitemradio", { name: "Background 4" });
await expect(other).toHaveAttribute("aria-keyshortcuts", "Delete");
other.focus();
await userEvent.keyboard("{Delete}");
await expect(args.onRemoveBackgroundEffect).toHaveBeenCalledWith(
"added:two",
);
const wrapper = other.parentElement!;
await userEvent.hover(wrapper);
const cross = wrapper.querySelector<HTMLElement>(
"[aria-hidden]:last-child",
)!;
const probe = document.createElement("span");
probe.style.color = "var(--cpd-color-icon-critical-primary)";
cross.append(probe);
const critical = getComputedStyle(probe).color;
probe.remove();
await expect(getComputedStyle(cross).color).toBe(critical);
const tile = other.getBoundingClientRect();
const box = cross.getBoundingClientRect();
await expect(box.right).toBeGreaterThan(tile.right);
await expect(box.top).toBeLessThan(tile.top);
await userEvent.hover(cross);
await waitFor(async () =>
expect(
[...document.body.querySelectorAll("div")].some(
(d) => d.textContent === "Remove" && d.offsetParent !== null,
),
).toBe(true),
);
},
};
/** Removing by pointer while the selection really changes. */
export const RemovingWithLiveSelection: Story = {
args: AddedBackgroundsCanBeRemoved.args,
render: function WithLiveSelection(args): JSX.Element {
const [selected, setSelected] = useState("none");
const [offered, setOffered] = useState(args.backgroundEffects ?? []);
return (
<MediaMuteAndSwitchButton
{...args}
backgroundEffects={offered}
selectedBackgroundEffect={selected}
onSelectBackgroundEffect={setSelected}
onRemoveBackgroundEffect={(id): void =>
setOffered((current) => current.filter((o) => o.id !== id))
}
/>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const body = within(document.body);
const tile = await body.findByRole("menuitemradio", {
name: "Background 4",
});
await userEvent.hover(tile);
const cross = tile.parentElement!.querySelector<HTMLElement>(
"[aria-hidden]:last-child",
)!;
await expect(cross).toBeVisible();
await userEvent.click(cross);
await waitFor(async () =>
expect(
body.queryByRole("menuitemradio", { name: "Background 4" }),
).toBeNull(),
);
await expect(
body.getByRole("menuitemradio", { name: "None" }),
).toHaveAttribute("aria-checked", "true");
},
};
/** In a short call the effects scroll into view with the list. */
export const BackgroundEffectsScrollWhenTheyDoNotFit: Story = {
args: {
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, describe, expect, onTestFinished, test, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { axe } from "vitest-axe";
import {
act,
@@ -367,120 +367,6 @@ describe("MediaMuteAndSwitchButton", () => {
expect(onSelectBackgroundEffect).not.toHaveBeenCalled();
});
test("added images are chosen like shipped ones", async () => {
const user = userEvent.setup();
const onSelectBackgroundEffect = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="video"
enabled={true}
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
selectedOption="cam1"
backgroundEffects={[
...effects,
{
id: "added:mine",
kind: "image",
imageUrl: "",
},
]}
selectedBackgroundEffect="none"
onSelectBackgroundEffect={onSelectBackgroundEffect}
/>,
);
await user.click(getByRole("button", { name: "Camera" }));
const section = screen.getByRole("group", { name: "Background effects" });
await user.click(
within(section).getByRole("menuitemradio", { name: "Background 3" }),
);
expect(onSelectBackgroundEffect).toHaveBeenCalledWith("added:mine");
});
test("adds the file chosen from the add tile, and stays open", async () => {
const user = userEvent.setup();
const onAddBackgroundImage = vi.fn();
const pick = vi
.spyOn(HTMLInputElement.prototype, "click")
.mockImplementation(() => {});
try {
const { getByRole, container } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="video"
enabled={true}
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
selectedOption="cam1"
backgroundEffects={effects}
selectedBackgroundEffect="none"
onSelectBackgroundEffect={vi.fn()}
onAddBackgroundImage={onAddBackgroundImage}
/>,
);
await user.click(getByRole("button", { name: "Camera" }));
await user.click(screen.getByRole("menuitem", { name: "Add image" }));
expect(pick).toHaveBeenCalled();
// The picker takes the focus, which would otherwise close the menu.
await user.keyboard("{Escape}");
expect(screen.getByRole("menu")).toBeInTheDocument();
const file = new File(["x"], "mine.png", { type: "image/png" });
// What the picker hands back; the open menu blocks pointer events.
fireEvent.change(
container.querySelector<HTMLInputElement>("input[type=file]")!,
{ target: { files: [file] } },
);
expect(onAddBackgroundImage).toHaveBeenCalledWith(file);
screen.getByRole("menuitemradio", { name: "None", checked: true });
} finally {
pick.mockRestore();
}
});
test("a refusal can be dismissed and does not outlive the menu", async () => {
const user = userEvent.setup();
// jsdom lays nothing out, so has nothing to scroll.
const scroll = Element.prototype.scrollIntoView;
Element.prototype.scrollIntoView = vi.fn();
onTestFinished(() => {
Element.prototype.scrollIntoView = scroll;
});
const menu = (refusal?: { text: string }): JSX.Element =>
withProviders(
<MediaMuteAndSwitchButton
iconsAndLabels="video"
enabled={true}
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
selectedOption="cam1"
backgroundEffects={effects}
selectedBackgroundEffect="blur"
onSelectBackgroundEffect={vi.fn()}
onAddBackgroundImage={vi.fn()}
backgroundImageRefusal={refusal}
/>,
);
const { rerender, getByRole } = render(menu());
await user.click(getByRole("button", { name: "Camera" }));
rerender(menu({ text: "That file is not a supported image" }));
expect(
screen.getByText("That file is not a supported image"),
).toBeVisible();
screen.getByRole("menuitemradio", { name: "Blur", checked: true });
await user.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByText("That file is not a supported image")).toBeNull();
// The next refusal is shown, even if it says the same.
rerender(menu({ text: "That file is not a supported image" }));
expect(
screen.getByText("That file is not a supported image"),
).toBeVisible();
await user.keyboard("{Escape}");
await user.click(getByRole("button", { name: "Camera" }));
expect(screen.queryByText("That file is not a supported image")).toBeNull();
});
test("offers the background effects in a phone's drawer", async () => {
const userAgent = vi
.spyOn(navigator, "userAgent", "get")
+8 -90
View File
@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
import {
useCallback,
useId,
useRef,
useState,
type CSSProperties,
type FC,
@@ -16,7 +15,6 @@ import {
type ReactElement,
} from "react";
import {
Alert,
Button,
Menu,
MenuItem,
@@ -81,12 +79,6 @@ export interface MediaMuteAndSwitchButtonProps {
backgroundEffectNotice?: string;
/** Whether the first effect chosen is still being prepared. */
backgroundEffectSettling?: boolean;
/** Called with the file chosen from the add tile. Omit to leave it out. */
onAddBackgroundImage?: (file: File) => void;
/** Removes a removable effect, never the one in force. */
onRemoveBackgroundEffect?: (id: string) => void;
/** Why the last file offered couldn't be used; a new object each time. */
backgroundImageRefusal?: { text: string };
/**
* 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`
@@ -121,9 +113,6 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
onSelectBackgroundEffect,
backgroundEffectNotice,
backgroundEffectSettling,
onAddBackgroundImage,
onRemoveBackgroundEffect,
backgroundImageRefusal,
onSelect,
}) => {
// Requested but not yet selected. Keyed by kind too, since Chrome uses
@@ -133,42 +122,11 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
id: string;
} | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
// The file picker takes the focus, which the menu reads as a click
// elsewhere; it is held open until the picker is done.
const choosingFile = useRef(false);
const fileInput = useRef<HTMLInputElement | null>(null);
// A refusal is about the attempt that caused it: shown until dismissed or
// until the menu closes, and again for the next one.
const [seenRefusal, setSeenRefusal] = useState<object>();
const refusal =
backgroundImageRefusal !== seenRefusal ? backgroundImageRefusal : undefined;
const onOpenChange = useCallback(
(open: boolean): void => {
if (!open && choosingFile.current) return;
setMenuOpen(open);
// Drop a request that never arrived.
if (!open) setPlannedSelection(null);
if (!open) setSeenRefusal(backgroundImageRefusal);
},
[backgroundImageRefusal],
);
// Scrolled to as it appears, as the list may be scrolled away from it.
const scrollIntoView = useCallback((element: HTMLElement | null): void => {
element?.scrollIntoView({ block: "nearest" });
const onOpenChange = useCallback((open: boolean): void => {
setMenuOpen(open);
// Drop a request that never arrived.
if (!open) setPlannedSelection(null);
}, []);
const watchFileInput = useCallback(
(input: HTMLInputElement | null): (() => void) | undefined => {
fileInput.current = input;
if (input === null) return;
// Dismissing the picker fires cancel, which React doesn't type.
const done = (): void => {
choosingFile.current = false;
};
input.addEventListener("cancel", done);
return (): void => input.removeEventListener("cancel", done);
},
[],
);
const isBusy = busy ?? false;
const { t } = useTranslation();
const devices = useMediaDevices();
@@ -368,21 +326,6 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
>
{/* The mute button lives inside */}
{button}
{onAddBackgroundImage !== undefined && (
<input
ref={watchFileInput}
type="file"
accept="image/*"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
// Cleared, so the same file can be chosen twice in a row.
e.target.value = "";
choosingFile.current = false;
if (file) onAddBackgroundImage(file);
}}
/>
)}
<Menu
className={styles.menu}
// Named for screen readers only: each section has its own heading.
@@ -451,39 +394,14 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
describedBy={
backgroundEffectNotice === undefined ? undefined : noticeId
}
onAdd={
onAddBackgroundImage === undefined
? undefined
: (): void => {
choosingFile.current = true;
fileInput.current?.click();
}
}
addLabel={t("action.add_background_image")}
onRemove={onRemoveBackgroundEffect}
removeLabel={t("action.remove")}
/>
{/* In the list, so the menu grows no taller for it. */}
{refusal !== undefined && (
<div
ref={scrollIntoView}
role="none"
className={styles.refusal}
>
<Alert
type="critical"
title={refusal.text}
onClose={(): void => setSeenRefusal(refusal)}
/>
{backgroundEffectNotice !== undefined && (
<div id={noticeId} role="none" className={styles.notice}>
<InfoIcon width={20} height={20} />
<span>{backgroundEffectNotice}</span>
</div>
)}
{refusal === undefined &&
backgroundEffectNotice !== undefined && (
<div id={noticeId} role="none" className={styles.notice}>
<InfoIcon width={20} height={20} />
<span>{backgroundEffectNotice}</span>
</div>
)}
</MenuScrollSection>
)}
</MenuScrollArea>
-13
View File
@@ -95,19 +95,6 @@ describe("Config.initWith", () => {
expect(Config.get().ssla).toBe("https://example.invalid/ssla");
});
it("falls back to the defaults when fetching config.json rejects", async () => {
// A rejected promise is what a webview gives for a missing local file.
vi.spyOn(globalThis, "fetch").mockRejectedValue(
new TypeError("Load failed"),
);
vi.spyOn(logger, "info").mockImplementation(() => {});
Reflect.set(Config, "internalInstance", undefined);
await expect(Config.init()).resolves.toBeUndefined();
expect(Config.get().media_quality).toEqual(DEFAULT_CONFIG.media_quality);
});
it("replaces a config initialized earlier", () => {
Config.initWith({ ssla: "https://first.invalid/ssla" });
Config.initWith({ ssla: "https://second.invalid/ssla" });
+5 -12
View File
@@ -140,20 +140,13 @@ export function validateConfig(config: ConfigOptions): ConfigOptions {
}
async function downloadConfig(fetchTarget: string): Promise<ConfigOptions> {
let response: Response;
try {
response = await fetch(fetchTarget);
} catch (e) {
// The embedded package ships no config.json, and some webviews (Element X
// iOS) reject the fetch of a missing local file rather than answering 404.
logger.info(`No config at ${fetchTarget}, using defaults`, e);
// resolveConfig merges in the defaults.
return {};
}
const response = await fetch(fetchTarget);
if (isFailure(response)) {
// resolveConfig merges in the defaults.
return {};
// Lack of a config isn't an error, we should just use the defaults.
// Also treat a blank config as no config, assuming the status code is 0, because we don't get 404s from file:
// URIs so this is the only way we can not fail if the file doesn't exist when loading from a file:// URI.
return DEFAULT_CONFIG;
}
return response.json();
-68
View File
@@ -15,7 +15,6 @@ import {
type BackgroundEffectsOptions,
type BackgroundEffectsState,
} from "./BackgroundEffects";
import { type AddedBackground } from "../tracks/backgroundImages";
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
import { flushPromises, testScope, withTestScheduler } from "../utils/test";
@@ -70,8 +69,6 @@ describe("the pipeline's state", () => {
const effects = createBackgroundEffects(testScope(), {
supported: true,
effect$: behavior(effect, { n: "none", b: "blur" }),
setEffect: vi.fn(),
added$: new BehaviorSubject<AddedBackground[] | undefined>([]),
pipeline: fakePipeline().pipeline,
transformer,
});
@@ -97,8 +94,6 @@ describe("the pipeline's state", () => {
describe("background effects", () => {
let effect$: BehaviorSubject<string>;
let added$: BehaviorSubject<AddedBackground[] | undefined>;
let setEffect: (raw: string) => void;
let fake: ReturnType<typeof fakePipeline>;
function build(
@@ -107,8 +102,6 @@ describe("background effects", () => {
return createBackgroundEffects(testScope(), {
supported: true,
effect$,
setEffect,
added$,
pipeline: fake.pipeline,
transformer: { onFirstFrame: undefined },
...options,
@@ -123,8 +116,6 @@ describe("background effects", () => {
beforeEach(() => {
effect$ = new BehaviorSubject("none");
added$ = new BehaviorSubject<AddedBackground[] | undefined>(undefined);
setEffect = vi.fn((raw: string) => effect$.next(raw));
fake = fakePipeline();
});
@@ -178,63 +169,4 @@ 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();
});
it("puts an added background on as its picture", async () => {
added$.next([{ id: "mine", url: "blob:mine" }]);
build();
await choose("added:mine");
expect(fake.switches).toEqual([
{ mode: "virtual-background", imagePath: "blob:mine" },
]);
});
it("switches between any two effects in place", async () => {
added$.next([{ id: "mine", url: "blob:mine" }]);
const shipped = shippedBackgrounds[0];
const options: Record<string, unknown> = {
none: { mode: "disabled" },
blur: { mode: "background-blur", blurRadius: 15 },
[`image:${shipped.id}`]: {
mode: "virtual-background",
imagePath: shipped.imagePath,
},
"added:mine": { mode: "virtual-background", imagePath: "blob:mine" },
};
// Each kind to each other kind, and back.
const order = [
"blur",
`image:${shipped.id}`,
"added:mine",
"blur",
"added:mine",
`image:${shipped.id}`,
"none",
"added:mine",
"none",
"blur",
];
const effects = build();
await choose(order[0]);
const pipeline = effects.state$.value.processor;
for (const raw of order.slice(1)) await choose(raw);
expect(effects.state$.value.processor).toBe(pipeline);
expect(fake.switches).toEqual(order.map((raw) => options[raw]));
});
it("forgets an added background the device no longer keeps", () => {
effect$.next("added:gone");
build();
// Not before the device has said what it keeps.
expect(effect$.value).toBe("added:gone");
added$.next([]);
expect(effect$.value).toBe("none");
});
});
+10 -49
View File
@@ -27,11 +27,9 @@ import { type Behavior } from "./Behavior";
import { type ObservableScope } from "./ObservableScope";
import {
type BackgroundEffect,
type EffectId,
imagePathFor,
parseEffect,
} from "../tracks/backgroundEffectCatalog";
import { type AddedBackground } from "../tracks/backgroundImages";
const blurRadius = 15;
@@ -47,10 +45,6 @@ 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 backgrounds the device keeps, undefined until it has said. */
added$: Behavior<AddedBackground[] | undefined>;
/** The background processor pipeline to be switched between effects. */
pipeline: BackgroundProcessorWrapper;
/** Tells, once, that a frame carrying an effect has been drawn. */
@@ -65,14 +59,7 @@ export interface BackgroundEffects {
/** Switches the pipeline as the choice changes, for as long as the scope lasts. */
export function createBackgroundEffects(
scope: ObservableScope,
{
supported,
effect$,
setEffect,
added$,
pipeline,
transformer,
}: BackgroundEffectsOptions,
{ supported, effect$, pipeline, transformer }: BackgroundEffectsOptions,
): BackgroundEffects {
// Wrapped once, so the call and the preview see one processor throughout.
const processor = fromLivekitProcessor(pipeline);
@@ -82,19 +69,6 @@ export function createBackgroundEffects(
distinctUntilChanged(),
);
combineLatest([choice$, added$])
.pipe(scope.bind())
.subscribe(([effect, added]) => {
if (effect.kind === "none") return;
if (
!supported ||
(effect.kind === "added" &&
added !== undefined &&
!added.some(({ id }) => id === effect.id))
)
setEffect("none");
});
const drewAFrame$ = scope.behavior(
new Observable<boolean>((subscriber) => {
subscriber.next(false);
@@ -127,16 +101,9 @@ export function createBackgroundEffects(
const switchOptions$ = scope.behavior<
SwitchBackgroundProcessorOptions | undefined
>(
combineLatest([state$, choice$, added$]).pipe(
map(([{ processor }, effect, added]) =>
processor === undefined
? undefined
: switchOptionsFor(
effect,
effect.kind === "added"
? added?.find(({ id }) => id === effect.id)?.url
: undefined,
),
combineLatest([state$, choice$]).pipe(
map(([{ processor }, effect]) =>
processor === undefined ? undefined : switchOptionsFor(effect),
),
distinctUntilChanged(deepCompare),
),
@@ -154,24 +121,18 @@ export function createBackgroundEffects(
return { state$ };
}
/** `addedUrl` is the picture of the added background chosen, if still kept. */
function switchOptionsFor(
effect: BackgroundEffect,
addedUrl: string | undefined,
): SwitchBackgroundProcessorOptions {
const withImage = (
imagePath: string | undefined,
): SwitchBackgroundProcessorOptions =>
imagePath
? { mode: "virtual-background", imagePath }
: { mode: "disabled" };
switch (effect.kind) {
case "blur":
return { mode: "background-blur", blurRadius };
case "shipped":
return withImage(imagePathFor(effect.id));
case "added":
return withImage(addedUrl);
case "shipped": {
const imagePath = imagePathFor(effect.id);
return imagePath
? { mode: "virtual-background", imagePath }
: { mode: "disabled" };
}
default:
return { mode: "disabled" };
}
+7 -2
View File
@@ -494,7 +494,12 @@ export function createCallViewModel$(
>(
rtcParticipation.localMember$.pipe(
switchMap((member) => member?.tracks$ ?? of(null)),
map((tracks) => tracks ?? []),
map((tracks) =>
(tracks ?? []).filter(
(t): t is LocalAudioMediaTrack | LocalVideoMediaTrack =>
t.kind !== "data",
),
),
),
);
// Our own tracks, which carry the controls remote ones lack
@@ -1454,7 +1459,7 @@ export function createCallViewModel$(
source: "screenShare",
capture: capture.screenShare,
})
: rtcParticipation.unpublish("screenShare")
: rtcParticipation.unpublish(track)
).catch((e: unknown) => {
logger.error(
`Screen share ${track === undefined ? "start" : "stop"} failed`,
+1 -12
View File
@@ -19,10 +19,8 @@ import {
type BackgroundEffectsState,
} from "../state/BackgroundEffects";
import { backgroundEffect } from "../settings/settings";
import { type AddedBackground } from "./backgroundImages";
import { flushPromises, testScope } from "../utils/test";
const sdkSupportMock = vi.hoisted(() => vi.fn(() => true));
const pipelines = vi.hoisted(() => ({
built: 0,
destroyed: 0,
@@ -43,18 +41,10 @@ vi.mock("@livekit/track-processors", () => ({
return Promise.resolve();
});
}),
supportsBackgroundProcessors: (): boolean => sdkSupportMock(),
supportsBackgroundProcessors: (): boolean => true,
}));
const platformMock = vi.hoisted(() => ({ platform: "desktop" }));
vi.mock("../Platform", () => platformMock);
vi.mock("./backgroundImages", async () => {
const { BehaviorSubject } = await import("rxjs");
return {
addedBackgrounds: {
added$: new BehaviorSubject<AddedBackground[] | undefined>(undefined),
},
};
});
vi.mock("./BackgroundEffectTransformer", () => ({
BackgroundEffectTransformer: vi.fn(),
}));
@@ -87,7 +77,6 @@ describe("BackgroundEffectsProvider", () => {
pipelines.destroyed = 0;
pipelines.switches = [];
platformMock.platform = "desktop";
sdkSupportMock.mockReturnValue(true);
backgroundEffect.setValue("none");
});
afterEach(() => backgroundEffect.setValue("none"));
-3
View File
@@ -13,7 +13,6 @@ import { createContext, type FC, type JSX, use } from "react";
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
import { BackgroundEffectTransformer } from "./BackgroundEffectTransformer";
import { addedBackgrounds } from "./backgroundImages";
import { type Behavior } from "../state/Behavior";
import { type ObservableScope } from "../state/ObservableScope";
import {
@@ -54,8 +53,6 @@ export function createAppBackgroundEffects(
return createBackgroundEffects(scope, {
supported: supportsBackgroundProcessors(),
effect$: backgroundEffectSetting.value$,
setEffect: backgroundEffectSetting.setValue,
added$: addedBackgrounds.added$,
pipeline: new BackgroundProcessorWrapper(transformer, "background-effect"),
transformer,
});
@@ -15,7 +15,6 @@ import {
serializeEffect,
shippedBackgrounds,
} from "./backgroundEffectCatalog";
import { maxAddedBackgrounds } from "./backgroundImages";
describe("the chosen background effect", () => {
test("round-trips through its stored form", () => {
@@ -23,7 +22,6 @@ describe("the chosen background effect", () => {
{ kind: "none" },
{ kind: "blur" },
...shippedBackgrounds.map(({ id }) => ({ kind: "shipped" as const, id })),
{ kind: "added", id: "d3b07384" },
];
for (const effect of effects)
expect(parseEffect(serializeEffect(effect))).toEqual(effect);
@@ -32,7 +30,6 @@ describe("the chosen background effect", () => {
test("falls back to no effect when the stored form names nothing we ship", () => {
expect(parseEffect("image:gone")).toEqual({ kind: "none" });
expect(parseEffect("")).toEqual({ kind: "none" });
expect(parseEffect("added:")).toEqual({ kind: "none" });
});
test("reads a stored value that is not a string as no effect", () => {
@@ -40,14 +37,6 @@ describe("the chosen background effect", () => {
expect(parseEffect(raw)).toEqual({ kind: "none" });
});
test("fills the grid at the limit and one short of it", () => {
// No effect, blur and the shipped ones, then the added ones.
const fixed = 2 + shippedBackgrounds.length;
expect((fixed + maxAddedBackgrounds) % 3).toBe(0);
// One short, the add tile takes the last place.
expect((fixed + maxAddedBackgrounds - 1 + 1) % 3).toBe(0);
});
test("gives every shipped background an image to draw", () => {
for (const { id } of shippedBackgrounds)
expect(imagePathFor(id)).toBeTruthy();
+4 -12
View File
@@ -22,18 +22,16 @@ export const shippedBackgrounds: ShippedBackground[] = [
export type BackgroundEffect =
| { kind: "none" }
| { kind: "blur" }
| { kind: "shipped"; id: string }
| { kind: "added"; id: string };
| { kind: "shipped"; id: string };
/** An effect as the setting stores it, and as the menu names its option. */
export type EffectId = "none" | "blur" | `image:${string}` | `added:${string}`;
export type EffectId = "none" | "blur" | `image:${string}`;
export const noEffect: BackgroundEffect = { kind: "none" };
/**
* Reads a stored effect; one we no longer ship reads as no effect. Whether an
* added one is still kept only the device knows. Stored by hand or by another
* build, the value need not even be a string.
* Reads a stored effect; one we no longer ship reads as no effect. Stored by
* hand or by another build, the value need not even be a string.
*/
export function parseEffect(raw: unknown): BackgroundEffect {
if (typeof raw !== "string") return noEffect;
@@ -43,10 +41,6 @@ export function parseEffect(raw: unknown): BackgroundEffect {
if (shippedBackgrounds.some((b) => b.id === id))
return { kind: "shipped", id };
}
if (raw.startsWith("added:")) {
const id = raw.slice("added:".length);
if (id) return { kind: "added", id };
}
return noEffect;
}
@@ -56,8 +50,6 @@ export function serializeEffect(effect: BackgroundEffect): EffectId {
return "blur";
case "shipped":
return `image:${effect.id}`;
case "added":
return `added:${effect.id}`;
default:
return "none";
}
-172
View File
@@ -1,172 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { IDBFactory } from "fake-indexeddb";
import {
AddedBackgrounds,
type BackgroundImageStorage,
IndexedDBImageStorage,
type KeptImage,
maxAddedBackgrounds,
prepareImage,
UnusableImage,
} from "./backgroundImages";
import { flushPromises } from "../utils/test";
describe("AddedBackgrounds", () => {
beforeEach(() => {
let n = 0;
vi.spyOn(URL, "createObjectURL").mockImplementation(() => `blob:${++n}`);
});
it("offers what the device keeps, oldest first", async () => {
const image = new Blob(["x"], { type: "image/png" });
const store = new AddedBackgrounds(
memoryStorage([
{ id: "later", image, addedAt: 2 },
{ id: "earlier", image, addedAt: 1 },
]),
asGiven,
);
expect(store.added$.value).toBeUndefined();
await flushPromises();
expect(store.added$.value?.map(({ id }) => id)).toEqual([
"earlier",
"later",
]);
});
it("keeps an added image and offers it after the others", async () => {
const storage = memoryStorage();
const store = new AddedBackgrounds(storage, asGiven);
await flushPromises();
const id = await store.add(new Blob(["x"], { type: "image/png" }));
expect(store.added$.value?.map((a) => a.id)).toEqual([id]);
expect((await storage.list()).map((k) => k.id)).toEqual([id]);
});
it("keeps the prepared image, not the file", async () => {
const storage = memoryStorage();
const prepared = new Blob(["y"], { type: "image/webp" });
const store = new AddedBackgrounds(storage, async () =>
Promise.resolve(prepared),
);
await store.add(new Blob(["x"], { type: "image/png" }));
expect((await storage.list())[0].image).toBe(prepared);
});
it("keeps nothing of a file it refuses", async () => {
const storage = memoryStorage();
const store = new AddedBackgrounds(storage, async () =>
Promise.reject(new UnusableImage("animated")),
);
await flushPromises();
await expect(store.add(new Blob(["x"]))).rejects.toThrow(UnusableImage);
expect(await storage.list()).toEqual([]);
expect(store.added$.value).toEqual([]);
});
it("keeps no more than the grid holds", async () => {
const storage = memoryStorage();
const store = new AddedBackgrounds(storage, asGiven);
for (let i = 0; i < maxAddedBackgrounds; i++)
await store.add(new Blob([`${i}`]));
await expect(store.add(new Blob(["more"]))).rejects.toThrow(RangeError);
expect(await storage.list()).toHaveLength(maxAddedBackgrounds);
});
it("forgets a removed image, and lets its picture go", async () => {
const revoke = vi
.spyOn(URL, "revokeObjectURL")
.mockImplementation(() => {});
const storage = memoryStorage();
const store = new AddedBackgrounds(storage, asGiven);
await flushPromises();
const first = await store.add(new Blob(["a"]));
const second = await store.add(new Blob(["b"]));
const url = store.added$.value![0].url;
await store.remove(first);
expect(store.added$.value?.map((a) => a.id)).toEqual([second]);
expect((await storage.list()).map((k) => k.id)).toEqual([second]);
expect(revoke).toHaveBeenCalledWith(url);
});
it("offers nothing where the browser keeps nothing", async () => {
const store = new AddedBackgrounds(null);
await flushPromises();
expect(store.added$.value).toEqual([]);
await expect(store.add(new Blob(["x"]))).rejects.toThrow();
});
it("offers none, rather than waiting, where the device can't be read", async () => {
const unreadable: BackgroundImageStorage = {
...memoryStorage(),
list: async () => Promise.reject(new Error("storage cleared")),
};
const store = new AddedBackgrounds(unreadable);
await flushPromises();
expect(store.added$.value).toEqual([]);
});
});
describe("prepareImage", () => {
// Refused before anything is decoded, which only a real browser could do.
it("refuses a file that isn't an image", async () => {
await expect(
prepareImage(new Blob(["x"], { type: "text/plain" })),
).rejects.toEqual(new UnusableImage("not-an-image"));
});
it("refuses an animated type where the browser can't count frames", async () => {
expect(globalThis.ImageDecoder).toBeUndefined();
await expect(
prepareImage(new Blob(["GIF89a"], { type: "image/gif" })),
).rejects.toEqual(new UnusableImage("animated"));
});
});
describe("IndexedDBImageStorage", () => {
it("keeps images across sessions until they are deleted", async () => {
const indexedDB = new IDBFactory();
const image = new Blob(["x"], { type: "image/webp" });
const first = new IndexedDBImageStorage(indexedDB);
await first.put({ id: "a", image, addedAt: 1 });
await first.put({ id: "b", image, addedAt: 2 });
// A session of its own, as after a reload.
const second = new IndexedDBImageStorage(indexedDB);
expect(
(await second.list()).map(({ id, addedAt }) => [id, addedAt]),
).toEqual([
["a", 1],
["b", 2],
]);
await second.delete("a");
expect((await first.list()).map(({ id }) => id)).toEqual(["b"]);
});
});
// Decoding needs a real browser; the end-to-end checks cover it.
const asGiven = async (file: Blob): Promise<Blob> => Promise.resolve(file);
function memoryStorage(kept: KeptImage[] = []): BackgroundImageStorage {
return {
list: async (): Promise<KeptImage[]> => Promise.resolve([...kept]),
put: async (image): Promise<void> => {
kept.push(image);
return Promise.resolve();
},
delete: async (id): Promise<void> => {
const index = kept.findIndex((k) => k.id === id);
if (index !== -1) kept.splice(index, 1);
return Promise.resolve();
},
};
}
-220
View File
@@ -1,220 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { BehaviorSubject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../state/Behavior";
export const maxAddedBackgrounds = 5;
/** The longest edge kept; the pipeline scales every image to the camera. */
export const maxStoredEdge = 1920;
/** Why a file can't be used as a background. */
export type UnusableReason = "not-an-image" | "animated" | "undecodable";
export class UnusableImage extends Error {
public constructor(public readonly reason: UnusableReason) {
super(reason);
this.name = "UnusableImage";
}
}
/** A background the user added, as the menu and the pipeline use it. */
export interface AddedBackground {
id: string;
url: string;
}
/** A background the user added, as it is kept on the device. */
export interface KeptImage {
id: string;
image: Blob;
addedAt: number;
}
/** Where added backgrounds are kept. */
export interface BackgroundImageStorage {
list(): Promise<KeptImage[]>;
put(image: KeptImage): Promise<void>;
delete(id: string): Promise<void>;
}
/** The backgrounds this device keeps, which never leave it. */
export class AddedBackgrounds {
private readonly subject$ = new BehaviorSubject<
AddedBackground[] | undefined
>(undefined);
/** Undefined until what the device keeps has been read. */
public readonly added$: Behavior<AddedBackground[] | undefined> =
this.subject$;
public constructor(
private readonly storage: BackgroundImageStorage | null,
private readonly prepare: (file: Blob) => Promise<Blob> = prepareImage,
) {
this.read().catch((e) => {
logger.warn("Could not read added backgrounds", e);
this.subject$.next([]);
});
}
private async read(): Promise<void> {
const kept = (await this.storage?.list()) ?? [];
kept.sort((a, b) => a.addedAt - b.addedAt);
this.subject$.next(
kept.map(({ id, image }) => ({ id, url: URL.createObjectURL(image) })),
);
}
/**
* Keeps a file as a background, and answers with its id. Throws
* {@link UnusableImage} for a file that can't be used, and a RangeError
* once maxAddedBackgrounds are kept.
*/
public async add(file: Blob): Promise<string> {
if (this.storage === null)
throw new Error("This browser keeps no backgrounds");
const image = await this.prepare(file);
if ((await this.storage.list()).length >= maxAddedBackgrounds)
throw new RangeError(
`${maxAddedBackgrounds} backgrounds are kept already`,
);
const kept: KeptImage = {
id: crypto.randomUUID(),
image,
addedAt: Date.now(),
};
await this.storage.put(kept);
this.subject$.next([
...(this.subject$.value ?? []),
{ id: kept.id, url: URL.createObjectURL(image) },
]);
return kept.id;
}
public async remove(id: string): Promise<void> {
await this.storage?.delete(id);
const current = this.subject$.value ?? [];
const gone = current.find((a) => a.id === id);
if (gone) URL.revokeObjectURL(gone.url);
this.subject$.next(current.filter((a) => a.id !== id));
}
}
/**
* Refuses what can't be used, reduces what is larger than is kept, and lays
* every image on an opaque ground: a background has to cover what is behind
* it, so a transparent pixel would be a hole in it.
*/
export async function prepareImage(file: Blob): Promise<Blob> {
if (!file.type.startsWith("image/")) throw new UnusableImage("not-an-image");
if (await isAnimated(file)) throw new UnusableImage("animated");
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(file);
} catch (e) {
logger.debug("Could not decode the chosen file", e);
throw new UnusableImage("undecodable");
}
try {
const scale = Math.min(
1,
maxStoredEdge / Math.max(bitmap.width, bitmap.height),
);
const canvas = new OffscreenCanvas(
Math.round(bitmap.width * scale),
Math.round(bitmap.height * scale),
);
const context = canvas.getContext("2d");
if (!context) throw new UnusableImage("undecodable");
context.fillStyle = "black";
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
return await canvas.convertToBlob({ type: "image/webp", quality: 0.9 });
} finally {
bitmap.close();
}
}
// Read from the file, not its type: a still and an animated WebP share one.
async function isAnimated(file: Blob): Promise<boolean> {
if (typeof ImageDecoder === "undefined") return /gif|apng/.test(file.type);
try {
const decoder = new ImageDecoder({
data: await file.arrayBuffer(),
type: file.type,
});
// Some browsers have no selected track until the tracks are ready.
await decoder.tracks.ready;
await decoder.completed;
return (decoder.tracks.selectedTrack?.frameCount ?? 1) > 1;
} catch (e) {
logger.debug("Could not read the frame count, judging by type", e);
return /gif|apng/.test(file.type);
}
}
const DB_NAME = "element-call-background-images";
const STORE = "backgrounds";
export class IndexedDBImageStorage implements BackgroundImageStorage {
private db?: Promise<IDBDatabase>;
public constructor(private readonly indexedDB: IDBFactory) {}
private async open(): Promise<IDBDatabase> {
return (this.db ??= new Promise((resolve, reject) => {
const request = this.indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = (): void => {
request.result.createObjectStore(STORE, { keyPath: "id" });
};
request.onsuccess = (): void => resolve(request.result);
request.onerror = (): void => reject(request.error);
}));
}
private async run<T>(
mode: IDBTransactionMode,
body: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
const db = await this.open();
return new Promise((resolve, reject) => {
const request = body(db.transaction(STORE, mode).objectStore(STORE));
request.onsuccess = (): void => resolve(request.result);
request.onerror = (): void => reject(request.error);
});
}
public async list(): Promise<KeptImage[]> {
return this.run<KeptImage[]>("readonly", (s) => s.getAll());
}
public async put(image: KeptImage): Promise<void> {
await this.run("readwrite", (s) => s.put(image));
}
public async delete(id: string): Promise<void> {
await this.run("readwrite", (s) => s.delete(id));
}
}
function openStorage(): BackgroundImageStorage | null {
// Merely reading indexedDB throws where it is disabled.
try {
return globalThis.indexedDB
? new IndexedDBImageStorage(globalThis.indexedDB)
: null;
} catch (e) {
logger.warn("Could not reach IndexedDB", e);
return null;
}
}
export const addedBackgrounds = new AddedBackgrounds(openStorage());
+14 -9
View File
@@ -71,6 +71,8 @@ import {
type RTCParticipation,
type RTCSlot,
type ParticipationState,
type DataPublishRequest,
type LocalTrack,
type PublishRequest,
type RemoteRTCMember,
type TransportMetadata,
@@ -535,19 +537,22 @@ export function mockRTCParticipation(
state$,
localMember$,
remoteMembers$,
publish: vi.fn(async (request: PublishRequest) =>
Promise.resolve(
request.source === "microphone"
? mockAudioTrack()
: mockVideoTrack({ source: request.source }),
),
),
publish: vi.fn(
async (
request: PublishRequest | DataPublishRequest,
): Promise<LocalTrack> => {
if (request.source === "data") throw new Error("Not mocked");
return Promise.resolve(
request.source === "microphone"
? mockAudioTrack()
: mockVideoTrack({ source: request.source }),
);
},
) as RTCParticipation["publish"],
unpublish: vi.fn(async () => Promise.resolve()),
keyRotationSuppressed$,
connectedTransports$,
setAudioOutputDeviceId: vi.fn(async () => Promise.resolve()),
sendData: vi.fn(async () => Promise.resolve()),
data$: NEVER,
};
participation$.next(participation);
return participation;