mirror of
https://github.com/vector-im/element-call.git
synced 2026-11-07 00:52:28 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4dd44bd78 | ||
|
|
9bb06cbcbf | ||
|
|
8fae3d2df2 | ||
|
|
8fefb6246c | ||
|
|
f670aa5f09 | ||
|
|
d1792e14e3 | ||
|
|
593144b25a | ||
|
|
38dba0fe68 | ||
|
|
c946b9c78e | ||
|
|
9b4e7df9be | ||
|
|
76262d90bf | ||
|
|
008584df0d |
@@ -109,12 +109,4 @@ describe("scopeStylesToRoot", () => {
|
||||
".a .b_abc12:hover { border: 0 }",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves an excluded stylesheet as it is", async () => {
|
||||
const css = "body { margin: 0 } .menu { color: red }";
|
||||
const result = await postcss([
|
||||
scopeStylesToRoot({ exclude: [/host\.css$/] }),
|
||||
]).process(css, { from: "/app/host.css" });
|
||||
expect(result.css).toBe(css);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,18 +60,12 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`;
|
||||
* tokens, the component styles, the fonts — is not in this build at all: the
|
||||
* host supplies it, unscoped, along with its copy of Compound.
|
||||
*/
|
||||
interface Options {
|
||||
/** Stylesheets left as they are: a host's own, which it doesn't scope. */
|
||||
exclude?: RegExp[];
|
||||
}
|
||||
|
||||
export function scopeStylesToRoot({ exclude = [] }: Options = {}): Plugin {
|
||||
export function scopeStylesToRoot(): Plugin {
|
||||
return {
|
||||
postcssPlugin: "element-call-scope-styles-to-root",
|
||||
Once(root) {
|
||||
const file = root.source?.input.file ?? "";
|
||||
if (exclude.some((pattern) => pattern.test(file))) return;
|
||||
const isModule = file.endsWith(".module.css");
|
||||
const isModule =
|
||||
root.source?.input.file?.endsWith(".module.css") ?? false;
|
||||
root.walkRules((rule) => {
|
||||
if (isRelative(rule)) return;
|
||||
rule.selector = (isModule ? scopeBare : scopeAll).processSync(
|
||||
|
||||
+12
-11
@@ -72,9 +72,9 @@ import {
|
||||
useUrlParams,
|
||||
} from "../src/UrlParams";
|
||||
import { MediaDevicesContext } from "../src/MediaDevicesContext";
|
||||
import { AppViewModel } from "../src/state/AppViewModel";
|
||||
import { MediaDevices } from "../src/state/MediaDevices";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { BackgroundEffectsProvider } from "../src/tracks/BackgroundEffectsContext";
|
||||
import { ProcessorProvider } from "../src/tracks/TrackProcessorContext";
|
||||
import { Config } from "../src/config/Config";
|
||||
import { type ConfigOptions } from "../src/config/ConfigOptions";
|
||||
import { i18n } from "../src/utils/i18n";
|
||||
@@ -194,12 +194,14 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
// observers running for the rest of the page's life. Null until then, which
|
||||
// is one render.
|
||||
const { controlledAudioDevices, callIntent } = params;
|
||||
const [vm, setVm] = useState<AppViewModel | null>(null);
|
||||
const [mediaDevices, setMediaDevices] = useState<MediaDevices | null>(null);
|
||||
useEffect(() => {
|
||||
const scope = new ObservableScope();
|
||||
setVm(new AppViewModel(scope, { controlledAudioDevices, callIntent }));
|
||||
setMediaDevices(
|
||||
new MediaDevices(scope, { controlledAudioDevices, callIntent }),
|
||||
);
|
||||
return (): void => {
|
||||
setVm(null);
|
||||
setMediaDevices(null);
|
||||
scope.end();
|
||||
};
|
||||
}, [controlledAudioDevices, callIntent]);
|
||||
@@ -220,7 +222,8 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
// is told that Element Call has loaded, as the widget tells its client once
|
||||
// its own initialisation is over. Once per mount, however often the pieces
|
||||
// are later swapped out.
|
||||
const ready = container !== null && rtcSession !== null && vm !== null;
|
||||
const ready =
|
||||
container !== null && rtcSession !== null && mediaDevices !== null;
|
||||
const announcedLoaded = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!ready || announcedLoaded.current) return;
|
||||
@@ -251,10 +254,8 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
<Decoration>
|
||||
<TooltipProvider>
|
||||
<ClientProvider client={client}>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<BackgroundEffectsProvider
|
||||
effects={vm.backgroundEffects}
|
||||
>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<CallView
|
||||
client={client}
|
||||
rtcSession={rtcSession}
|
||||
@@ -263,7 +264,7 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
preload={params.preload}
|
||||
skipLobby={params.skipLobby}
|
||||
/>
|
||||
</BackgroundEffectsProvider>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -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:a79801206c628a912d57fa6f355d5c5ab9c6c40c2d1b6208d4192678fb6e80c7
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
|
||||
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:a79801206c628a912d57fa6f355d5c5ab9c6c40c2d1b6208d4192678fb6e80c7
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
|
||||
|
||||
@@ -9,4 +9,4 @@ android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref
|
||||
|
||||
[plugins]
|
||||
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
|
||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.37.0" }
|
||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.36.0" }
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.6-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
+3
-4
@@ -7,13 +7,13 @@
|
||||
"features": ["plural_postfix_us", "filter_untranslated"],
|
||||
"files": [
|
||||
{
|
||||
"pattern": "locales/en/app.json",
|
||||
"pattern": "locales/en/*.json",
|
||||
"lang": "inherited"
|
||||
},
|
||||
{
|
||||
"group": "existing",
|
||||
"pattern": "locales/*/app.json",
|
||||
"excludes": ["locales/en/app.json"],
|
||||
"pattern": "locales/*/*.json",
|
||||
"excludes": ["locales/en/*.json"],
|
||||
"lang": "${autodetectLang}"
|
||||
}
|
||||
]
|
||||
@@ -22,7 +22,6 @@
|
||||
"download": {
|
||||
"files": [
|
||||
{
|
||||
"conditions": ["equals: ${file}, app.json"],
|
||||
"output": "locales/${langLsrDash}/${file}"
|
||||
}
|
||||
],
|
||||
|
||||
+1
-13
@@ -3,7 +3,7 @@
|
||||
"user_menu": "User menu"
|
||||
},
|
||||
"action": {
|
||||
"add_background_image": "Add image",
|
||||
"blur_background": "Blur background",
|
||||
"close": "Close",
|
||||
"copy_link": "Copy link",
|
||||
"edit": "Edit",
|
||||
@@ -23,13 +23,6 @@
|
||||
"upload_file": "Upload file"
|
||||
},
|
||||
"analytics_notice": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <6>Cookie Policy</6>.",
|
||||
"background_effects": {
|
||||
"blur": "Blur",
|
||||
"none": "None",
|
||||
"numbered": "Background {{n}}",
|
||||
"slow": "Background effects run slowly on this platform, which may cause your video to stutter.",
|
||||
"unavailable": "Background effects are not supported on this platform."
|
||||
},
|
||||
"call_ended_view": {
|
||||
"create_account_button": "Create account",
|
||||
"create_account_prompt": "<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>",
|
||||
@@ -99,10 +92,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>",
|
||||
@@ -223,7 +212,6 @@
|
||||
"auto_gain_control_label": "Automatic gain control",
|
||||
"background_blur_header": "Background",
|
||||
"background_blur_label": "Blur the background of the video",
|
||||
"background_effects_header": "Background effects",
|
||||
"bitrate_label": "Bitrate",
|
||||
"blur_not_supported_by_browser": "(Background blur is not supported by this device.)",
|
||||
"camera_header": "Camera quality",
|
||||
|
||||
+3
-3
@@ -57,6 +57,7 @@
|
||||
"@formatjs/intl-durationformat": "^0.10.0",
|
||||
"@formatjs/intl-segmenter": "^11.7.3",
|
||||
"@livekit/components-core": "^0.12.0",
|
||||
"@livekit/components-react": "^2.0.0",
|
||||
"@livekit/track-processors": "^0.7.1",
|
||||
"@mediapipe/tasks-vision": "^0.10.18",
|
||||
"@playwright/test": "^1.60.0",
|
||||
@@ -87,7 +88,7 @@
|
||||
"@typescript-eslint/utils": "^8.61.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^11.0.0",
|
||||
"@vector-im/compound-web": "^10.5.0",
|
||||
"@vector-im/compound-web": "^10.4.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/browser-playwright": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
@@ -97,7 +98,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",
|
||||
@@ -107,7 +107,7 @@
|
||||
"livekit-client": "^2.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#3f05dc4d104a3dfc753c5e3549a18c74fab4b076",
|
||||
"matrix-widget-api": "^1.18.0",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
|
||||
@@ -1,825 +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, type Browser, 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.
|
||||
test.skip(
|
||||
({ browserName }) => browserName === "firefox",
|
||||
"Background effects need WebGL2, which headless Firefox on CI does not have",
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
await expect(tile).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
// The fake camera shows no person, so the whole frame is background: the
|
||||
// preview takes on the picture's colours and leaves the camera's.
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("no effect", () => {
|
||||
test("draws nothing, even after blurring", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await countDraws(page);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Idle user", "No effect after blur");
|
||||
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const effects = page.getByRole("group", { name: "Background effects" });
|
||||
await effects.getByRole("menuitemradio", { name: "Blur" }).click();
|
||||
// Long enough for the first effect of a session to be built.
|
||||
await expect
|
||||
.poll(async () => drawsPerSecond(page), { timeout: 60_000 })
|
||||
.toBeGreaterThan(0);
|
||||
const blurred = await drawsPerSecond(page);
|
||||
|
||||
await effects.getByRole("menuitemradio", { name: "None" }).click();
|
||||
// A frame already on its way may still be drawn.
|
||||
await expect.poll(async () => drawsPerSecond(page)).toBe(0);
|
||||
test.info().annotations.push({
|
||||
type: "WebGL draws per second",
|
||||
description: `blurred ${blurred}, none 0`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Counts every WebGL draw on the page, the segmenter's and the effect's. */
|
||||
async function countDraws(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const counter = { draws: 0 };
|
||||
(window as unknown as { webglDraws: typeof counter }).webglDraws = counter;
|
||||
for (const name of ["drawArrays", "drawElements"] as const) {
|
||||
const draw = WebGL2RenderingContext.prototype[name] as (
|
||||
...args: unknown[]
|
||||
) => void;
|
||||
WebGL2RenderingContext.prototype[name] = function (
|
||||
this: WebGL2RenderingContext,
|
||||
...args: unknown[]
|
||||
): void {
|
||||
counter.draws += 1;
|
||||
draw.apply(this, args);
|
||||
} as never;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** WebGL draws over the next second. */
|
||||
async function drawsPerSecond(page: Page): Promise<number> {
|
||||
return page.evaluate(async () => {
|
||||
const counter = (window as unknown as { webglDraws: { draws: number } })
|
||||
.webglDraws;
|
||||
const before = counter.draws;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return counter.draws - before;
|
||||
});
|
||||
}
|
||||
|
||||
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.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]);
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -125,24 +125,25 @@ test("leaves the host's own page unstyled", async ({ page }) => {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// Element Call's page-wide CSS (normalize.css and the tokens in base.css)
|
||||
// should not reach the host's page. Compound CSS that gets loaded to EW (host)
|
||||
// on the other hand should impact the host. So probe with styles only Element Call sets:
|
||||
// - A fresh <sub> keeps the browser's `vertical-align: sub` unless
|
||||
// normalize.css leaked; it turns that into `baseline`.
|
||||
// - `--font-scale` is Element Call's own token, so `:root` must not have it.
|
||||
// Element Call's stylesheet is written for a page of its own: normalize.css
|
||||
// gives `html` a line height, Compound gives `body` its font and feature
|
||||
// settings, and the design tokens live on `:root`. None of that may reach the
|
||||
// host's document — the harness sets none of these itself, so anything other
|
||||
// than the browser's defaults here came from us.
|
||||
const host = await page.evaluate(() => {
|
||||
const sub = document.body.appendChild(document.createElement("sub"));
|
||||
const verticalAlign = getComputedStyle(sub).verticalAlign;
|
||||
sub.remove();
|
||||
const html = getComputedStyle(document.documentElement);
|
||||
const body = getComputedStyle(document.body);
|
||||
return {
|
||||
verticalAlign,
|
||||
token: getComputedStyle(document.documentElement).getPropertyValue(
|
||||
"--font-scale",
|
||||
),
|
||||
lineHeight: html.lineHeight,
|
||||
fontFeatureSettings: body.fontFeatureSettings,
|
||||
token: html.getPropertyValue("--cpd-color-text-primary"),
|
||||
};
|
||||
});
|
||||
expect(host).toEqual({ verticalAlign: "sub", token: "" });
|
||||
expect(host).toEqual({
|
||||
lineHeight: "normal",
|
||||
fontFeatureSettings: "normal",
|
||||
token: "",
|
||||
});
|
||||
|
||||
// While inside the container, the same rules do apply
|
||||
const root = panes.first().locator("[data-element-call-root]");
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
Generated
+116
-47
@@ -40,6 +40,9 @@ importers:
|
||||
'@livekit/components-core':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.15(livekit-client@2.23.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
'@livekit/components-react':
|
||||
specifier: ^2.0.0
|
||||
version: 2.9.24(livekit-client@2.23.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
|
||||
'@livekit/track-processors':
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.23.0(@types/dom-mediacapture-record@1.0.22))
|
||||
@@ -63,7 +66,7 @@ importers:
|
||||
version: 10.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@rolldown/plugin-babel':
|
||||
specifier: ^0.2.3
|
||||
version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
||||
version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
||||
'@sentry/react':
|
||||
specifier: ^8.0.0
|
||||
version: 8.55.2(react@19.2.8)
|
||||
@@ -131,11 +134,11 @@ importers:
|
||||
specifier: ^11.0.0
|
||||
version: 11.3.1(@types/react@19.2.18)(react@19.2.8)
|
||||
'@vector-im/compound-web':
|
||||
specifier: ^10.5.0
|
||||
version: 10.5.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.3.1(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
specifier: ^10.4.0
|
||||
version: 10.4.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.3.1(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.2
|
||||
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
||||
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
||||
'@vitest/browser-playwright':
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.11(playwright@1.62.1)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11)
|
||||
@@ -160,9 +163,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)
|
||||
@@ -191,8 +191,8 @@ importers:
|
||||
specifier: ^1.9.1
|
||||
version: 1.9.2
|
||||
matrix-js-sdk:
|
||||
specifier: github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
|
||||
specifier: github:matrix-org/matrix-js-sdk#3f05dc4d104a3dfc753c5e3549a18c74fab4b076
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076
|
||||
matrix-widget-api:
|
||||
specifier: ^1.18.0
|
||||
version: 1.20.0
|
||||
@@ -397,8 +397,8 @@ packages:
|
||||
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/runtime@8.0.8':
|
||||
resolution: {integrity: sha512-dEu8nGlRqjsJsvviUUoSMfcRHpwomCiRlXsc2mPfJ79+ffTpDm+JtfUtTMzUrqCApHQnCsVPD3gT1ZT8RgBRCQ==}
|
||||
'@babel/runtime@8.0.0':
|
||||
resolution: {integrity: sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==}
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
|
||||
@@ -408,6 +408,10 @@ packages:
|
||||
resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/traverse@7.29.8':
|
||||
resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.29.11':
|
||||
resolution: {integrity: sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -1180,6 +1184,19 @@ packages:
|
||||
livekit-client: ^2.20.1
|
||||
tslib: ^2.6.2
|
||||
|
||||
'@livekit/components-react@2.9.24':
|
||||
resolution: {integrity: sha512-qw5Oy1EfPg1f/xrKYrFielSrmtxY8BZ9mDt6299G5aikotQIztmldBfTNosrVS7VSNit7qeYfOFSyKaML20W6Q==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@livekit/krisp-noise-filter': ^0.2.12 || ^0.3.0 || ^0.4.0
|
||||
livekit-client: ^2.20.1
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
tslib: ^2.6.2
|
||||
peerDependenciesMeta:
|
||||
'@livekit/krisp-noise-filter':
|
||||
optional: true
|
||||
|
||||
'@livekit/mutex@1.1.1':
|
||||
resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==}
|
||||
|
||||
@@ -3297,8 +3314,8 @@ packages:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@vector-im/compound-web@10.5.0':
|
||||
resolution: {integrity: sha512-dcxIAKtTKBLKz2Y83mNkXyYx3shtSs80KvdGKN5ryFvo6OnVOhHPEHiQNbaPQ0EDjc6xStjY9+oz2Fh4c+kniQ==}
|
||||
'@vector-im/compound-web@10.4.0':
|
||||
resolution: {integrity: sha512-Tt5hsNK7ulhGdRuy9lzSm5C+T8M/4O4kLDFLkaU+Hi1kVWj6tj/5awW+U+GvtpWRh5llbHN2HRCh79+gQhek+A==}
|
||||
peerDependencies:
|
||||
'@fontsource/inconsolata': ^5
|
||||
'@fontsource/inter': ^5
|
||||
@@ -3404,6 +3421,11 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
acorn@8.18.0:
|
||||
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
acorn@8.19.0:
|
||||
resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -3675,6 +3697,10 @@ packages:
|
||||
cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -4051,10 +4077,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==}
|
||||
|
||||
@@ -4514,6 +4536,9 @@ packages:
|
||||
jose@6.2.13:
|
||||
resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==}
|
||||
|
||||
jose@6.2.9:
|
||||
resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==}
|
||||
|
||||
js-tokens@10.0.0:
|
||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||
|
||||
@@ -4669,6 +4694,9 @@ packages:
|
||||
lodash-es@4.18.1:
|
||||
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
|
||||
|
||||
lodash.debounce@4.0.8:
|
||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
@@ -4731,9 +4759,9 @@ packages:
|
||||
matrix-events-sdk@0.0.1:
|
||||
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
|
||||
resolution: {gitHosted: true, integrity: sha512-ZAhn/zB44MgrXqUzi9ffSFcmZY3+2nx7bEgfEJR7chYDFyT/XmlmfCJx7Ar4rOTAYZJRVnMxrEwiEHGDapUi0Q==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01}
|
||||
version: 45.0.0
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076:
|
||||
resolution: {gitHosted: true, integrity: sha512-Ly8eS7fNNKDo9KmPvTkE49KvD8iMUmrVWJMNERNuxc8UksMLMG36NLz3fs8HytdwrB7AKPfyVo392VZsxILDIg==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076}
|
||||
version: 44.0.0
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
matrix-widget-api@1.20.0:
|
||||
@@ -5960,6 +5988,12 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
usehooks-ts@3.1.1:
|
||||
resolution: {integrity: sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==}
|
||||
engines: {node: '>=16.15.0'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
@@ -6323,10 +6357,10 @@ snapshots:
|
||||
'@babel/helper-compilation-targets': 7.29.7
|
||||
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
'@babel/helpers': 7.29.7
|
||||
'@babel/parser': 7.29.11
|
||||
'@babel/parser': 7.29.8
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/traverse': 7.29.10(supports-color@7.2.0)
|
||||
'@babel/types': 7.29.11
|
||||
'@babel/traverse': 7.29.8(supports-color@7.2.0)
|
||||
'@babel/types': 7.29.8
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
@@ -6338,8 +6372,8 @@ snapshots:
|
||||
|
||||
'@babel/generator@7.29.8':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.11
|
||||
'@babel/types': 7.29.11
|
||||
'@babel/parser': 7.29.8
|
||||
'@babel/types': 7.29.8
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jsesc: 3.1.0
|
||||
@@ -6356,8 +6390,8 @@ snapshots:
|
||||
|
||||
'@babel/helper-module-imports@7.29.7(supports-color@7.2.0)':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.10(supports-color@7.2.0)
|
||||
'@babel/types': 7.29.11
|
||||
'@babel/traverse': 7.29.8(supports-color@7.2.0)
|
||||
'@babel/types': 7.29.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6366,7 +6400,7 @@ snapshots:
|
||||
'@babel/core': 7.29.7(supports-color@7.2.0)
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@7.2.0)
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
'@babel/traverse': 7.29.10(supports-color@7.2.0)
|
||||
'@babel/traverse': 7.29.8(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6383,7 +6417,7 @@ snapshots:
|
||||
'@babel/helpers@7.29.7':
|
||||
dependencies:
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.11
|
||||
'@babel/types': 7.29.8
|
||||
|
||||
'@babel/parser@7.29.11':
|
||||
dependencies:
|
||||
@@ -6399,13 +6433,13 @@ snapshots:
|
||||
|
||||
'@babel/runtime@7.29.7': {}
|
||||
|
||||
'@babel/runtime@8.0.8': {}
|
||||
'@babel/runtime@8.0.0': {}
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/parser': 7.29.11
|
||||
'@babel/types': 7.29.11
|
||||
'@babel/parser': 7.29.8
|
||||
'@babel/types': 7.29.8
|
||||
|
||||
'@babel/traverse@7.29.10(supports-color@7.2.0)':
|
||||
dependencies:
|
||||
@@ -6414,7 +6448,19 @@ snapshots:
|
||||
'@babel/helper-globals': 7.29.7
|
||||
'@babel/parser': 7.29.11
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.11
|
||||
'@babel/types': 7.29.8
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/traverse@7.29.8(supports-color@7.2.0)':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/generator': 7.29.8
|
||||
'@babel/helper-globals': 7.29.7
|
||||
'@babel/parser': 7.29.8
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.8
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -7157,6 +7203,18 @@ snapshots:
|
||||
rxjs: 7.8.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@livekit/components-react@2.9.24(livekit-client@2.23.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@livekit/components-core': 0.12.15(livekit-client@2.23.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
clsx: 2.1.1
|
||||
events: 3.3.0
|
||||
jose: 6.2.9
|
||||
livekit-client: 2.23.0(@types/dom-mediacapture-record@1.0.22)
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
tslib: 2.8.1
|
||||
usehooks-ts: 3.1.1(react@19.2.8)
|
||||
|
||||
'@livekit/mutex@1.1.1': {}
|
||||
|
||||
'@livekit/protocol@1.52.1':
|
||||
@@ -8109,13 +8167,13 @@ snapshots:
|
||||
'@rolldown/binding-win32-x64-msvc@1.2.15':
|
||||
optional: true
|
||||
|
||||
'@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
|
||||
'@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@7.2.0)
|
||||
picomatch: 4.0.4
|
||||
rolldown: 1.2.15
|
||||
optionalDependencies:
|
||||
'@babel/runtime': 8.0.8
|
||||
'@babel/runtime': 8.0.0
|
||||
vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
@@ -8845,7 +8903,7 @@ snapshots:
|
||||
'@types/react': 19.2.18
|
||||
react: 19.2.8
|
||||
|
||||
'@vector-im/compound-web@10.5.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.3.1(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
'@vector-im/compound-web@10.4.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.3.1(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -8867,12 +8925,12 @@ snapshots:
|
||||
- '@types/react-dom'
|
||||
- react-dom
|
||||
|
||||
'@vitejs/plugin-react@6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
|
||||
'@vitejs/plugin-react@6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)
|
||||
optionalDependencies:
|
||||
'@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
||||
'@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
||||
babel-plugin-react-compiler: 1.0.0
|
||||
|
||||
'@vitest/browser-playwright@4.1.11(playwright@1.62.1)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11)':
|
||||
@@ -8997,12 +9055,14 @@ snapshots:
|
||||
|
||||
'@webcontainer/env@1.1.1': {}
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.19.0):
|
||||
acorn-jsx@5.3.2(acorn@8.18.0):
|
||||
dependencies:
|
||||
acorn: 8.19.0
|
||||
acorn: 8.18.0
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
acorn@8.18.0: {}
|
||||
|
||||
acorn@8.19.0: {}
|
||||
|
||||
agent-base@6.0.2(supports-color@7.2.0):
|
||||
@@ -9294,6 +9354,8 @@ snapshots:
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -9671,8 +9733,8 @@ snapshots:
|
||||
|
||||
espree@9.6.1:
|
||||
dependencies:
|
||||
acorn: 8.19.0
|
||||
acorn-jsx: 5.3.2(acorn@8.19.0)
|
||||
acorn: 8.18.0
|
||||
acorn-jsx: 5.3.2(acorn@8.18.0)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
esprima@4.0.1: {}
|
||||
@@ -9719,8 +9781,6 @@ snapshots:
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
|
||||
fake-indexeddb@6.2.5: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
@@ -10187,6 +10247,8 @@ snapshots:
|
||||
|
||||
jose@6.2.13: {}
|
||||
|
||||
jose@6.2.9: {}
|
||||
|
||||
js-tokens@10.0.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
@@ -10347,6 +10409,8 @@ snapshots:
|
||||
|
||||
lodash-es@4.18.1: {}
|
||||
|
||||
lodash.debounce@4.0.8: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
log-symbols@7.0.1:
|
||||
@@ -10404,9 +10468,9 @@ snapshots:
|
||||
|
||||
matrix-events-sdk@0.0.1: {}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076:
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.8
|
||||
'@babel/runtime': 8.0.0
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
|
||||
another-json: 0.2.0
|
||||
bs58: 6.0.0
|
||||
@@ -11637,7 +11701,7 @@ snapshots:
|
||||
terser@5.46.1:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.11
|
||||
acorn: 8.19.0
|
||||
acorn: 8.18.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
|
||||
@@ -11805,6 +11869,11 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
|
||||
usehooks-ts@3.1.1(react@19.2.8):
|
||||
dependencies:
|
||||
lodash.debounce: 4.0.8
|
||||
react: 19.2.8
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
util@0.12.5:
|
||||
|
||||
@@ -44,9 +44,8 @@ const TARGETS = [
|
||||
{
|
||||
config: "vite-component.config.ts",
|
||||
what: "the component build",
|
||||
// `sdk` too: the build resolves `@element-hq/matrixrtc-sdk` to its source
|
||||
sources: ["src", "component", "sdk"],
|
||||
excluded: ["component/dev", "sdk/dev"],
|
||||
sources: ["src", "component"],
|
||||
excluded: ["component/dev"],
|
||||
mustBeExternal: [
|
||||
"react",
|
||||
"react-dom",
|
||||
|
||||
+5
-10
@@ -126,15 +126,10 @@ probe that decides whether the service that issued the token can take over the
|
||||
delayed leave, which picks the delayed leave timings the join uses. The join
|
||||
waits for the publisher to hold its token, so that the LiveKit room exists
|
||||
before a subscriber, who lacks the permission to create it, asks for a token
|
||||
of its own. The tracks are created once the membership is `joined`, with their
|
||||
upstream paused until then. A lobby that wants them earlier captures them with
|
||||
`createLocalMedia`, on the browser's devices alone so that any backend fits, and
|
||||
hands the media to `join()` as `publish`: the tracks are moved, not shared — they
|
||||
leave the lobby's `tracks$`, their objects are finished, and the backend
|
||||
publishes the very same device tracks and the processor already running on them,
|
||||
so nothing is captured or initialised again at the join. The upstream flows
|
||||
exactly while the membership is `joined`: a sync outage pauses it, and `leave()`
|
||||
pauses it for good and sends the leave.
|
||||
of its own. Until `join()` the tracks exist but their upstream is paused: a host
|
||||
can show a preview without anyone hearing it. The upstream flows exactly while the
|
||||
membership is `joined`: a sync outage pauses it, and `leave()` pauses it for good
|
||||
and sends the leave.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
@@ -543,7 +538,7 @@ backend is handed the whole session's view and gives back media:
|
||||
type MemberMediaFields = Pick<RTCMember, "tracks$" | "encryptionError$">;
|
||||
|
||||
export interface MediaBackend {
|
||||
readonly transportTypes: readonly string[]; // ["m.livekit", "livekit"]
|
||||
readonly transportType: string; // "livekit"
|
||||
/** Authenticate with the transport the client found; the probe for delegation runs here. */
|
||||
prepareLocalTransport(
|
||||
transport: Transport,
|
||||
|
||||
+2
-7
@@ -61,13 +61,8 @@ export {
|
||||
createLivekitBackend,
|
||||
type LivekitBackendOptions,
|
||||
} from "./src/media-backend/livekit/LivekitBackend";
|
||||
// Tracks for a lobby, captured ahead of the join and handed to it
|
||||
export { createLocalMedia } from "./src/LocalMedia";
|
||||
// For a host whose processor is a livekit-client one, such as @livekit/track-processors
|
||||
export {
|
||||
fromLivekitProcessor,
|
||||
toLivekitProcessor,
|
||||
} from "./src/media-backend/livekit/videoProcessor";
|
||||
// For a host that creates LiveKit tracks itself, such as a preview before joining
|
||||
export { convertToLivekitProcessor } from "./src/media-backend/livekit/videoProcessor";
|
||||
// For a developer panel to check a transport before using it
|
||||
export {
|
||||
getSFUConfig as authenticateWithTransport,
|
||||
|
||||
@@ -1,237 +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 { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createLocalMedia, type GetUserMedia } from "./LocalMedia";
|
||||
import { type LocalVideoMediaTrack, type VideoProcessor } from "./media-api";
|
||||
|
||||
describe("createLocalMedia", () => {
|
||||
it("opens the microphone and the camera in one request", async () => {
|
||||
const getUserMedia = fakeGetUserMedia();
|
||||
const media = createLocalMedia(getUserMedia);
|
||||
|
||||
const tracks = await media.capture([
|
||||
{ source: "microphone", deviceId: "mic" },
|
||||
{
|
||||
source: "camera",
|
||||
capture: { resolution: { width: 640, height: 480 } },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getUserMedia).toHaveBeenCalledExactlyOnceWith({
|
||||
audio: { deviceId: "mic" },
|
||||
video: { width: 640, height: 480, frameRate: undefined },
|
||||
});
|
||||
expect(tracks.map((t) => t.source)).toEqual(["microphone", "camera"]);
|
||||
expect(media.tracks$.value).toEqual(tracks);
|
||||
expect(media.requests.map((r) => r.source)).toEqual([
|
||||
"microphone",
|
||||
"camera",
|
||||
]);
|
||||
});
|
||||
|
||||
it("joins an identical capture still in flight rather than opening the devices again", async () => {
|
||||
const getUserMedia = fakeGetUserMedia();
|
||||
const media = createLocalMedia(getUserMedia);
|
||||
const requests = [{ source: "camera" as const }];
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
media.capture(requests),
|
||||
media.capture(requests),
|
||||
]);
|
||||
|
||||
expect(getUserMedia).toHaveBeenCalledOnce();
|
||||
expect(second).toEqual(first);
|
||||
expect(media.tracks$.value).toEqual(first);
|
||||
});
|
||||
|
||||
it("shows the processor's output, initialised once", async () => {
|
||||
const processor = fakeProcessor();
|
||||
const media = createLocalMedia(fakeGetUserMedia());
|
||||
const [camera] = await media.capture([{ source: "camera", processor }]);
|
||||
const element = document.createElement("video");
|
||||
|
||||
camera.attach(element);
|
||||
|
||||
expect(processor.init).toHaveBeenCalledOnce();
|
||||
expect(tracksOn(element)).toEqual([processor.output]);
|
||||
});
|
||||
|
||||
it("hands the tracks over as they are: no second capture, no second init", async () => {
|
||||
const getUserMedia = fakeGetUserMedia();
|
||||
const processor = fakeProcessor();
|
||||
const media = createLocalMedia(getUserMedia);
|
||||
const [microphone, camera] = await media.capture([
|
||||
{ source: "microphone" },
|
||||
{ source: "camera", processor },
|
||||
]);
|
||||
|
||||
const taken = media.takeTracks();
|
||||
|
||||
expect(taken.get("microphone")?.track).toBe(trackBehind(microphone));
|
||||
expect(taken.get("camera")).toEqual({
|
||||
track: trackBehind(camera),
|
||||
constraints: {},
|
||||
muted: false,
|
||||
processor: { processor, processedTrack: processor.output },
|
||||
});
|
||||
expect(media.tracks$.value).toEqual([]);
|
||||
expect(getUserMedia).toHaveBeenCalledOnce();
|
||||
expect(processor.init).toHaveBeenCalledOnce();
|
||||
expect(processor.destroy).not.toHaveBeenCalled();
|
||||
media.stop();
|
||||
expect(taken.get("camera")!.track.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finishes a handed-over track", async () => {
|
||||
const media = createLocalMedia(fakeGetUserMedia());
|
||||
const [camera] = await media.capture([{ source: "camera" }]);
|
||||
const element = document.createElement("video");
|
||||
camera.attach(element);
|
||||
|
||||
media.takeTracks();
|
||||
|
||||
expect(element.srcObject).toBeNull();
|
||||
await expect(camera.setEnabled(false)).rejects.toThrow("handed over");
|
||||
expect(() => camera.attach(element)).toThrow("handed over");
|
||||
});
|
||||
|
||||
it("mutes by disabling the track and hands the mute over", async () => {
|
||||
const media = createLocalMedia(fakeGetUserMedia());
|
||||
const [microphone] = await media.capture([{ source: "microphone" }]);
|
||||
|
||||
await expect(microphone.setEnabled(false)).resolves.toBe(false);
|
||||
|
||||
expect(microphone.muted$.value).toBe(true);
|
||||
expect(trackBehind(microphone).enabled).toBe(false);
|
||||
expect(media.takeTracks().get("microphone")?.muted).toBe(true);
|
||||
});
|
||||
|
||||
it("switches the device by opening it anew", async () => {
|
||||
const getUserMedia = fakeGetUserMedia();
|
||||
const processor = fakeProcessor();
|
||||
const media = createLocalMedia(getUserMedia);
|
||||
const [camera] = await media.capture([{ source: "camera", processor }]);
|
||||
const first = trackBehind(camera);
|
||||
const element = document.createElement("video");
|
||||
camera.attach(element);
|
||||
|
||||
await camera.setDevice("other");
|
||||
|
||||
expect(getUserMedia).toHaveBeenLastCalledWith({
|
||||
video: { deviceId: "other" },
|
||||
});
|
||||
expect(first.stop).toHaveBeenCalled();
|
||||
expect(processor.restart).toHaveBeenCalledOnce();
|
||||
const [restartedOn, input] = vi.mocked(processor.restart).mock.calls[0];
|
||||
expect(restartedOn).not.toBe(first);
|
||||
expect(input).toBe(vi.mocked(processor.init).mock.calls[0][1]);
|
||||
expect(tracksOn(element)).toEqual([processor.output]);
|
||||
expect(media.takeTracks().get("camera")?.constraints).toEqual({
|
||||
deviceId: "other",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the facing mode and switches it", async () => {
|
||||
const getUserMedia = fakeGetUserMedia({ facingMode: "user" });
|
||||
const media = createLocalMedia(getUserMedia);
|
||||
const [camera] = (await media.capture([{ source: "camera" }])) as [
|
||||
LocalVideoMediaTrack,
|
||||
];
|
||||
expect(camera.facingMode$.value).toBe("user");
|
||||
|
||||
await camera.switchFacingMode();
|
||||
|
||||
expect(getUserMedia).toHaveBeenLastCalledWith({
|
||||
video: { facingMode: "environment" },
|
||||
});
|
||||
});
|
||||
|
||||
it("releases one device and stops the rest", async () => {
|
||||
const media = createLocalMedia(fakeGetUserMedia());
|
||||
const [microphone, camera] = await media.capture([
|
||||
{ source: "microphone" },
|
||||
{ source: "camera" },
|
||||
]);
|
||||
|
||||
media.release("camera");
|
||||
expect(trackBehind(camera).stop).toHaveBeenCalled();
|
||||
expect(media.tracks$.value).toEqual([microphone]);
|
||||
|
||||
media.stop();
|
||||
expect(trackBehind(microphone).stop).toHaveBeenCalled();
|
||||
expect(media.tracks$.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a screen share", async () => {
|
||||
const media = createLocalMedia(fakeGetUserMedia());
|
||||
await expect(media.capture([{ source: "screenShare" }])).rejects.toThrow(
|
||||
"screen share",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
class FakeMediaStream {
|
||||
public constructor(private readonly tracks: MediaStreamTrack[]) {}
|
||||
public getTracks(): MediaStreamTrack[] {
|
||||
return this.tracks;
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.MediaStream = FakeMediaStream as unknown as typeof MediaStream;
|
||||
});
|
||||
|
||||
let nextId = 0;
|
||||
const tracksById = new Map<string, MediaStreamTrack>();
|
||||
|
||||
function fakeTrack(
|
||||
kind: "audio" | "video",
|
||||
settings: MediaTrackSettings = {},
|
||||
): MediaStreamTrack {
|
||||
const track = {
|
||||
kind,
|
||||
id: `${kind}-${nextId++}`,
|
||||
enabled: true,
|
||||
stop: vi.fn(),
|
||||
getSettings: () => settings,
|
||||
} as unknown as MediaStreamTrack;
|
||||
tracksById.set(track.id, track);
|
||||
return track;
|
||||
}
|
||||
|
||||
function fakeGetUserMedia(videoSettings?: MediaTrackSettings): GetUserMedia {
|
||||
return vi.fn(async (constraints: MediaStreamConstraints) => {
|
||||
const tracks: MediaStreamTrack[] = [];
|
||||
if (constraints.audio) tracks.push(fakeTrack("audio"));
|
||||
if (constraints.video) tracks.push(fakeTrack("video", videoSettings));
|
||||
return Promise.resolve(
|
||||
new FakeMediaStream(tracks) as unknown as MediaStream,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function fakeProcessor(): VideoProcessor & { output: MediaStreamTrack } {
|
||||
const output = fakeTrack("video");
|
||||
return {
|
||||
name: "blur",
|
||||
output,
|
||||
init: vi.fn(async () => Promise.resolve(output)),
|
||||
restart: vi.fn(async () => Promise.resolve(output)),
|
||||
destroy: vi.fn(async () => Promise.resolve()),
|
||||
};
|
||||
}
|
||||
|
||||
/** The device's track, which a media track was created on and keeps the id of. */
|
||||
function trackBehind(mediaTrack: { id: string }): MediaStreamTrack {
|
||||
return tracksById.get(mediaTrack.id)!;
|
||||
}
|
||||
|
||||
function tracksOn(element: HTMLMediaElement): MediaStreamTrack[] {
|
||||
return (element.srcObject as unknown as FakeMediaStream).getTracks();
|
||||
}
|
||||
@@ -1,335 +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, type Observable, of } from "rxjs";
|
||||
|
||||
import { MatrixRTCError } from "./errors";
|
||||
import {
|
||||
type HandedOverTrack,
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalMedia,
|
||||
type LocalVideoMediaTrack,
|
||||
type MediaSource,
|
||||
type MediaStreamStats,
|
||||
type PublishRequest,
|
||||
type VideoProcessor,
|
||||
} from "./media-api";
|
||||
import { constant } from "./reactive/Behavior";
|
||||
import { inTurn } from "./utils/processorTurns";
|
||||
|
||||
type Source = "microphone" | "camera";
|
||||
type Request = Extract<PublishRequest, { source: Source }>;
|
||||
export type GetUserMedia = (
|
||||
constraints: MediaStreamConstraints,
|
||||
) => Promise<MediaStream>;
|
||||
|
||||
/**
|
||||
* Local media on the browser's devices alone, for a lobby ahead of a join:
|
||||
* no backend is involved, so whichever backend joins can take the tracks
|
||||
* over. Activity and level are not measured; stats are none.
|
||||
*/
|
||||
export function createLocalMedia(
|
||||
getUserMedia: GetUserMedia = async (constraints) =>
|
||||
navigator.mediaDevices.getUserMedia(constraints),
|
||||
): LocalMedia {
|
||||
const capturedTracks = new Map<Source, CapturedTrack>();
|
||||
const requests = new Map<Source, Request>();
|
||||
const tracks$ = new BehaviorSubject<
|
||||
(LocalAudioMediaTrack | LocalVideoMediaTrack)[]
|
||||
>([]);
|
||||
const publishCapturedTracks = (): void =>
|
||||
tracks$.next(
|
||||
[...capturedTracks.values()].map(({ mediaTrack }) => mediaTrack),
|
||||
);
|
||||
|
||||
// A second identical capture while the first is open joins it: a host that
|
||||
// mounts twice (React's strict mode) must not open the devices twice and
|
||||
// replace the tracks it is already showing
|
||||
const pending = new Map<
|
||||
string,
|
||||
Promise<(LocalAudioMediaTrack | LocalVideoMediaTrack)[]>
|
||||
>();
|
||||
const capture = async (
|
||||
wanted: PublishRequest[],
|
||||
): Promise<(LocalAudioMediaTrack | LocalVideoMediaTrack)[]> => {
|
||||
const key = JSON.stringify(
|
||||
wanted.map((request) => ({ ...request, processor: undefined })),
|
||||
);
|
||||
const running = pending.get(key);
|
||||
if (running) return running;
|
||||
const started = captureNow(wanted).finally(() => pending.delete(key));
|
||||
pending.set(key, started);
|
||||
return started;
|
||||
};
|
||||
|
||||
const captureNow = async (
|
||||
wanted: PublishRequest[],
|
||||
): Promise<(LocalAudioMediaTrack | LocalVideoMediaTrack)[]> => {
|
||||
const bySource = new Map<Source, Request>();
|
||||
for (const request of wanted) {
|
||||
if (request.source === "screenShare")
|
||||
throw new MatrixRTCError(
|
||||
"A screen share cannot be captured ahead of a join",
|
||||
);
|
||||
bySource.set(request.source, request);
|
||||
}
|
||||
const constraints: MediaStreamConstraints = {};
|
||||
for (const request of bySource.values())
|
||||
constraints[request.source === "microphone" ? "audio" : "video"] =
|
||||
trackConstraints(request);
|
||||
const stream = await getUserMedia(constraints);
|
||||
const captured: CapturedTrack[] = [];
|
||||
for (const track of stream.getTracks()) {
|
||||
const source: Source = track.kind === "audio" ? "microphone" : "camera";
|
||||
const request = bySource.get(source)!;
|
||||
capturedTracks.get(source)?.stop();
|
||||
const captured_ = new CapturedTrack(
|
||||
source,
|
||||
track,
|
||||
trackConstraints(request),
|
||||
getUserMedia,
|
||||
);
|
||||
capturedTracks.set(source, captured_);
|
||||
requests.set(source, request);
|
||||
if (request.source === "camera" && request.processor)
|
||||
await captured_.setProcessor(request.processor);
|
||||
captured.push(captured_);
|
||||
}
|
||||
publishCapturedTracks();
|
||||
return captured.map(({ mediaTrack }) => mediaTrack);
|
||||
};
|
||||
|
||||
const release = (source: MediaSource): void => {
|
||||
if (source !== "microphone" && source !== "camera") return;
|
||||
capturedTracks.get(source)?.stop();
|
||||
capturedTracks.delete(source);
|
||||
publishCapturedTracks();
|
||||
};
|
||||
|
||||
const stop = (): void => {
|
||||
for (const track of capturedTracks.values()) track.stop();
|
||||
capturedTracks.clear();
|
||||
publishCapturedTracks();
|
||||
};
|
||||
|
||||
const takeTracks = (): Map<Source, HandedOverTrack> => {
|
||||
const taken = new Map<Source, HandedOverTrack>();
|
||||
for (const [source, track] of capturedTracks)
|
||||
taken.set(source, track.handOver());
|
||||
capturedTracks.clear();
|
||||
publishCapturedTracks();
|
||||
return taken;
|
||||
};
|
||||
|
||||
return {
|
||||
get requests(): PublishRequest[] {
|
||||
return [...requests.values()];
|
||||
},
|
||||
tracks$,
|
||||
capture,
|
||||
release,
|
||||
stop,
|
||||
takeTracks,
|
||||
};
|
||||
}
|
||||
|
||||
/** The constraints a request means to the browser. */
|
||||
function trackConstraints(request: Request): MediaTrackConstraints {
|
||||
const device = request.deviceId && { deviceId: request.deviceId };
|
||||
if (request.source === "microphone") return { ...device, ...request.capture };
|
||||
const resolution = request.capture?.resolution;
|
||||
return {
|
||||
...device,
|
||||
...(resolution && {
|
||||
width: resolution.width,
|
||||
height: resolution.height,
|
||||
frameRate: resolution.frameRate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal Class that is the owner of the tracks.
|
||||
* Will get stored in `capturedTracks` of `createLocalMedia`.
|
||||
* To the outside this class will return the `LocalMedia` interface.
|
||||
*/
|
||||
class CapturedTrack {
|
||||
public readonly mediaTrack: LocalAudioMediaTrack | LocalVideoMediaTrack;
|
||||
private readonly muted$ = new BehaviorSubject(false);
|
||||
private readonly facingMode$: BehaviorSubject<
|
||||
"user" | "environment" | undefined
|
||||
>;
|
||||
private readonly elements = new Set<HTMLMediaElement>();
|
||||
private processor: HandedOverTrack["processor"];
|
||||
/** What the processor reads frames from; a processor needs a playing element. */
|
||||
private processorInput?: HTMLVideoElement;
|
||||
private finished = false;
|
||||
|
||||
public constructor(
|
||||
public readonly source: Source,
|
||||
private track: MediaStreamTrack,
|
||||
private constraints: MediaTrackConstraints,
|
||||
private readonly getUserMedia: GetUserMedia,
|
||||
) {
|
||||
this.facingMode$ = new BehaviorSubject(facingModeOf(track));
|
||||
this.mediaTrack = this.asMediaTrack();
|
||||
}
|
||||
|
||||
public handOver(): HandedOverTrack {
|
||||
const { track, processor } = this;
|
||||
// The device actually in use, so that a backend asked for it again
|
||||
// recognises it rather than restarting the track
|
||||
const { deviceId } = track.getSettings();
|
||||
const constraints = { ...this.constraints, ...(deviceId && { deviceId }) };
|
||||
this.finish();
|
||||
return { track, constraints, muted: this.muted$.value, processor };
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
const { processor } = this;
|
||||
if (processor)
|
||||
void inTurn(processor.processor, async () =>
|
||||
processor.processor.destroy(),
|
||||
);
|
||||
this.processor?.processedTrack.stop();
|
||||
this.track.stop();
|
||||
this.finish();
|
||||
}
|
||||
|
||||
public async setProcessor(
|
||||
processor: VideoProcessor | undefined,
|
||||
): Promise<void> {
|
||||
this.live();
|
||||
if (processor === this.processor?.processor) return;
|
||||
const previous = this.processor?.processor;
|
||||
if (previous) await inTurn(previous, async () => previous.destroy());
|
||||
this.processor = processor && {
|
||||
processor,
|
||||
processedTrack: await inTurn(processor, async () =>
|
||||
processor.init(this.track, this.input()),
|
||||
),
|
||||
};
|
||||
this.show();
|
||||
}
|
||||
|
||||
private input(): HTMLVideoElement {
|
||||
this.processorInput ??= document.createElement("video");
|
||||
this.processorInput.muted = true;
|
||||
this.processorInput.playsInline = true;
|
||||
play(this.processorInput, this.track);
|
||||
return this.processorInput;
|
||||
}
|
||||
|
||||
private finish(): void {
|
||||
this.finished = true;
|
||||
for (const element of this.elements) element.srcObject = null;
|
||||
this.elements.clear();
|
||||
}
|
||||
|
||||
private live(): void {
|
||||
if (this.finished)
|
||||
throw new MatrixRTCError(
|
||||
`The ${this.source} was handed over to a participation`,
|
||||
);
|
||||
}
|
||||
|
||||
private shown(): MediaStreamTrack {
|
||||
return this.processor?.processedTrack ?? this.track;
|
||||
}
|
||||
|
||||
private show(): void {
|
||||
for (const element of this.elements) play(element, this.shown());
|
||||
}
|
||||
|
||||
private async reacquire(constraints: MediaTrackConstraints): Promise<void> {
|
||||
this.live();
|
||||
this.constraints = { ...this.constraints, ...constraints };
|
||||
const stream = await this.getUserMedia({
|
||||
[this.track.kind]: this.constraints,
|
||||
});
|
||||
this.track.stop();
|
||||
this.track = stream.getTracks()[0];
|
||||
this.track.enabled = !this.muted$.value;
|
||||
const { processor } = this;
|
||||
if (processor)
|
||||
processor.processedTrack = await inTurn(processor.processor, async () =>
|
||||
processor.processor.restart(this.track, this.input()),
|
||||
);
|
||||
this.facingMode$.next(facingModeOf(this.track));
|
||||
this.show();
|
||||
}
|
||||
|
||||
private asMediaTrack(): LocalAudioMediaTrack | LocalVideoMediaTrack {
|
||||
const base = {
|
||||
source: this.source,
|
||||
id: this.track.id,
|
||||
muted$: this.muted$,
|
||||
// Nothing leaves the device before the track is published, so there is
|
||||
// nothing unencrypted to report
|
||||
encrypted: true,
|
||||
stats$: (): Observable<MediaStreamStats> => of(undefined),
|
||||
attach: (element: HTMLMediaElement): void => {
|
||||
this.live();
|
||||
this.elements.add(element);
|
||||
play(element, this.shown());
|
||||
},
|
||||
detach: (element: HTMLMediaElement): void => {
|
||||
if (this.elements.delete(element)) element.srcObject = null;
|
||||
},
|
||||
setEnabled: async (enabled: boolean): Promise<boolean> => {
|
||||
this.live();
|
||||
this.track.enabled = enabled;
|
||||
this.muted$.next(!enabled);
|
||||
return Promise.resolve(enabled);
|
||||
},
|
||||
setDevice: async (deviceId: string): Promise<void> => {
|
||||
if (this.track.getSettings().deviceId === deviceId) return;
|
||||
await this.reacquire({ deviceId });
|
||||
},
|
||||
};
|
||||
if (this.source === "microphone")
|
||||
return {
|
||||
...base,
|
||||
kind: "audio",
|
||||
isActive$: constant(false),
|
||||
audioLevel$: constant(0),
|
||||
setAudioContext: () => {},
|
||||
setVolume: () => {},
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
kind: "video",
|
||||
facingMode$: this.facingMode$,
|
||||
switchFacingMode: async () => {
|
||||
const facingMode = this.facingMode$.value;
|
||||
if (facingMode === undefined) return;
|
||||
await this.reacquire({
|
||||
facingMode: facingMode === "user" ? "environment" : "user",
|
||||
});
|
||||
return this.track.getSettings().deviceId;
|
||||
},
|
||||
setProcessor: async (processor) => this.setProcessor(processor),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function play(element: HTMLMediaElement, track: MediaStreamTrack): void {
|
||||
element.srcObject = new MediaStream([track]);
|
||||
// Autoplay is not a given for a stream set after mount; a test DOM returns
|
||||
// nothing from play
|
||||
const playing = element.play() as Promise<void> | undefined;
|
||||
playing?.catch(() => undefined);
|
||||
}
|
||||
|
||||
function facingModeOf(
|
||||
track: MediaStreamTrack,
|
||||
): "user" | "environment" | undefined {
|
||||
const { facingMode } = track.getSettings();
|
||||
return facingMode === "user" || facingMode === "environment"
|
||||
? facingMode
|
||||
: undefined;
|
||||
}
|
||||
@@ -164,7 +164,7 @@ function setup(
|
||||
context: MediaBackendContext,
|
||||
): MediaBackend => {
|
||||
const backend: MediaBackend = {
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
transportType: "livekit",
|
||||
prepareLocalTransport: async () => fake.prepared.promise,
|
||||
delegateDelayedLeave: fake.delegateDelayedLeave,
|
||||
local: {
|
||||
|
||||
@@ -136,8 +136,7 @@ export function createRTCParticipation(
|
||||
// fallback: another member's SFU may let us publish (MSC4195). LiveKit only,
|
||||
// so it stays out of the backend interface
|
||||
const transportFromBackends = async (): Promise<Transport | null> =>
|
||||
backend.transportTypes.includes("m.livekit") ||
|
||||
backend.transportTypes.includes("livekit")
|
||||
backend.transportType === "livekit"
|
||||
? findRemotePublishLivekitTransport({
|
||||
client,
|
||||
// A snapshot: if nobody with a usable SFU is here yet, the user has
|
||||
@@ -153,7 +152,7 @@ export function createRTCParticipation(
|
||||
const preparedTransport$ = from(
|
||||
discoverLocalTransport(
|
||||
client,
|
||||
backend.transportTypes,
|
||||
backend.transportType,
|
||||
options,
|
||||
transportFromBackends,
|
||||
logger,
|
||||
|
||||
+6
-10
@@ -20,7 +20,6 @@ import { type Observable } from "rxjs";
|
||||
import { type Behavior } from "./reactive/Behavior";
|
||||
import {
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalMedia,
|
||||
type LocalMemberMedia,
|
||||
type LocalVideoMediaTrack,
|
||||
type MediaSource,
|
||||
@@ -85,15 +84,12 @@ export interface RTCSlot {
|
||||
|
||||
export interface RTCParticipationOptions {
|
||||
/**
|
||||
* What the local member publishes at the join: requests the SDK captures
|
||||
* itself, or the media a lobby captured ahead of the join, which the
|
||||
* participation takes over (its tracks leave `media.tracks$`) and publishes
|
||||
* in place of capturing. `publish` on the participation adds to either from
|
||||
* then on. Kept as an option, rather than only the method, because the
|
||||
* LiveKit backend reads it to build the room's default capture options
|
||||
* before the first connection exists.
|
||||
* Published at the join; `publish` on the participation adds to it from then
|
||||
* on. Kept as an option, rather than only the method, because the LiveKit
|
||||
* backend reads the initial requests to build the room's default capture
|
||||
* options before the first connection exists.
|
||||
*/
|
||||
publish: PublishRequest[] | LocalMedia;
|
||||
publish: PublishRequest[];
|
||||
/**
|
||||
* LEGACY
|
||||
*
|
||||
@@ -234,7 +230,7 @@ export interface DataMessage {
|
||||
* backend-specific developer panel and for connection diagnostics.
|
||||
*/
|
||||
export interface TransportMetadata {
|
||||
/** `"m.livekit"` or `"livekit"` today. */
|
||||
/** `"livekit"` today. */
|
||||
type: string;
|
||||
/** Stable key, unique per transport in the slot: the raw transport, serialised with sorted keys. */
|
||||
id: string;
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("discoverLocalTransport", () => {
|
||||
await expect(
|
||||
discoverLocalTransport(
|
||||
c,
|
||||
["m.livekit", "livekit"],
|
||||
"livekit",
|
||||
{ transport: hostTransport },
|
||||
fromBackends(),
|
||||
logger,
|
||||
@@ -61,7 +61,7 @@ describe("discoverLocalTransport", () => {
|
||||
await expect(
|
||||
discoverLocalTransport(
|
||||
client([homeserverTransport]),
|
||||
["m.livekit", "livekit"],
|
||||
"livekit",
|
||||
{ fallbackTransport: hostTransport },
|
||||
fromBackends(),
|
||||
logger,
|
||||
@@ -73,7 +73,7 @@ describe("discoverLocalTransport", () => {
|
||||
await expect(
|
||||
discoverLocalTransport(
|
||||
client([{ type: "not_livekit" }]),
|
||||
["m.livekit", "livekit"],
|
||||
"livekit",
|
||||
{ fallbackTransport: hostTransport },
|
||||
fromBackends(),
|
||||
logger,
|
||||
@@ -87,7 +87,7 @@ describe("discoverLocalTransport", () => {
|
||||
await expect(
|
||||
discoverLocalTransport(
|
||||
client([homeserverTransport]),
|
||||
["m.livekit", "livekit"],
|
||||
"livekit",
|
||||
{},
|
||||
backends,
|
||||
logger,
|
||||
@@ -99,7 +99,7 @@ describe("discoverLocalTransport", () => {
|
||||
it("fails naming the homeserver where there is no transport at all", async () => {
|
||||
const promise = discoverLocalTransport(
|
||||
client([]),
|
||||
["m.livekit", "livekit"],
|
||||
"livekit",
|
||||
{},
|
||||
fromBackends(),
|
||||
logger,
|
||||
|
||||
@@ -15,15 +15,14 @@ import { TransportAutoDiscovery } from "./TransportAutoDiscovery";
|
||||
|
||||
/**
|
||||
* The transport the local member advertises: the one the host names, else the
|
||||
* homeserver's first of the most preferred type the backend serves, else the
|
||||
* host's fallback, else the first the backends can provide, which are asked
|
||||
* only then.
|
||||
* homeserver's first of the type the backend serves, else the host's fallback,
|
||||
* else the first the backends can provide, which are asked only then.
|
||||
*
|
||||
* @throws {MatrixRTCTransportMissingError} where none of these exists.
|
||||
*/
|
||||
export async function discoverLocalTransport(
|
||||
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
|
||||
transportTypes: readonly string[],
|
||||
transportType: string,
|
||||
{
|
||||
transport: override,
|
||||
fallbackTransport,
|
||||
@@ -35,7 +34,7 @@ export async function discoverLocalTransport(
|
||||
override ??
|
||||
(await new TransportAutoDiscovery({
|
||||
client,
|
||||
transportTypes,
|
||||
transportType,
|
||||
fallbackTransport,
|
||||
logger,
|
||||
}).discoverPreferredTransport()) ??
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
transportType: "livekit",
|
||||
fallbackTransport: configTransport,
|
||||
logger: rootLogger,
|
||||
});
|
||||
@@ -75,29 +75,6 @@ describe("TransportAutoDiscovery", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("prefers a transport of an earlier type over one listed before it", async () => {
|
||||
const mLivekit: Transport = {
|
||||
type: "m.livekit",
|
||||
url: "wss://sfu.example.org",
|
||||
};
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue([
|
||||
backendTransport,
|
||||
mLivekit,
|
||||
]);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
fallbackTransport: configTransport,
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
|
||||
mLivekit,
|
||||
);
|
||||
});
|
||||
|
||||
it("Retries limit_exceeded backend transport", async () => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports
|
||||
@@ -115,7 +92,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
transportType: "livekit",
|
||||
fallbackTransport: configTransport,
|
||||
logger: rootLogger,
|
||||
});
|
||||
@@ -139,7 +116,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
transportType: "livekit",
|
||||
fallbackTransport: configTransport,
|
||||
logger: rootLogger,
|
||||
});
|
||||
@@ -156,7 +133,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
transportType: "livekit",
|
||||
fallbackTransport: undefined,
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
@@ -17,27 +17,27 @@ type TransportDiscoveryClient = Pick<
|
||||
|
||||
export interface TransportAutoDiscoveryProps {
|
||||
client: TransportDiscoveryClient;
|
||||
/** The transport types we want to discover. Those are the ones, the local media backend supports. most preferred first. */
|
||||
transportTypes: readonly string[];
|
||||
/** A transport to use when the homeserver advertises none of the types. */
|
||||
/** The transport type the media backend serves. */
|
||||
transportType: string;
|
||||
/** A transport to use when the homeserver advertises none of the type. */
|
||||
fallbackTransport?: Transport;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export class TransportAutoDiscovery {
|
||||
private readonly client: TransportDiscoveryClient;
|
||||
private readonly transportTypes: readonly string[];
|
||||
private readonly transportType: string;
|
||||
private readonly fallbackTransport: Transport | undefined;
|
||||
private readonly logger: Logger;
|
||||
|
||||
public constructor({
|
||||
client,
|
||||
transportTypes,
|
||||
transportType,
|
||||
fallbackTransport,
|
||||
logger,
|
||||
}: TransportAutoDiscoveryProps) {
|
||||
this.client = client;
|
||||
this.transportTypes = transportTypes;
|
||||
this.transportType = transportType;
|
||||
this.fallbackTransport = fallbackTransport;
|
||||
this.logger = logger.getChild("[TransportAutoDiscovery]");
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export class TransportAutoDiscovery {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the first transport of the backend's most preferred type from the homeserver.
|
||||
* Fetches the first transport of the backend's type from the homeserver.
|
||||
* This will not throw errors, but instead just log them and return null if the expected config is not found or malformed.
|
||||
* @private
|
||||
*/
|
||||
@@ -76,16 +76,17 @@ export class TransportAutoDiscovery {
|
||||
const transportList = await doNetworkOperationWithRetry(async () =>
|
||||
client._unstable_getRTCTransports(),
|
||||
);
|
||||
for (const type of this.transportTypes) {
|
||||
const first = transportList.find(
|
||||
(transport) => transport.type === type,
|
||||
);
|
||||
if (first) return first;
|
||||
}
|
||||
this.logger.info(
|
||||
`No ${this.transportTypes.join(" or ")} transport found in getRTCTransports end point`,
|
||||
transportList,
|
||||
const first = transportList.find(
|
||||
(transport) => transport.type === this.transportType,
|
||||
);
|
||||
if (first) {
|
||||
return first;
|
||||
} else {
|
||||
this.logger.info(
|
||||
`No ${this.transportType} transport found in getRTCTransports end point`,
|
||||
transportList,
|
||||
);
|
||||
}
|
||||
} catch (ex) {
|
||||
this.logger.info(`Failed to use getRTCTransports end point: ${ex}`);
|
||||
}
|
||||
|
||||
@@ -185,45 +185,3 @@ export interface MemberMedia {
|
||||
export interface LocalMemberMedia extends MemberMedia {
|
||||
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks captured ahead of a join, for a lobby: the same local tracks a
|
||||
* participation hands out, on nothing but the browser's devices, so they fit
|
||||
* whichever backend joins. Handed to `join` as `publish`, they are moved once
|
||||
* the participation publishes them: they leave `tracks$`, their objects are
|
||||
* finished (controls reject, elements detached), and the same devices come
|
||||
* back under `localMember$.tracks$` as new objects. `stop` releases only what
|
||||
* was not moved; `capture` works again afterwards.
|
||||
*/
|
||||
export interface LocalMedia {
|
||||
/** What was captured with, one per source; a join reads its defaults from here. */
|
||||
readonly requests: PublishRequest[];
|
||||
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[]>;
|
||||
/**
|
||||
* Opens the devices, in one permission prompt where the browser asks for
|
||||
* one. Rejects where a device cannot be opened, having opened none of them.
|
||||
* A source captured already is replaced.
|
||||
*/
|
||||
capture(
|
||||
requests: PublishRequest[],
|
||||
): Promise<(LocalAudioMediaTrack | LocalVideoMediaTrack)[]>;
|
||||
/** Closes the device behind a source; nothing where it was not captured. */
|
||||
release(source: MediaSource): void;
|
||||
/** Closes every device still held. */
|
||||
stop(): void;
|
||||
/**
|
||||
* For a media backend, as it starts publishing: moves every track out,
|
||||
* finishing its object here. Not for a host; the participation calls it.
|
||||
*/
|
||||
takeTracks(): Map<"microphone" | "camera", HandedOverTrack>;
|
||||
}
|
||||
|
||||
/** One track as a backend receives it: the device's own track and what runs on it. */
|
||||
export interface HandedOverTrack {
|
||||
track: MediaStreamTrack;
|
||||
/** What it was opened with, for a backend that restarts it. */
|
||||
constraints: MediaTrackConstraints;
|
||||
muted: boolean;
|
||||
/** Where a processor runs: it and its output, to adopt rather than initialise again. */
|
||||
processor?: { processor: VideoProcessor; processedTrack: MediaStreamTrack };
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import { type Observable } from "rxjs";
|
||||
import { type ResolvedTransport, type RTCMember } from "../api";
|
||||
import {
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalMedia,
|
||||
type LocalMemberMedia,
|
||||
type LocalVideoMediaTrack,
|
||||
type MediaSource,
|
||||
@@ -57,8 +56,8 @@ export interface MediaBackendContext {
|
||||
/** The slot the session is in, `application#id`; a token is issued for it. */
|
||||
slotId: string;
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
/** What the local member publishes at the join: requests, or a lobby's media. */
|
||||
publish: PublishRequest[] | LocalMedia;
|
||||
/** What the local member publishes at the join. */
|
||||
publish: PublishRequest[];
|
||||
encryptionSystem: EncryptionSystem;
|
||||
/**
|
||||
* Every media key the session knows: the current ones replayed on
|
||||
@@ -114,11 +113,8 @@ export interface DataPacket {
|
||||
}
|
||||
|
||||
export interface MediaBackend {
|
||||
/**
|
||||
* The membership transport types this backend serves, ordered decreasingly
|
||||
* by preference.
|
||||
*/
|
||||
readonly transportTypes: readonly string[];
|
||||
/** The membership transport type this backend serves: "livekit". */
|
||||
readonly transportType: string;
|
||||
|
||||
/**
|
||||
* Makes `transport` usable as the one the local member publishes on. The
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
vi,
|
||||
} from "vitest";
|
||||
import {
|
||||
ConnectionError,
|
||||
type LocalParticipant,
|
||||
type RemoteParticipant,
|
||||
type RemoteTrack,
|
||||
@@ -42,7 +41,7 @@ import {
|
||||
} from "./Connection.ts";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { type ClientGetTokenParts, type ClientOpenIDParts } from "./auth";
|
||||
import { LivekitConnectionError, MatrixRTCError } from "../../errors";
|
||||
import { MatrixRTCError } from "../../errors";
|
||||
import { testJWTToken } from "../../utils/test-fixtures";
|
||||
import { ownMemberMock } from "../../utils/test";
|
||||
import { mockRemoteParticipant } from "./test";
|
||||
@@ -58,10 +57,6 @@ let fakeLocalParticipant: MockedObject<LocalParticipant>;
|
||||
|
||||
const ROOM_ID = "!roomID:example.org";
|
||||
const SLOT_ID = "m.call#ROOM";
|
||||
const FIREFOX_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0";
|
||||
const CHROME_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
|
||||
|
||||
const transport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
@@ -152,13 +147,6 @@ function setupRemoteConnection(): Connection {
|
||||
return new Connection(opts, logger);
|
||||
}
|
||||
|
||||
function mockUserAgent(userAgent: string): void {
|
||||
const spy = vi
|
||||
.spyOn(navigator, "userAgent", "get")
|
||||
.mockReturnValue(userAgent);
|
||||
onTestFinished(() => spy.mockRestore());
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
@@ -387,89 +375,6 @@ describe("Start connection states", () => {
|
||||
expect(connection.livekitAlias).toBe("!example_room_id");
|
||||
});
|
||||
|
||||
it("retries a join that failed on the WebSocket in Firefox", async () => {
|
||||
setupTest();
|
||||
mockUserAgent(FIREFOX_USER_AGENT);
|
||||
const connection = setupRemoteConnection();
|
||||
const capturedStates: ConnectionState[] = [];
|
||||
const s = connection.state$.subscribe((value) => {
|
||||
capturedStates.push(value);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
const firstJoin = Promise.withResolvers<void>();
|
||||
fakeLivekitRoom.connect.mockReturnValueOnce(firstJoin.promise);
|
||||
|
||||
const started = connection.start();
|
||||
await vi.waitFor(() => expect(fakeLivekitRoom.connect).toHaveBeenCalled());
|
||||
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(1);
|
||||
firstJoin.reject(ConnectionError.websocket("WS closed unexpectedly"));
|
||||
await started;
|
||||
|
||||
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(2);
|
||||
expect(capturedStates.at(-1)).toEqual({
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Connected,
|
||||
});
|
||||
expect(capturedStates.some((st) => st.state === "error")).toBe(false);
|
||||
});
|
||||
|
||||
it("retries a join in Firefox only once", async () => {
|
||||
setupTest();
|
||||
mockUserAgent(FIREFOX_USER_AGENT);
|
||||
const connection = setupRemoteConnection();
|
||||
fakeLivekitRoom.connect.mockRejectedValue(
|
||||
ConnectionError.websocket("WS closed unexpectedly"),
|
||||
);
|
||||
|
||||
await expect(connection.start()).rejects.toThrow(LivekitConnectionError);
|
||||
|
||||
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not retry a join that failed for another reason in Firefox", async () => {
|
||||
setupTest();
|
||||
mockUserAgent(FIREFOX_USER_AGENT);
|
||||
const connection = setupRemoteConnection();
|
||||
fakeLivekitRoom.connect.mockRejectedValueOnce(
|
||||
ConnectionError.serverUnreachable("server was not reachable"),
|
||||
);
|
||||
|
||||
await expect(connection.start()).rejects.toThrow(LivekitConnectionError);
|
||||
|
||||
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry a join that failed on the WebSocket in other browsers", async () => {
|
||||
setupTest();
|
||||
mockUserAgent(CHROME_USER_AGENT);
|
||||
const connection = setupRemoteConnection();
|
||||
fakeLivekitRoom.connect.mockRejectedValueOnce(
|
||||
ConnectionError.websocket("WS closed unexpectedly"),
|
||||
);
|
||||
|
||||
await expect(connection.start()).rejects.toThrow(LivekitConnectionError);
|
||||
|
||||
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry a join in Firefox once stopped", async () => {
|
||||
setupTest();
|
||||
mockUserAgent(FIREFOX_USER_AGENT);
|
||||
const connection = setupRemoteConnection();
|
||||
const firstJoin = Promise.withResolvers<void>();
|
||||
fakeLivekitRoom.connect.mockReturnValueOnce(firstJoin.promise);
|
||||
fakeLivekitRoom.disconnect.mockResolvedValue(undefined);
|
||||
|
||||
const started = connection.start();
|
||||
await vi.waitFor(() => expect(fakeLivekitRoom.connect).toHaveBeenCalled());
|
||||
const stopping = connection.stop();
|
||||
firstJoin.reject(ConnectionError.websocket("WS closed unexpectedly"));
|
||||
await stopping;
|
||||
|
||||
await expect(started).resolves.toBeUndefined();
|
||||
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stopping while connecting does not report an error", async () => {
|
||||
setupTest();
|
||||
const connection = setupRemoteConnection();
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
ConnectionError,
|
||||
ConnectionErrorReason,
|
||||
type ConnectionState as LivekitConnectionState,
|
||||
getBrowser,
|
||||
type Participant,
|
||||
type RemoteParticipant,
|
||||
type RemoteTrackPublication,
|
||||
@@ -323,7 +322,7 @@ export class Connection {
|
||||
|
||||
try {
|
||||
this.logger.info(`livekitRoom.connect ${url}`);
|
||||
await this.connectLivekitRoom(url, jwt);
|
||||
await this.livekitRoom.connect(url, jwt);
|
||||
this.logger.info(`livekitRoom.connect SUCCESS ${url}`);
|
||||
} catch (e) {
|
||||
this.logger.info(`livekitRoom.connect FAILED ${url}`, e);
|
||||
@@ -383,23 +382,6 @@ export class Connection {
|
||||
}
|
||||
}
|
||||
|
||||
private async connectLivekitRoom(url: string, jwt: string): Promise<void> {
|
||||
try {
|
||||
await this.livekitRoom.connect(url, jwt);
|
||||
} catch (e) {
|
||||
// Behind a proxy, Firefox fails the first WebSocket to a host after trying
|
||||
// it over HTTP/2, so a join that fails on the WebSocket gets a second try.
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=2015411
|
||||
const failedOnFirefoxWebSocket =
|
||||
getBrowser()?.name === "Firefox" &&
|
||||
e instanceof ConnectionError &&
|
||||
e.reason === ConnectionErrorReason.WebSocket;
|
||||
if (this.stopped || !failedOnFirefoxWebSocket) throw e;
|
||||
this.logger.info(`livekitRoom.connect RETRY ${url}`, e);
|
||||
await this.livekitRoom.connect(url, jwt);
|
||||
}
|
||||
}
|
||||
|
||||
protected async getSFUConfig(): Promise<SFUConfig> {
|
||||
return await getSFUConfig({
|
||||
client: this.client,
|
||||
|
||||
@@ -92,7 +92,7 @@ export function createLivekitBackend(
|
||||
roomId,
|
||||
slotId,
|
||||
ownMembershipIdentity,
|
||||
publish: requestsOrMedia,
|
||||
publish,
|
||||
encryptionSystem,
|
||||
mediaKeys$,
|
||||
timings,
|
||||
@@ -101,9 +101,6 @@ export function createLivekitBackend(
|
||||
{ client, mediaQuality }: LivekitBackendOptions,
|
||||
): MediaBackend {
|
||||
const logger = parentLogger.getChild("[LivekitBackend]");
|
||||
const [publish, media] = Array.isArray(requestsOrMedia)
|
||||
? [requestsOrMedia, undefined]
|
||||
: [requestsOrMedia.requests, requestsOrMedia];
|
||||
const keyProvider = createKeyProvider(encryptionSystem, mediaKeys$, logger);
|
||||
const transports = createTransportRegistry(scope);
|
||||
const localTransport$ = new BehaviorSubject<TransportLocator | null>(null);
|
||||
@@ -140,7 +137,6 @@ export function createLivekitBackend(
|
||||
connectionMap$,
|
||||
localTransport$,
|
||||
publish,
|
||||
media,
|
||||
audioOutputDeviceId$,
|
||||
encryptionSystem,
|
||||
logger,
|
||||
@@ -283,7 +279,7 @@ export function createLivekitBackend(
|
||||
});
|
||||
|
||||
return {
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
transportType: "livekit",
|
||||
prepareLocalTransport,
|
||||
delegateDelayedLeave: delegateLeave,
|
||||
local: localMedia_,
|
||||
|
||||
@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
|
||||
import { EventEmitter } from "events";
|
||||
import {
|
||||
type LocalParticipant,
|
||||
LocalVideoTrack,
|
||||
ParticipantEvent,
|
||||
RemoteAudioTrack,
|
||||
type RemoteParticipant,
|
||||
@@ -18,22 +17,10 @@ import {
|
||||
TrackEvent,
|
||||
type TrackPublication,
|
||||
} from "livekit-client";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
onTestFinished,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import {
|
||||
type AudioMediaTrack,
|
||||
type LocalVideoMediaTrack,
|
||||
type VideoProcessor,
|
||||
} from "../../media-api";
|
||||
import { type AudioMediaTrack } from "../../media-api";
|
||||
import {
|
||||
createLivekitMediaTrack,
|
||||
createLocalLivekitMediaTrack,
|
||||
@@ -218,55 +205,8 @@ describe("createLivekitMediaTrack", () => {
|
||||
await expect(track.setEnabled(false)).resolves.toBe(false);
|
||||
expect(setEnabled).toHaveBeenCalledWith("microphone", false);
|
||||
await track.setDevice("mic-2");
|
||||
expect(switchActiveDevice).toHaveBeenCalledWith(
|
||||
"audioinput",
|
||||
"mic-2",
|
||||
false,
|
||||
);
|
||||
expect(switchActiveDevice).toHaveBeenCalledWith("audioinput", "mic-2");
|
||||
});
|
||||
|
||||
// LiveKit stops a camera that is turned off; the processor is built when it
|
||||
// is turned on again
|
||||
it.each([
|
||||
{ state: "is off", isMuted: true, takes: true },
|
||||
{ state: "has stopped for good", isMuted: false, takes: false },
|
||||
])(
|
||||
"gives a processor to a camera that $state: $takes",
|
||||
async ({ isMuted, takes }) => {
|
||||
// jsdom has none; LiveKit's facing mode checks against it
|
||||
vi.stubGlobal("MediaStreamTrack", class {});
|
||||
onTestFinished(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
const camera = Object.assign(Object.create(LocalVideoTrack.prototype), {
|
||||
isMuted,
|
||||
_mediaStreamTrack: {
|
||||
readyState: "ended",
|
||||
label: "",
|
||||
getSettings: () => ({}),
|
||||
},
|
||||
getProcessor: () => undefined,
|
||||
setProcessor: vi.fn().mockResolvedValue(undefined),
|
||||
}) as LocalVideoTrack;
|
||||
const { participant, publication, room } = fakes({
|
||||
track: camera as unknown as FakeTrack,
|
||||
});
|
||||
const track = createLocalLivekitMediaTrack(
|
||||
scope,
|
||||
Object.assign(participant, {
|
||||
isLocal: true,
|
||||
}) as unknown as LocalParticipant,
|
||||
publication as unknown as TrackPublication,
|
||||
room,
|
||||
vi.fn(),
|
||||
) as LocalVideoMediaTrack;
|
||||
|
||||
await track.setProcessor({ name: "blur" } as VideoProcessor);
|
||||
expect(vi.mocked(camera.setProcessor).mock.calls.length).toBe(
|
||||
takes ? 1 : 0,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
type FakeTrack = Track & {
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
type VideoMediaTrack,
|
||||
} from "../../media-api";
|
||||
import { MatrixRTCError } from "../../errors";
|
||||
import { toLivekitProcessor, type LivekitProcessor } from "./videoProcessor";
|
||||
import { convertToLivekitProcessor } from "./videoProcessor";
|
||||
|
||||
export const mediaSources: Record<Track.Source, MediaSource> = {
|
||||
[Track.Source.Microphone]: "microphone",
|
||||
@@ -197,19 +197,9 @@ export function createLocalLivekitMediaTrack(
|
||||
setDevice: async (deviceId: string): Promise<void> => {
|
||||
if (base.source !== "microphone" && base.source !== "camera")
|
||||
throw new MatrixRTCError(`A ${base.source} track has no device`);
|
||||
// LiveKit decides whether to restart by the constraints it keeps, which
|
||||
// a browser that does not echo the device id back (Firefox) leaves
|
||||
// without one; a track already on the device is left alone here
|
||||
const current = publication.track;
|
||||
if (
|
||||
current instanceof LocalTrack &&
|
||||
current.mediaStreamTrack.getSettings().deviceId === deviceId
|
||||
)
|
||||
return;
|
||||
await room.switchActiveDevice(
|
||||
base.kind === "audio" ? "audioinput" : "videoinput",
|
||||
deviceId,
|
||||
false,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -238,17 +228,11 @@ export function createLocalLivekitMediaTrack(
|
||||
},
|
||||
setProcessor: async (processor) => {
|
||||
const track = videoTrack();
|
||||
// A camera that is off has a stopped track and takes the processor on
|
||||
// turning on again; one stopped for good takes none
|
||||
if (
|
||||
!track ||
|
||||
(track.mediaStreamTrack.readyState === "ended" && !track.isMuted)
|
||||
)
|
||||
return;
|
||||
const current = track.getProcessor() as LivekitProcessor | undefined;
|
||||
if (processor === current?.source) return;
|
||||
if (processor) await track.setProcessor(toLivekitProcessor(processor));
|
||||
else if (current) await track.stopProcessor();
|
||||
// A processor cannot be built on a track that has already ended
|
||||
if (!track || track.mediaStreamTrack.readyState === "ended") return;
|
||||
if (processor)
|
||||
await track.setProcessor(convertToLivekitProcessor(processor));
|
||||
else if (track.getProcessor()) await track.stopProcessor();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
|
||||
import {
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalMedia,
|
||||
type LocalVideoMediaTrack,
|
||||
type MediaSource,
|
||||
type PublishRequest,
|
||||
@@ -58,8 +57,6 @@ interface Props {
|
||||
/** The transport we publish on; null until the session has found it. */
|
||||
localTransport$: Behavior<TransportLocator | null>;
|
||||
publish: PublishRequest[];
|
||||
/** Tracks the host captured ahead of the join; the publisher takes them over. */
|
||||
media?: LocalMedia;
|
||||
/** Read by the connection factory for the rooms it creates from now on. */
|
||||
audioOutputDeviceId$: BehaviorSubject<string | undefined>;
|
||||
encryptionSystem: EncryptionSystem;
|
||||
@@ -82,7 +79,6 @@ export function createLivekitLocalMedia({
|
||||
connectionMap$,
|
||||
localTransport$,
|
||||
publish: initialRequests,
|
||||
media,
|
||||
audioOutputDeviceId$,
|
||||
encryptionSystem,
|
||||
logger: parentLogger,
|
||||
@@ -123,7 +119,6 @@ export function createLivekitLocalMedia({
|
||||
connection.livekitRoom,
|
||||
desired,
|
||||
logger.getChild(`[Publisher ${JSON.stringify(connection.transport)}]`),
|
||||
media,
|
||||
);
|
||||
publisher$.next(publisher);
|
||||
return Promise.resolve(async (): Promise<void> => {
|
||||
|
||||
@@ -11,32 +11,16 @@ import {
|
||||
LocalParticipant,
|
||||
type LocalTrack,
|
||||
type LocalTrackPublication,
|
||||
LocalVideoTrack,
|
||||
ParticipantEvent,
|
||||
type Room as LivekitRoom,
|
||||
Track,
|
||||
type TrackPublishOptions,
|
||||
} from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
type HandedOverTrack,
|
||||
type LocalMedia,
|
||||
type PublishRequest,
|
||||
type VideoProcessor,
|
||||
} from "../../media-api";
|
||||
import { type PublishRequest } from "../../media-api";
|
||||
import { flushPromises } from "../../utils/test";
|
||||
import { type DesiredMedia, Publisher } from "./Publisher";
|
||||
import { type LivekitProcessor } from "./videoProcessor";
|
||||
|
||||
describe("Publisher", () => {
|
||||
describe("setAudioOutputDevice", () => {
|
||||
@@ -120,24 +104,6 @@ describe("Publisher", () => {
|
||||
expect(participant.createTracks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates both with the options of requests made since the join", async () => {
|
||||
const processor: VideoProcessor = {
|
||||
name: "effect",
|
||||
init: vi.fn(),
|
||||
restart: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
want(microphone, { source: "camera", deviceId: "front", processor });
|
||||
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(room.options.videoCaptureDefaults).toMatchObject({
|
||||
deviceId: "front",
|
||||
processor: expect.objectContaining({ source: processor }),
|
||||
});
|
||||
});
|
||||
|
||||
it("asks for the camera and the microphone in one call", async () => {
|
||||
const enableBoth = vi.spyOn(participant, "enableCameraAndMicrophone");
|
||||
want(microphone, camera);
|
||||
@@ -361,143 +327,6 @@ function fakeRoom(
|
||||
return { room, switchActiveDevice };
|
||||
}
|
||||
|
||||
// A lobby's tracks come as the device's own MediaStreamTracks; LiveKit's
|
||||
// track classes are real here so that what is published is what LiveKit will
|
||||
// restart and stop as its own
|
||||
describe("adopting a lobby's tracks", () => {
|
||||
// LiveKit wraps every track in a MediaStream, which jsdom does not have
|
||||
beforeAll(() => {
|
||||
globalThis.MediaStream = class {
|
||||
public constructor(private readonly tracks: MediaStreamTrack[]) {}
|
||||
public getTracks(): MediaStreamTrack[] {
|
||||
return this.tracks;
|
||||
}
|
||||
} as unknown as typeof MediaStream;
|
||||
});
|
||||
|
||||
function setup(
|
||||
source: "microphone" | "camera",
|
||||
handed: HandedOverTrack,
|
||||
): { participant: LocalParticipant; publisher: Publisher } {
|
||||
const participant = fakeLocalParticipant(async () => Promise.resolve());
|
||||
const room = {
|
||||
options: {},
|
||||
setE2EEEnabled: () => undefined,
|
||||
localParticipant: participant,
|
||||
} as unknown as LivekitRoom;
|
||||
const desired: DesiredMedia = new Map([
|
||||
[source, { request: { source }, enabled: true }],
|
||||
]);
|
||||
const media = {
|
||||
takeTracks: () => new Map([[source, handed]]),
|
||||
} as unknown as LocalMedia;
|
||||
const publisher = new Publisher(room, desired, logger, media);
|
||||
return { participant, publisher };
|
||||
}
|
||||
|
||||
it("publishes the lobby's track as its own rather than capturing", async () => {
|
||||
const handed = handedOver("video");
|
||||
const { participant, publisher } = setup("camera", handed);
|
||||
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(participant.createTracks).not.toHaveBeenCalled();
|
||||
const [published, options] = vi.mocked(participant.publishTrack).mock
|
||||
.calls[0] as [LocalVideoTrack, TrackPublishOptions];
|
||||
expect(published).toBeInstanceOf(LocalVideoTrack);
|
||||
expect(published.mediaStreamTrack).toBe(handed.track);
|
||||
expect(published.isUserProvided).toBe(false);
|
||||
expect(options.source).toBe(Track.Source.Camera);
|
||||
});
|
||||
|
||||
it("adopts the processor's output without initialising it again", async () => {
|
||||
const setProcessor = vi
|
||||
.spyOn(LocalVideoTrack.prototype, "setProcessor")
|
||||
.mockResolvedValue(undefined);
|
||||
const processor = {
|
||||
name: "blur",
|
||||
init: vi.fn(),
|
||||
restart: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
} as unknown as VideoProcessor;
|
||||
const processedTrack = fakeMediaStreamTrack("video");
|
||||
const handed = handedOver("video", {
|
||||
processor: { processor, processedTrack },
|
||||
});
|
||||
const { publisher } = setup("camera", handed);
|
||||
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
|
||||
const adapter = setProcessor.mock.calls[0][0] as LivekitProcessor;
|
||||
await adapter.init({ kind: Track.Kind.Video, track: handed.track });
|
||||
expect(adapter.processedTrack).toBe(processedTrack);
|
||||
expect(adapter.source).toBe(processor);
|
||||
expect(processor.init).not.toHaveBeenCalled();
|
||||
setProcessor.mockRestore();
|
||||
});
|
||||
|
||||
// A development build mounts, unmounts and mounts the call again: the first
|
||||
// publisher must not have taken the tracks the second one needs
|
||||
it("leaves the lobby's tracks alone until it starts", async () => {
|
||||
const participant = fakeLocalParticipant(async () => Promise.resolve());
|
||||
const room = {
|
||||
options: {},
|
||||
setE2EEEnabled: () => undefined,
|
||||
localParticipant: participant,
|
||||
} as unknown as LivekitRoom;
|
||||
const takeTracks = vi.fn(() => new Map());
|
||||
const media = { takeTracks } as unknown as LocalMedia;
|
||||
|
||||
const publisher = new Publisher(room, new Map(), logger, media);
|
||||
await publisher.destroy();
|
||||
|
||||
expect(takeTracks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("joins muted where the lobby muted", async () => {
|
||||
const { participant, publisher } = setup(
|
||||
"microphone",
|
||||
handedOver("audio", { muted: true }),
|
||||
);
|
||||
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
|
||||
const [published] = vi.mocked(participant.publishTrack).mock.calls[0];
|
||||
expect((published as LocalTrack).isMuted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function fakeMediaStreamTrack(kind: "audio" | "video"): MediaStreamTrack {
|
||||
return {
|
||||
kind,
|
||||
id: `${kind}-track`,
|
||||
label: "",
|
||||
enabled: true,
|
||||
readyState: "live",
|
||||
getSettings: () => ({}),
|
||||
getConstraints: () => ({}),
|
||||
getCapabilities: () => ({}),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
} as unknown as MediaStreamTrack;
|
||||
}
|
||||
|
||||
function handedOver(
|
||||
kind: "audio" | "video",
|
||||
extra: Partial<HandedOverTrack> = {},
|
||||
): HandedOverTrack {
|
||||
return {
|
||||
track: fakeMediaStreamTrack(kind),
|
||||
constraints: { deviceId: "lobby" },
|
||||
muted: false,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeLocalTrack(source: Track.Source): LocalTrack {
|
||||
const state = { isMuted: false, isUpstreamPaused: false };
|
||||
const track = {
|
||||
|
||||
@@ -6,23 +6,15 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
LocalAudioTrack,
|
||||
type LocalTrackPublication,
|
||||
LocalVideoTrack,
|
||||
ParticipantEvent,
|
||||
type Room as LivekitRoom,
|
||||
type Track,
|
||||
} from "livekit-client";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
type HandedOverTrack,
|
||||
type LocalMedia,
|
||||
type MediaSource,
|
||||
type PublishRequest,
|
||||
} from "../../media-api";
|
||||
import { type MediaSource, type PublishRequest } from "../../media-api";
|
||||
import { livekitSources, mediaSources } from "./LivekitMediaTrack";
|
||||
import { toLivekitProcessor } from "./videoProcessor";
|
||||
import {
|
||||
audioCaptureOptions,
|
||||
screenShareCaptureOptions,
|
||||
@@ -39,9 +31,6 @@ export interface DesiredPublication {
|
||||
|
||||
export type DesiredMedia = Map<MediaSource, DesiredPublication>;
|
||||
|
||||
/** A lobby's tracks, moved out of its media, until each is published. */
|
||||
export type HandedOverTracks = ReturnType<LocalMedia["takeTracks"]>;
|
||||
|
||||
/**
|
||||
* Publishes the local media on one LiveKit room, following the host's
|
||||
* requests.
|
||||
@@ -57,19 +46,10 @@ export class Publisher {
|
||||
public started = false;
|
||||
private readonly room: LivekitRoom;
|
||||
|
||||
/** A lobby's tracks, taken over at `start`; each leaves the map as it is published. */
|
||||
private captured: HandedOverTracks = new Map();
|
||||
|
||||
/**
|
||||
* @param media - What a lobby opened ahead of the join, published in place
|
||||
* of capturing. Taken over only at `start`, so that a publisher torn down
|
||||
* before it publishes leaves the tracks to the next one.
|
||||
*/
|
||||
public constructor(
|
||||
room: LivekitRoom,
|
||||
private readonly desired: DesiredMedia,
|
||||
private readonly logger: Logger,
|
||||
private readonly media?: LocalMedia,
|
||||
) {
|
||||
this.room = room;
|
||||
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
|
||||
@@ -99,106 +79,33 @@ export class Publisher {
|
||||
public start(): void {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
this.captured = this.media?.takeTracks() ?? new Map();
|
||||
// A track muted in the lobby joins muted
|
||||
for (const [source, { muted }] of this.captured) {
|
||||
const wanted = this.desired.get(source);
|
||||
if (wanted && muted) wanted.enabled = false;
|
||||
}
|
||||
const requests = [...this.desired.values()]
|
||||
.filter(
|
||||
({ request, enabled }) => enabled || this.hasCaptured(request.source),
|
||||
)
|
||||
.filter(({ enabled }) => enabled)
|
||||
.map(({ request }) => request);
|
||||
const toCapture = new Set(
|
||||
requests
|
||||
.map((request) => request.source)
|
||||
.filter((source) => !this.hasCaptured(source)),
|
||||
);
|
||||
const sources = new Set(requests.map((request) => request.source));
|
||||
// Microphone and camera in one call so that the browser asks for
|
||||
// permission once; the room defaults carry their options. LiveKit
|
||||
// resolves these once the track is published, which may block on the
|
||||
// connection; LocalTrackPublished is what tells us a track exists.
|
||||
const captureBoth = toCapture.has("microphone") && toCapture.has("camera");
|
||||
if (captureBoth) {
|
||||
this.takeCaptureDefaults(requests);
|
||||
if (sources.has("microphone") && sources.has("camera"))
|
||||
void this.room.localParticipant.enableCameraAndMicrophone();
|
||||
}
|
||||
for (const request of requests)
|
||||
if (
|
||||
request.source === "screenShare" ||
|
||||
this.hasCaptured(request.source) ||
|
||||
!captureBoth
|
||||
!(sources.has("microphone") && sources.has("camera"))
|
||||
)
|
||||
this.publish(request).catch((e) => {
|
||||
this.logger.error(`Failed to publish the ${request.source}`, e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the requests the room's capture defaults. The room took its defaults
|
||||
* from the requests at the join, but one made since, such as a camera turned
|
||||
* on before the start, has options of its own: a video processor dropped
|
||||
* here would let the room see the camera before it is applied.
|
||||
*/
|
||||
private takeCaptureDefaults(requests: PublishRequest[]): void {
|
||||
const options = this.room.options;
|
||||
for (const request of requests)
|
||||
if (request.source === "microphone")
|
||||
options.audioCaptureDefaults = {
|
||||
...options.audioCaptureDefaults,
|
||||
...audioCaptureOptions(request),
|
||||
};
|
||||
else if (request.source === "camera")
|
||||
options.videoCaptureDefaults = {
|
||||
...options.videoCaptureDefaults,
|
||||
...videoCaptureOptions(request),
|
||||
};
|
||||
}
|
||||
|
||||
private hasCaptured(source: MediaSource): boolean {
|
||||
return (
|
||||
(source === "microphone" || source === "camera") &&
|
||||
this.captured.has(source)
|
||||
);
|
||||
}
|
||||
|
||||
private takeCaptured(request: PublishRequest): HandedOverTrack | undefined {
|
||||
if (request.source === "screenShare") return undefined;
|
||||
const track = this.captured.get(request.source);
|
||||
this.captured.delete(request.source);
|
||||
return track;
|
||||
}
|
||||
|
||||
/** A lobby's track as if this participant had captured it: LiveKit restarts and stops it as its own. */
|
||||
private async adopt(
|
||||
{ track, constraints, muted, processor }: HandedOverTrack,
|
||||
request: PublishRequest,
|
||||
): Promise<LocalTrackPublication> {
|
||||
const local =
|
||||
track.kind === "audio"
|
||||
? new LocalAudioTrack(track, constraints, false)
|
||||
: new LocalVideoTrack(track, constraints, false);
|
||||
if (muted) await local.mute();
|
||||
if (processor && local instanceof LocalVideoTrack)
|
||||
await local.setProcessor(
|
||||
toLivekitProcessor(processor.processor, processor.processedTrack),
|
||||
);
|
||||
return this.room.localParticipant.publishTrack(local, {
|
||||
source: livekitSources[request.source],
|
||||
...(request.source === "camera" && videoPublishOptions(request.capture)),
|
||||
});
|
||||
}
|
||||
|
||||
/** Publishes one request and resolves with its publication once LiveKit has it. */
|
||||
public async publish(
|
||||
request: PublishRequest,
|
||||
): Promise<LocalTrackPublication | undefined> {
|
||||
const participant = this.room.localParticipant;
|
||||
let publication: LocalTrackPublication | undefined;
|
||||
const captured = this.takeCaptured(request);
|
||||
if (captured) publication = await this.adopt(captured, request);
|
||||
else if (request.source === "microphone")
|
||||
if (request.source === "microphone")
|
||||
publication = await participant.setMicrophoneEnabled(
|
||||
true,
|
||||
audioCaptureOptions(request),
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
import { serverNameOfTransport } from "../../utils/serverName";
|
||||
import { isCsApiUnavailable } from "./auth";
|
||||
import * as csApi from "./auth/csApi";
|
||||
import { decodeSFUJWTPayload } from "./auth/types";
|
||||
@@ -60,7 +59,7 @@ export async function findRemotePublishLivekitTransport({
|
||||
if (!isUnstableLivekitTransport(transport) || !("url" in transport))
|
||||
continue;
|
||||
const { url } = transport;
|
||||
const serverName = serverNameOfTransport(transport, membership.userId);
|
||||
const serverName = membership.getTransportServerName(transport);
|
||||
const key = JSON.stringify([url, serverName]);
|
||||
if (tried.has(key)) continue;
|
||||
tried.add(key);
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type PublishRequest,
|
||||
type VideoCaptureSettings,
|
||||
} from "../../media-api";
|
||||
import { toLivekitProcessor } from "./videoProcessor";
|
||||
import { convertToLivekitProcessor } from "./videoProcessor";
|
||||
|
||||
export type PublishRequestFor<S extends PublishRequest["source"]> = Extract<
|
||||
PublishRequest,
|
||||
@@ -40,7 +40,7 @@ export function videoCaptureOptions({
|
||||
if (!deviceId && !processor && !capture?.resolution) return undefined;
|
||||
return {
|
||||
...(deviceId && { deviceId }),
|
||||
...(processor && { processor: toLivekitProcessor(processor) }),
|
||||
...(processor && { processor: convertToLivekitProcessor(processor) }),
|
||||
...(capture?.resolution && { resolution: capture.resolution }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,18 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
type TrackProcessor,
|
||||
type VideoProcessorOptions,
|
||||
} from "livekit-client";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { Track, type VideoProcessorOptions } from "livekit-client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { type VideoProcessor } from "../../media-api";
|
||||
import { fromLivekitProcessor, toLivekitProcessor } from "./videoProcessor";
|
||||
import { convertToLivekitProcessor } from "./videoProcessor";
|
||||
|
||||
describe("toLivekitProcessor", () => {
|
||||
describe("convertToLivekitProcessor", () => {
|
||||
const captured = { id: "captured" } as MediaStreamTrack;
|
||||
const processed = { id: "processed" } as MediaStreamTrack;
|
||||
const restarted = { id: "restarted" } as MediaStreamTrack;
|
||||
@@ -38,7 +33,7 @@ describe("toLivekitProcessor", () => {
|
||||
|
||||
it("holds the processed track where LiveKit reads it", async () => {
|
||||
const processor = fakeProcessor();
|
||||
const adapter = toLivekitProcessor(processor);
|
||||
const adapter = convertToLivekitProcessor(processor);
|
||||
expect(adapter.name).toBe("blur");
|
||||
expect(adapter.processedTrack).toBeUndefined();
|
||||
|
||||
@@ -52,202 +47,4 @@ describe("toLivekitProcessor", () => {
|
||||
await adapter.destroy();
|
||||
expect(processor.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A stopped track tears its processor down without waiting, while the track
|
||||
// replacing it initialises the same one
|
||||
it("runs a destroy and the next init on one processor in turn, across adapters", async () => {
|
||||
const processor = fakeProcessor();
|
||||
let finishDestroy = (): void => {};
|
||||
vi.mocked(processor.destroy).mockImplementation(
|
||||
async () =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishDestroy = resolve;
|
||||
}),
|
||||
);
|
||||
const previous = toLivekitProcessor(processor);
|
||||
const next = toLivekitProcessor(processor);
|
||||
await previous.init(options);
|
||||
vi.mocked(processor.init).mockClear();
|
||||
|
||||
const destroying = previous.destroy();
|
||||
const initialising = next.init(options);
|
||||
await Promise.resolve();
|
||||
expect(processor.init).not.toHaveBeenCalled();
|
||||
|
||||
finishDestroy();
|
||||
await Promise.all([destroying, initialising]);
|
||||
expect(processor.init).toHaveBeenCalledOnce();
|
||||
expect(next.processedTrack).toBe(processed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a processor set on a camera that is off", () => {
|
||||
const ended = { id: "ended", readyState: "ended" } as MediaStreamTrack;
|
||||
|
||||
function fakeProcessor(): VideoProcessor {
|
||||
return {
|
||||
name: "blur",
|
||||
init: vi.fn().mockResolvedValue({ id: "processed" }),
|
||||
restart: vi.fn().mockResolvedValue({ id: "restarted" }),
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
it("builds on the restart that turns the camera on, not before", async () => {
|
||||
const processor = fakeProcessor();
|
||||
const adapter = toLivekitProcessor(processor);
|
||||
|
||||
await adapter.init({ kind: Track.Kind.Video, track: ended });
|
||||
expect(processor.init).not.toHaveBeenCalled();
|
||||
expect(adapter.processedTrack).toBeUndefined();
|
||||
|
||||
const live = { id: "live", readyState: "live" } as MediaStreamTrack;
|
||||
await adapter.restart({ kind: Track.Kind.Video, track: live });
|
||||
expect(processor.init).toHaveBeenCalledWith(live, undefined);
|
||||
expect(processor.restart).not.toHaveBeenCalled();
|
||||
expect(adapter.processedTrack).toEqual({ id: "processed" });
|
||||
});
|
||||
|
||||
// The processor may be the app's one pipeline, running on another track
|
||||
it("leaves the processor alone when let go before it was built", async () => {
|
||||
const processor = fakeProcessor();
|
||||
const adapter = toLivekitProcessor(processor);
|
||||
await adapter.init({ kind: Track.Kind.Video, track: ended });
|
||||
await adapter.destroy();
|
||||
expect(processor.destroy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// LiveKit's own camera track, so that the order of its restart is what is
|
||||
// tested: a raw frame handed to the sender would reach the room
|
||||
describe("on LiveKit's camera track", () => {
|
||||
// jsdom has no MediaStream; LiveKit wraps every track in one
|
||||
beforeAll(() => {
|
||||
const original = globalThis.MediaStream;
|
||||
globalThis.MediaStream = class {
|
||||
public constructor(private readonly tracks: MediaStreamTrack[] = []) {}
|
||||
public getTracks(): MediaStreamTrack[] {
|
||||
return this.tracks;
|
||||
}
|
||||
public getVideoTracks(): MediaStreamTrack[] {
|
||||
return this.tracks.filter(({ kind }) => kind === "video");
|
||||
}
|
||||
public getAudioTracks(): MediaStreamTrack[] {
|
||||
return this.tracks.filter(({ kind }) => kind === "audio");
|
||||
}
|
||||
public addTrack(track: MediaStreamTrack): void {
|
||||
this.tracks.push(track);
|
||||
}
|
||||
public removeTrack(track: MediaStreamTrack): void {
|
||||
this.tracks.splice(this.tracks.indexOf(track), 1);
|
||||
}
|
||||
} as unknown as typeof MediaStream;
|
||||
// LiveKit plays its processor element and catches a refusal
|
||||
const play = vi
|
||||
.spyOn(HTMLMediaElement.prototype, "play")
|
||||
.mockResolvedValue(undefined);
|
||||
return (): void => {
|
||||
globalThis.MediaStream = original;
|
||||
play.mockRestore();
|
||||
};
|
||||
});
|
||||
|
||||
function deviceTrack(id: string): MediaStreamTrack {
|
||||
const track = {
|
||||
kind: "video",
|
||||
id,
|
||||
label: "",
|
||||
enabled: true,
|
||||
readyState: "live",
|
||||
getSettings: () => ({}),
|
||||
getConstraints: () => ({}),
|
||||
getCapabilities: () => ({}),
|
||||
applyConstraints: vi.fn().mockResolvedValue(undefined),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
stop: vi.fn(() => {
|
||||
Object.assign(track, { readyState: "ended" });
|
||||
}),
|
||||
};
|
||||
return track as unknown as MediaStreamTrack;
|
||||
}
|
||||
|
||||
it("hands the sender only processed frames when the camera turns on", async () => {
|
||||
const reacquired = deviceTrack("reacquired");
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getUserMedia: vi.fn().mockResolvedValue({
|
||||
getTracks: () => [reacquired],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const processedTrack = { id: "processed" } as MediaStreamTrack;
|
||||
const processor = {
|
||||
...fakeProcessor(),
|
||||
init: vi.fn().mockResolvedValue(processedTrack),
|
||||
};
|
||||
const camera = new LocalVideoTrack(deviceTrack("first"), {}, false);
|
||||
camera.source = Track.Source.Camera;
|
||||
const replaceTrack = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(camera, {
|
||||
sender: {
|
||||
replaceTrack,
|
||||
getParameters: () => ({ encodings: [] }),
|
||||
setParameters: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
|
||||
await camera.mute();
|
||||
expect(camera.mediaStreamTrack.readyState).toBe("ended");
|
||||
// What the camera sent before it was turned off is not in question
|
||||
replaceTrack.mockClear();
|
||||
await camera.setProcessor(toLivekitProcessor(processor));
|
||||
await camera.unmute();
|
||||
|
||||
expect(processor.init).toHaveBeenCalledWith(
|
||||
reacquired,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(replaceTrack.mock.calls).toEqual([[processedTrack]]);
|
||||
// LiveKit plays attached elements again on a timer
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromLivekitProcessor", () => {
|
||||
const captured = { id: "captured" } as MediaStreamTrack;
|
||||
const processed = { id: "processed" } as MediaStreamTrack;
|
||||
|
||||
function fakeLivekitProcessor(
|
||||
processedTrack: MediaStreamTrack | undefined,
|
||||
): TrackProcessor<Track.Kind.Video> {
|
||||
return {
|
||||
name: "background-blur",
|
||||
processedTrack,
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
restart: vi.fn().mockResolvedValue(undefined),
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
it("resolves with the track the LiveKit processor produced", async () => {
|
||||
const livekitProcessor = fakeLivekitProcessor(processed);
|
||||
const processor = fromLivekitProcessor(livekitProcessor);
|
||||
expect(processor.name).toBe("background-blur");
|
||||
await expect(processor.init(captured)).resolves.toBe(processed);
|
||||
expect(livekitProcessor.init).toHaveBeenCalledWith({
|
||||
kind: Track.Kind.Video,
|
||||
track: captured,
|
||||
});
|
||||
await expect(processor.restart(captured)).resolves.toBe(processed);
|
||||
await processor.destroy();
|
||||
expect(livekitProcessor.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects where the LiveKit processor produced no track", async () => {
|
||||
await expect(
|
||||
fromLivekitProcessor(fakeLivekitProcessor(undefined)).init(captured),
|
||||
).rejects.toThrow("produced no track");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,84 +5,30 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Track, type TrackProcessor } from "livekit-client";
|
||||
import { type Track, type TrackProcessor } from "livekit-client";
|
||||
|
||||
import { type VideoProcessor } from "../../media-api";
|
||||
import { inTurn } from "../../utils/processorTurns";
|
||||
|
||||
export interface LivekitProcessor extends TrackProcessor<Track.Kind.Video> {
|
||||
/** The processor this adapts, so that setting the same one again is a no-op. */
|
||||
source: VideoProcessor;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `VideoProcessor` as a livekit-client processor. LiveKit reads the
|
||||
* processed track off the processor after `init` and `restart` rather than
|
||||
* taking it as their result, so the adapter keeps it there. A processor a
|
||||
* lobby already runs on the track is adopted with its output, `running`, so
|
||||
* that the join does not initialise it again. Every step takes its turn on
|
||||
* the processor, which a stopped track tears down without waiting for the
|
||||
* next one's init.
|
||||
*
|
||||
* Set on a camera that is off, whose track LiveKit has stopped, the build
|
||||
* waits for the restart that turns it back on. LiveKit restarts a processor
|
||||
* before it hands the new track to the sender, so the camera's first frame
|
||||
* out is already processed.
|
||||
* A `VideoProcessor` as livekit-client wants it. LiveKit reads the processed
|
||||
* track off the processor after `init` and `restart` rather than taking it as
|
||||
* their result, so the adapter keeps it there.
|
||||
*/
|
||||
export function toLivekitProcessor(
|
||||
export function convertToLivekitProcessor(
|
||||
processor: VideoProcessor,
|
||||
running?: MediaStreamTrack,
|
||||
): LivekitProcessor {
|
||||
let built = false;
|
||||
const adapter: LivekitProcessor = {
|
||||
): TrackProcessor<Track.Kind.Video> {
|
||||
const adapter: TrackProcessor<Track.Kind.Video> = {
|
||||
name: processor.name,
|
||||
source: processor,
|
||||
init: async (opts) =>
|
||||
inTurn(processor, async () => {
|
||||
if (running === undefined && opts.track.readyState === "ended") return;
|
||||
adapter.processedTrack =
|
||||
running ?? (await processor.init(opts.track, opts.element));
|
||||
running = undefined;
|
||||
built = true;
|
||||
}),
|
||||
restart: async (opts) =>
|
||||
inTurn(processor, async () => {
|
||||
adapter.processedTrack = built
|
||||
? await processor.restart(opts.track, opts.element)
|
||||
: await processor.init(opts.track, opts.element);
|
||||
built = true;
|
||||
}),
|
||||
destroy: async () =>
|
||||
inTurn(processor, async () => {
|
||||
if (built) await processor.destroy();
|
||||
}),
|
||||
};
|
||||
return adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* A livekit-client processor, such as one from `@livekit/track-processors`,
|
||||
* as a `VideoProcessor`: `init` and `restart` resolve with the track it
|
||||
* leaves on itself.
|
||||
*/
|
||||
export function fromLivekitProcessor(
|
||||
processor: TrackProcessor<Track.Kind.Video>,
|
||||
): VideoProcessor {
|
||||
const processedTrack = (): MediaStreamTrack => {
|
||||
if (processor.processedTrack === undefined)
|
||||
throw new Error(`Processor ${processor.name} produced no track`);
|
||||
return processor.processedTrack;
|
||||
};
|
||||
return {
|
||||
name: processor.name,
|
||||
init: async (track, element) => {
|
||||
await processor.init({ kind: Track.Kind.Video, track, element });
|
||||
return processedTrack();
|
||||
init: async (opts) => {
|
||||
adapter.processedTrack = await processor.init(opts.track, opts.element);
|
||||
},
|
||||
restart: async (track, element) => {
|
||||
await processor.restart({ kind: Track.Kind.Video, track, element });
|
||||
return processedTrack();
|
||||
restart: async (opts) => {
|
||||
adapter.processedTrack = await processor.restart(
|
||||
opts.track,
|
||||
opts.element,
|
||||
);
|
||||
},
|
||||
destroy: async () => processor.destroy(),
|
||||
};
|
||||
return adapter;
|
||||
}
|
||||
|
||||
@@ -1,26 +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 { type VideoProcessor } from "../media-api";
|
||||
|
||||
const turns = new WeakMap<VideoProcessor, Promise<unknown>>();
|
||||
|
||||
/**
|
||||
* One operation at a time on a processor. A host shares one across the
|
||||
* tracks it captures and publishes, and a second init while the first still
|
||||
* loads its model, or a destroy while an init runs, leaves it broken: the
|
||||
* backend's own locking is per track, and a track that is stopped tears its
|
||||
* processor down without waiting.
|
||||
*/
|
||||
export async function inTurn<T>(
|
||||
processor: VideoProcessor,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const turn = (turns.get(processor) ?? Promise.resolve()).then(run, run);
|
||||
turns.set(processor, turn);
|
||||
return turn;
|
||||
}
|
||||
+3
-3
@@ -35,7 +35,7 @@ import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||
import { Initializer } from "./initializer";
|
||||
import { type WidgetHelpers } from "./widget";
|
||||
import { useTheme } from "./useTheme";
|
||||
import { BackgroundEffectsProvider } from "./tracks/BackgroundEffectsContext";
|
||||
import { ProcessorProvider } from "./tracks/TrackProcessorContext";
|
||||
import { type AppViewModel } from "./state/AppViewModel";
|
||||
import { MediaDevicesContext } from "./MediaDevicesContext";
|
||||
import {
|
||||
@@ -148,7 +148,7 @@ export const App: FC<Props> = ({ vm, widget }) => {
|
||||
loaded && clientReady ? (
|
||||
<ClientProvider client={widgetClient}>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<BackgroundEffectsProvider effects={vm.backgroundEffects}>
|
||||
<ProcessorProvider>
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} />}
|
||||
>
|
||||
@@ -159,7 +159,7 @@ export const App: FC<Props> = ({ vm, widget }) => {
|
||||
<SentryRoute path="*" element={<RoomPage />} />
|
||||
</Routes>
|
||||
</Sentry.ErrorBoundary>
|
||||
</BackgroundEffectsProvider>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
) : (
|
||||
|
||||
@@ -1,92 +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.
|
||||
*/
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
/* A zero floor, so a long label wraps inside its tile rather than widening
|
||||
the grid past the menu. */
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
gap: var(--cpd-space-3x);
|
||||
padding: var(--cpd-space-2x) var(--cpd-space-4x) 0;
|
||||
}
|
||||
|
||||
/* Within the grid, to outweigh the menu item styles the tiles are built on. */
|
||||
.grid .tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--cpd-space-1x);
|
||||
min-inline-size: 0;
|
||||
padding: var(--cpd-space-3x) var(--cpd-space-2x);
|
||||
/* Not hidden: that makes the tile a scroll container, which focus scrolls
|
||||
into view without its border, and so without the focus ring. */
|
||||
overflow: clip;
|
||||
border: var(--cpd-border-width-2) solid
|
||||
var(--cpd-color-border-interactive-secondary);
|
||||
border-radius: var(--cpd-space-2x);
|
||||
background: none;
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* As the menu items highlight on hover. */
|
||||
.grid .tile[data-highlighted] {
|
||||
background: var(--cpd-color-bg-action-secondary-hovered);
|
||||
}
|
||||
|
||||
.grid .tile[aria-checked="true"] {
|
||||
border-color: var(--cpd-color-border-accent);
|
||||
}
|
||||
|
||||
.grid .tile[data-disabled] {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.tile[data-disabled] .picture {
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.picture {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Positioned, or the picture, positioned itself, would cover it. */
|
||||
.mark {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
color: var(--cpd-color-icon-accent-primary);
|
||||
}
|
||||
|
||||
/* On a picture the mark needs a ground of its own to be seen. */
|
||||
.picture ~ .mark {
|
||||
border-radius: 50%;
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
box-shadow: 0 0 0 var(--cpd-border-width-2) var(--cpd-color-bg-canvas-default);
|
||||
}
|
||||
|
||||
.label {
|
||||
max-inline-size: 100%;
|
||||
font: var(--cpd-font-body-sm-medium);
|
||||
color: var(--cpd-color-text-primary);
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -1,177 +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 { type FC, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { InlineSpinner, MenuItem } from "@vector-im/compound-web";
|
||||
import {
|
||||
BlockIcon,
|
||||
BlurIcon,
|
||||
CheckCircleSolidIcon,
|
||||
PlusIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import styles from "./BackgroundEffectGrid.module.css";
|
||||
|
||||
/**
|
||||
* One choice in the camera menu's Background effects section, as the view
|
||||
* model offers it. The grid names it: pictures are numbered in order.
|
||||
*/
|
||||
export interface BackgroundEffectGridItem {
|
||||
/** Its option id, as `backgroundEffect` gives the one in force. */
|
||||
id: string;
|
||||
kind: "none" | "blur" | "image";
|
||||
/** For kind "image". */
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
effects: BackgroundEffectGridItem[];
|
||||
selected: string | undefined;
|
||||
/** Undefined where effects can't run: all but no effect are disabled. */
|
||||
onSelect: ((id: string) => void) | undefined;
|
||||
/** Shown on the selected tile until its effect is on screen. */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The background effects as a grid of tiles: one radio choice, walked by the
|
||||
* menu's arrow keys like the device rows. Sits in a menu section, which names
|
||||
* it.
|
||||
*/
|
||||
export const BackgroundEffectGrid: FC<Props> = ({
|
||||
effects,
|
||||
selected,
|
||||
onSelect,
|
||||
settling = false,
|
||||
describedBy,
|
||||
onAdd,
|
||||
addLabel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const choose = (id: string): void => {
|
||||
if (id !== selected) onSelect?.(id);
|
||||
};
|
||||
let pictures = 0;
|
||||
const tiles = effects.map((effect) => {
|
||||
const label =
|
||||
effect.kind === "none"
|
||||
? t("background_effects.none")
|
||||
: effect.kind === "blur"
|
||||
? t("background_effects.blur")
|
||||
: t("background_effects.numbered", { n: ++pictures });
|
||||
const checked = effect.id === selected;
|
||||
const disabled = onSelect === undefined && effect.kind !== "none";
|
||||
return (
|
||||
<MenuItem
|
||||
key={effect.id}
|
||||
role="menuitemradio"
|
||||
aria-checked={checked}
|
||||
aria-busy={checked && settling}
|
||||
disabled={disabled}
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
label={null}
|
||||
hideChevron
|
||||
className={styles.tile}
|
||||
// Without a handler it isn't one of the menu's items, so the keyboard
|
||||
// passes over it.
|
||||
onSelect={
|
||||
disabled
|
||||
? null
|
||||
: (e): void => {
|
||||
// Kept open, so the user sees what they chose take effect.
|
||||
e.preventDefault();
|
||||
choose(effect.id);
|
||||
}
|
||||
}
|
||||
>
|
||||
<TileContent
|
||||
effect={effect}
|
||||
label={label}
|
||||
checked={checked}
|
||||
settling={checked && settling}
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
// A command rather than a choice, so an item beside the radios.
|
||||
const addTile =
|
||||
onAdd === undefined ? null : (
|
||||
<MenuItem
|
||||
label={null}
|
||||
hideChevron
|
||||
className={styles.tile}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onAdd();
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className={styles.swatch}>
|
||||
<PlusIcon width={24} height={24} />
|
||||
</span>
|
||||
<VisuallyHidden>{addLabel}</VisuallyHidden>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
function TileContent({
|
||||
effect,
|
||||
label,
|
||||
checked,
|
||||
settling,
|
||||
}: {
|
||||
effect: BackgroundEffectGridItem;
|
||||
/** The tile's accessible name; drawn only where the tile has no picture. */
|
||||
label: string;
|
||||
checked: boolean;
|
||||
settling: boolean;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<span aria-hidden className={styles.swatch}>
|
||||
{effect.kind === "image" && (
|
||||
<img className={styles.picture} src={effect.imageUrl} alt="" />
|
||||
)}
|
||||
{settling ? (
|
||||
<span className={styles.mark}>
|
||||
<InlineSpinner size={20} />
|
||||
</span>
|
||||
) : checked ? (
|
||||
<CheckCircleSolidIcon
|
||||
className={styles.mark}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
) : effect.kind === "none" ? (
|
||||
<BlockIcon width={20} height={20} />
|
||||
) : effect.kind === "blur" ? (
|
||||
<BlurIcon width={20} height={20} />
|
||||
) : null}
|
||||
</span>
|
||||
{effect.kind === "image" ? (
|
||||
<VisuallyHidden>{label}</VisuallyHidden>
|
||||
) : (
|
||||
<span className={styles.label}>{label}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import { MediaDevices } from "../state/MediaDevices";
|
||||
import { globalScope } from "../state/ObservableScope";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
|
||||
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
|
||||
|
||||
// consts for tests
|
||||
const reactionIdentifier = "@user:example.com:DEVICE";
|
||||
@@ -124,21 +123,8 @@ export const Default: Story = {
|
||||
toggleAudio: fn(),
|
||||
toggleVideo: fn(),
|
||||
toggleScreenSharing: fn(),
|
||||
selectBackgroundEffect: fn(),
|
||||
backgroundEffect: "blur",
|
||||
backgroundEffects: [
|
||||
{ id: "none", kind: "none" },
|
||||
{ id: "blur", kind: "blur" },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: `image:${background.id}`,
|
||||
kind: "image" as const,
|
||||
imageUrl: background.imagePath,
|
||||
})),
|
||||
],
|
||||
backgroundEffectNotice: undefined,
|
||||
backgroundEffectSettling: false,
|
||||
backgroundImageRefusal: undefined,
|
||||
addBackgroundImage: fn(),
|
||||
toggleBlur: fn(),
|
||||
videoBlurEnabled: true,
|
||||
hangup: fn(),
|
||||
buttonSize: "lg",
|
||||
showFooter: true,
|
||||
|
||||
@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { type FC, type JSX, type Ref, useMemo } from "react";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import LogoMark from "../icons/LogoMark.svg?react";
|
||||
import LogoType from "../icons/LogoType.svg?react";
|
||||
@@ -32,12 +31,6 @@ import { type ViewModel } from "../state/ViewModel";
|
||||
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;
|
||||
@@ -63,33 +56,20 @@ export interface FooterActions {
|
||||
toggleAudio: (() => void) | undefined;
|
||||
/** Also controls if the videoMute button is disabled */
|
||||
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;
|
||||
toggleBlur: (() => void) | undefined;
|
||||
toggleScreenSharing: (() => void) | undefined;
|
||||
/** Also controls if the settings button is visible */
|
||||
openSettings: (() => void) | undefined;
|
||||
/** Also controls if the hangup button is visible */
|
||||
hangup: (() => void) | undefined;
|
||||
}
|
||||
|
||||
// we do not use any ? optional properties so that the vm type is including all fields.
|
||||
export interface FooterState {
|
||||
audioEnabled: boolean;
|
||||
audioBusy: boolean;
|
||||
videoEnabled: boolean;
|
||||
videoBusy: boolean;
|
||||
/** The background effect in force, as its option id. */
|
||||
backgroundEffect: string;
|
||||
/** Every effect on offer, in the order they are shown. */
|
||||
backgroundEffects: BackgroundEffectGridItem[];
|
||||
/** What the user is told about effects here, if anything. */
|
||||
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;
|
||||
videoBlurEnabled: boolean;
|
||||
showFooter: boolean;
|
||||
|
||||
/* This is needed for WindowMode = "flat" */
|
||||
@@ -137,14 +117,12 @@ export interface FooterProps {
|
||||
children?: JSX.Element | JSX.Element[] | false;
|
||||
vm: ViewModel<FooterSnapshot>;
|
||||
}
|
||||
|
||||
export const CallFooter: FC<FooterProps> = ({
|
||||
className,
|
||||
ref,
|
||||
children,
|
||||
vm,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const asOverlay = useBehavior(vm.asOverlay$);
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const hideControls = useBehavior(vm.hideControls$);
|
||||
@@ -173,27 +151,8 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$);
|
||||
const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$);
|
||||
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
|
||||
const backgroundEffect = useBehavior(vm.backgroundEffect$);
|
||||
const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
|
||||
const backgroundEffectNotice = useBehavior(vm.backgroundEffectNotice$);
|
||||
const backgroundEffectSettling = useBehavior(vm.backgroundEffectSettling$);
|
||||
const addBackgroundImage = useBehavior(vm.addBackgroundImage$);
|
||||
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 toggleBlur = useBehavior(vm.toggleBlur$);
|
||||
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
|
||||
const buttonSize = useBehavior(vm.buttonSize$);
|
||||
const showLogo = useBehavior(vm.showLogo$);
|
||||
|
||||
@@ -255,19 +214,8 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
options={videoOptions}
|
||||
selectedOption={selectedVideo}
|
||||
onSelect={selectVideoButtonOption}
|
||||
backgroundEffects={backgroundEffects}
|
||||
selectedBackgroundEffect={backgroundEffect}
|
||||
onSelectBackgroundEffect={selectBackgroundEffect}
|
||||
backgroundEffectSettling={backgroundEffectSettling}
|
||||
onAddBackgroundImage={addBackgroundImage}
|
||||
backgroundImageRefusal={refusalMessage}
|
||||
backgroundEffectNotice={
|
||||
backgroundEffectNotice === "unavailable"
|
||||
? t("background_effects.unavailable")
|
||||
: backgroundEffectNotice === "slow"
|
||||
? t("background_effects.slow")
|
||||
: undefined
|
||||
}
|
||||
videoBlurToggleClick={toggleBlur}
|
||||
videoBlurEnabled={videoBlurEnabled}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -5,34 +5,17 @@ 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, it, vi } from "vitest";
|
||||
import { 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";
|
||||
import type { Alignment, Layout } from "../state/layout-types";
|
||||
import type { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
import type { DeviceLabel } from "../state/MediaDevices";
|
||||
import {
|
||||
createCallFooterViewModel,
|
||||
createLobbyFooterViewModel,
|
||||
} from "./CallFooterViewModel";
|
||||
import { createCallFooterViewModel } from "./CallFooterViewModel";
|
||||
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", () => ({
|
||||
@@ -41,28 +24,12 @@ vi.mock("../Platform", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// The SDK's own check needs WebGL and canvas APIs jsdom does not have.
|
||||
const sdkSupportMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const modernRouteMock = vi.hoisted(() => vi.fn(() => true));
|
||||
// Prevent supportsBackgroundProcessors from throwing in jsdom – it is not
|
||||
// exercised by these tests (only used in `videoToggles`, not `videoOptions`).
|
||||
vi.mock("@livekit/track-processors", () => ({
|
||||
supportsBackgroundProcessors: (): boolean => sdkSupportMock(),
|
||||
supportsModernBackgroundProcessors: (): boolean => modernRouteMock(),
|
||||
supportsBackgroundProcessors: (): boolean => false,
|
||||
}));
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
add: vi.fn<(file: Blob) => Promise<string>>(),
|
||||
}));
|
||||
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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("livekit-client", () => ({
|
||||
supportsAudioOutputSelection: (): boolean => outputSelectionMock(),
|
||||
@@ -98,6 +65,10 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel {
|
||||
const gridLayout: Layout = {
|
||||
type: "grid",
|
||||
grid: [],
|
||||
spotlightAlignment$: new BehaviorSubject<Alignment>({
|
||||
inline: "end",
|
||||
block: "end",
|
||||
}),
|
||||
setVisibleTiles: (_: number) => {},
|
||||
};
|
||||
|
||||
@@ -138,7 +109,6 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -181,7 +151,6 @@ describe("createCallFooterViewModel", () => {
|
||||
select: vi.fn(),
|
||||
},
|
||||
}),
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -200,7 +169,6 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(layout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -223,7 +191,6 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -255,179 +222,4 @@ describe("createCallFooterViewModel", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("background effects", () => {
|
||||
afterEach(() => backgroundEffectSetting.setValue("none"));
|
||||
|
||||
// The lobby, because in a call the whole device switcher is already
|
||||
// withheld on a phone, whatever the verdict says.
|
||||
function lobbyOn(
|
||||
platform: string,
|
||||
): ReturnType<typeof createLobbyFooterViewModel> {
|
||||
platformMock.mockReturnValue(platform);
|
||||
return createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
}
|
||||
|
||||
it("offers them on a phone whose browser can run them", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
expect(lobbyOn("ios").selectBackgroundEffect$.value).toBeDefined();
|
||||
});
|
||||
|
||||
it("offers them on a desktop whose browser can run them", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
expect(lobbyOn("desktop").selectBackgroundEffect$.value).toBeDefined();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
backgroundEffectSetting.setValue("blur");
|
||||
sdkSupportMock.mockReturnValue(false);
|
||||
expect(lobbyOn("desktop").backgroundEffect$.value).toBe("none");
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
expect(lobbyOn("desktop").backgroundEffect$.value).toBe("blur");
|
||||
});
|
||||
|
||||
it("stores the effect chosen", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
const vm = lobbyOn("desktop");
|
||||
vm.selectBackgroundEffect$.value?.("image:arc");
|
||||
expect(vm.backgroundEffect$.value).toBe("image:arc");
|
||||
// What isn't an effect on offer is stored as none.
|
||||
vm.selectBackgroundEffect$.value?.("image:gone");
|
||||
expect(backgroundEffectSetting.getValue()).toBe("none");
|
||||
});
|
||||
|
||||
it("says they run slowly where only the slower route exists", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
modernRouteMock.mockReturnValue(false);
|
||||
const slow = lobbyOn("desktop");
|
||||
expect(slow.backgroundEffectNotice$.value).toBe("slow");
|
||||
expect(slow.selectBackgroundEffect$.value).toBeDefined();
|
||||
modernRouteMock.mockReturnValue(true);
|
||||
expect(lobbyOn("desktop").backgroundEffectNotice$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
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" },
|
||||
{ id: "blur", kind: "blur" },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: `image:${background.id}`,
|
||||
kind: "image",
|
||||
imageUrl: background.imagePath,
|
||||
})),
|
||||
{ id: "added:mine", kind: "image", imageUrl: "blob:mine" },
|
||||
]);
|
||||
added$.next([]);
|
||||
});
|
||||
|
||||
it("says they are unavailable where they cannot be chosen", () => {
|
||||
sdkSupportMock.mockReturnValue(false);
|
||||
expect(lobbyOn("desktop").backgroundEffectNotice$.value).toBe(
|
||||
"unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes on the wait for the first effect", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
const settling$ = new BehaviorSubject(true);
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
settling$,
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
expect(vm.backgroundEffectSettling$.value).toBe(true);
|
||||
settling$.next(false);
|
||||
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("availability is the same before and during a call", () => {
|
||||
for (const supported of [true, false]) {
|
||||
sdkSupportMock.mockReturnValue(supported);
|
||||
const lobby = lobbyOn("desktop");
|
||||
const inCall = createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
const offeredInLobby =
|
||||
lobby.selectBackgroundEffect$.value !== undefined;
|
||||
expect(inCall.selectBackgroundEffect$.value !== undefined).toBe(
|
||||
offeredInLobby,
|
||||
);
|
||||
expect(offeredInLobby).toBe(supported);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,38 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
type Observable,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { combineLatest, map, type Observable, switchMap } from "rxjs";
|
||||
import { supportsBackgroundProcessors } from "@livekit/track-processors";
|
||||
import { supportsAudioOutputSelection } from "livekit-client";
|
||||
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";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import {
|
||||
backgroundEffect as backgroundEffectSetting,
|
||||
backgroundBlur as backgroundBlurSettings,
|
||||
debugTileLayout as debugTileLayoutSetting,
|
||||
} from "../settings/settings";
|
||||
import { type Behavior, constant } from "../state/Behavior";
|
||||
@@ -45,8 +22,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 BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
import { type FooterSnapshot } from "./CallFooter";
|
||||
|
||||
/**
|
||||
* Shared helper: maps MuteStates into the audio/video enabled + toggle behaviors
|
||||
@@ -80,14 +56,13 @@ function buildMuteBehaviors(
|
||||
|
||||
/**
|
||||
* Shared helper: maps MediaDevices into the audio/video device-list behaviors
|
||||
* needed by FooterSnapshot (options, selection, callbacks, background effect).
|
||||
* needed by FooterSnapshot (options, selection, callbacks, blur toggle).
|
||||
*/
|
||||
function buildDeviceBehaviors(
|
||||
scope: ObservableScope,
|
||||
mediaDevices: MediaDevices,
|
||||
/** return empty arrays for audioOptions and videoOptions*/
|
||||
disableSwitcher$: Behavior<boolean>,
|
||||
backgroundEffectSettling$: Observable<boolean>,
|
||||
): Pick<
|
||||
ViewModel<FooterSnapshot>,
|
||||
| "audioOptions$"
|
||||
@@ -99,13 +74,8 @@ function buildDeviceBehaviors(
|
||||
| "videoOptions$"
|
||||
| "selectedVideo$"
|
||||
| "selectVideoButtonOption$"
|
||||
| "backgroundEffect$"
|
||||
| "selectBackgroundEffect$"
|
||||
| "backgroundEffects$"
|
||||
| "backgroundEffectNotice$"
|
||||
| "backgroundEffectSettling$"
|
||||
| "addBackgroundImage$"
|
||||
| "backgroundImageRefusal$"
|
||||
| "toggleBlur$"
|
||||
| "videoBlurEnabled$"
|
||||
> {
|
||||
const options$ = (
|
||||
available$: Behavior<Map<string, MenuOptions["label"]>>,
|
||||
@@ -122,15 +92,6 @@ function buildDeviceBehaviors(
|
||||
),
|
||||
);
|
||||
|
||||
const supported = supportsBackgroundProcessors();
|
||||
const slow = !supportsModernBackgroundProcessors();
|
||||
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(
|
||||
@@ -155,58 +116,18 @@ function buildDeviceBehaviors(
|
||||
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
|
||||
backgroundEffect$: scope.behavior(
|
||||
backgroundEffectSetting.value$.pipe(
|
||||
map((raw) => (supported ? serializeEffect(parseEffect(raw)) : "none")),
|
||||
),
|
||||
),
|
||||
selectBackgroundEffect$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
offered
|
||||
? (id: string): void =>
|
||||
backgroundEffectSetting.setValue(
|
||||
serializeEffect(parseEffect(id)),
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundEffects$: scope.behavior(
|
||||
addedBackgrounds.added$.pipe(
|
||||
map((added) => backgroundEffectChoices(added ?? [])),
|
||||
),
|
||||
),
|
||||
backgroundEffectNotice$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
!offered ? "unavailable" : slow ? "slow" : undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundEffectSettling$: scope.behavior(
|
||||
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",
|
||||
});
|
||||
});
|
||||
toggleBlur$: scope.behavior(
|
||||
combineLatest([backgroundBlurSettings.value$, disableSwitcher$]).pipe(
|
||||
map(([current, switcherDisabled]) => {
|
||||
return !switcherDisabled && supportsBackgroundProcessors()
|
||||
? (): void => {
|
||||
backgroundBlurSettings.setValue(!current);
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
: undefined;
|
||||
}),
|
||||
),
|
||||
),
|
||||
backgroundImageRefusal$: refusal$,
|
||||
videoBlurEnabled$: backgroundBlurSettings.value$,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -217,8 +138,6 @@ function buildDeviceBehaviors(
|
||||
* @param callModel - The root CallViewModel; provides layout, grid mode, reactions, etc.
|
||||
* @param muteStates - Audio and video mute state + toggles.
|
||||
* @param mediaDevices - Available and selected input devices.
|
||||
* @param backgroundEffectSettling$ - Whether the first effect chosen is still
|
||||
* being prepared.
|
||||
* @param reactionIdentifier - The local user's reaction identifier string, or
|
||||
* undefined when reactions are not supported (hides the reaction button).
|
||||
* @param options - `showControls`: whether the call controls should be shown.
|
||||
@@ -229,7 +148,6 @@ export function createCallFooterViewModel(
|
||||
callModel: CallViewModel,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
backgroundEffectSettling$: Observable<boolean>,
|
||||
reactionIdentifier: string | undefined,
|
||||
options: { showControls: boolean; header: HeaderStyle },
|
||||
): ViewModel<FooterSnapshot> {
|
||||
@@ -244,12 +162,7 @@ export function createCallFooterViewModel(
|
||||
);
|
||||
return {
|
||||
...buildMuteBehaviors(scope, muteStates),
|
||||
...buildDeviceBehaviors(
|
||||
scope,
|
||||
mediaDevices,
|
||||
disableDeviceSwitcher$,
|
||||
backgroundEffectSettling$,
|
||||
),
|
||||
...buildDeviceBehaviors(scope, mediaDevices, disableDeviceSwitcher$),
|
||||
// candidat to move into the FooterViewModel
|
||||
showFooter$: callModel.showFooter$,
|
||||
hideControls$: constant(!showControls),
|
||||
@@ -311,8 +224,6 @@ export function createCallFooterViewModel(
|
||||
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
|
||||
* @param muteStates - Audio and video mute state + toggles.
|
||||
* @param mediaDevices - Available and selected input devices.
|
||||
* @param backgroundEffectSettling$ - Whether the first effect chosen is still
|
||||
* being prepared.
|
||||
* @param openSettings - Callback to open the settings modal, or undefined.
|
||||
* @param hangup - Callback to leave/cancel, or undefined (hides the button).
|
||||
* @param showLogo - Whether to show the Element Call logo.
|
||||
@@ -321,7 +232,6 @@ export function createLobbyFooterViewModel(
|
||||
scope: ObservableScope,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
backgroundEffectSettling$: Observable<boolean>,
|
||||
openSettings: (() => void) | undefined,
|
||||
hangup: (() => void) | undefined,
|
||||
showLogo: boolean,
|
||||
@@ -365,30 +275,6 @@ export function createLobbyFooterViewModel(
|
||||
selectVideoButtonOption: undefined,
|
||||
}),
|
||||
...buildMuteBehaviors(scope, muteStates),
|
||||
...buildDeviceBehaviors(
|
||||
scope,
|
||||
mediaDevices,
|
||||
constant(false),
|
||||
backgroundEffectSettling$,
|
||||
),
|
||||
...buildDeviceBehaviors(scope, mediaDevices, constant(false)),
|
||||
};
|
||||
}
|
||||
|
||||
function backgroundEffectChoices(
|
||||
added: AddedBackground[],
|
||||
): BackgroundEffectGridItem[] {
|
||||
return [
|
||||
{ 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,
|
||||
})),
|
||||
...added.map((background) => ({
|
||||
id: serializeEffect({ kind: "added", id: background.id }),
|
||||
kind: "image" as const,
|
||||
imageUrl: background.url,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -36,17 +36,20 @@ Please see LICENSE in the repository root for full details.
|
||||
}
|
||||
}
|
||||
|
||||
/* The width design sets: a long device name wraps. */
|
||||
.menu {
|
||||
inline-size: 296px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Its share of the measured call area: the menu is portalled out of the root,
|
||||
so CSS can't size it against the call. */
|
||||
/* Only the device lists scroll, bounded by the measured call area: the menu is
|
||||
portalled out of the root, so CSS can't size it against the call. */
|
||||
.deviceList {
|
||||
overflow-y: auto;
|
||||
min-block-size: 0;
|
||||
max-block-size: var(--device-list-max-height);
|
||||
/* The raised headings stay raised within the list, under the menu's frame. */
|
||||
isolation: isolate;
|
||||
/* Keeps a row reached by keyboard clear of the sticky heading and meter. */
|
||||
scroll-padding-block: var(--device-list-scroll-padding-start, 0)
|
||||
var(--device-list-scroll-padding-end, 0);
|
||||
}
|
||||
|
||||
/* Compound's heading rule heads each section; the design adds space below it. */
|
||||
@@ -59,10 +62,23 @@ Please see LICENSE in the repository root for full details.
|
||||
margin-block-start: var(--cpd-space-7x);
|
||||
}
|
||||
|
||||
/* Raised, or the positioned effect tiles scroll over the heading they sit
|
||||
under. Compound's sticky heading is the section's aria-hidden first child. */
|
||||
.deviceList [role="group"] > [aria-hidden="true"]:first-child {
|
||||
z-index: 1;
|
||||
/* Sticky within its own section. Opaque and inset a border width so it doesn't
|
||||
paint over the menu's outline. */
|
||||
.sectionHeading {
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
margin-inline: var(--cpd-border-width-1);
|
||||
margin-block-start: var(--cpd-border-width-1);
|
||||
}
|
||||
|
||||
/* Sticky at the foot of the microphone section; opaque and inset as above. */
|
||||
.stickyMeter {
|
||||
position: sticky;
|
||||
inset-block-end: 0;
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
margin-inline: var(--cpd-border-width-1);
|
||||
margin-block-end: var(--cpd-border-width-1);
|
||||
}
|
||||
|
||||
/* Radix focuses whatever the pointer is over, so the browser's ring would
|
||||
@@ -76,24 +92,3 @@ Please see LICENSE in the repository root for full details.
|
||||
outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused);
|
||||
outline-offset: calc(-1 * var(--cpd-border-width-2));
|
||||
}
|
||||
|
||||
.notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--cpd-space-3x);
|
||||
margin: var(--cpd-space-2x) var(--cpd-space-4x) 0;
|
||||
padding: var(--cpd-space-3x);
|
||||
border-radius: var(--cpd-space-2x);
|
||||
background: var(--cpd-color-bg-subtle-secondary);
|
||||
color: var(--cpd-color-text-primary);
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
}
|
||||
|
||||
.notice > svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
}
|
||||
|
||||
.refusal {
|
||||
margin: var(--cpd-space-2x) var(--cpd-space-4x) 0;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ import { useEffect, useState, type FC, type JSX, type ReactNode } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||
import styles from "./MediaMuteAndSwitchButton.module.css";
|
||||
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
|
||||
import meterStyles from "./MicrophoneLevelMeter.module.css";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { RootElementProvider } from "../RootElementContext";
|
||||
@@ -61,19 +59,14 @@ const WithAMicrophone: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
};
|
||||
|
||||
/** Supplies a call-sized root. Without one the list is bounded by the whole Storybook frame. */
|
||||
const WithACallArea: FC<{ children: ReactNode; blockSize?: number }> = ({
|
||||
children,
|
||||
// A call's size: anything smaller makes a short device list scroll, and a
|
||||
// narrower one narrows the menu.
|
||||
blockSize = 720,
|
||||
}) => {
|
||||
const WithACallArea: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [callArea, setCallArea] = useState<HTMLElement | null>(null);
|
||||
return (
|
||||
<div
|
||||
ref={setCallArea}
|
||||
style={{
|
||||
blockSize,
|
||||
inlineSize: 1024,
|
||||
// A call's size: anything smaller makes a short device list scroll.
|
||||
blockSize: 720,
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "center",
|
||||
@@ -104,10 +97,6 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// By place: Compound's section heading is aria-hidden, as its group carries
|
||||
// the name.
|
||||
const SECTION_HEADING = "[role='group'] > [aria-hidden]";
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
iconsAndLabels: "audio",
|
||||
@@ -138,6 +127,8 @@ export const AudioMute: Story = {
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
||||
],
|
||||
videoBlurEnabled: true,
|
||||
videoBlurToggleClick: fn(),
|
||||
selectedOption: "2",
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
@@ -176,16 +167,6 @@ export const VideoMute: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
const effects: BackgroundEffectGridItem[] = [
|
||||
{ id: "none", kind: "none" },
|
||||
{ id: "blur", kind: "blur" },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: `image:${background.id}`,
|
||||
kind: "image" as const,
|
||||
imageUrl: background.imagePath,
|
||||
})),
|
||||
];
|
||||
|
||||
export const VideoUnmute: Story = {
|
||||
args: {
|
||||
iconsAndLabels: "video",
|
||||
@@ -194,256 +175,9 @@ export const VideoUnmute: Story = {
|
||||
{ label: { type: "name", name: "Camera 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Camera 2" }, id: "2" },
|
||||
],
|
||||
videoBlurEnabled: true,
|
||||
videoBlurToggleClick: fn(),
|
||||
selectedOption: "2",
|
||||
backgroundEffects: effects,
|
||||
selectedBackgroundEffect: "none",
|
||||
onSelectBackgroundEffect: fn(),
|
||||
onAddBackgroundImage: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
/** One choice among no effect, blur and the shipped images, under the cameras. */
|
||||
export const BackgroundEffects: Story = {
|
||||
args: VideoUnmute.args,
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const menu = within(document.body);
|
||||
const section = await menu.findByRole("group", {
|
||||
name: "Background effects",
|
||||
});
|
||||
const tiles = within(section).getAllByRole("menuitemradio");
|
||||
await expect(tiles.map((tile) => tile.textContent)).toEqual([
|
||||
"None",
|
||||
"Blur",
|
||||
"Background 1",
|
||||
"Background 2",
|
||||
]);
|
||||
await expect(
|
||||
within(section).getByRole("menuitemradio", { checked: true }),
|
||||
).toHaveTextContent("None");
|
||||
|
||||
const cameras = menu.getByRole("group", { name: "Camera" });
|
||||
await expect(
|
||||
cameras.compareDocumentPosition(section) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
const [first, second, third, fourth] = tiles.map((tile) =>
|
||||
tile.getBoundingClientRect(),
|
||||
);
|
||||
await expect(second.top).toBe(first.top);
|
||||
await expect(third.top).toBe(first.top);
|
||||
await expect(fourth.top).toBeGreaterThan(first.bottom);
|
||||
const frame = document.body
|
||||
.querySelector("[role='menu']")!
|
||||
.getBoundingClientRect();
|
||||
for (const tile of [first, second, third])
|
||||
await expect(tile.left >= frame.left && tile.right <= frame.right).toBe(
|
||||
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();
|
||||
},
|
||||
};
|
||||
|
||||
/** Where effects can't run: shown, disabled and explained, except no effect. */
|
||||
export const BackgroundEffectsUnavailable: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
onSelectBackgroundEffect: undefined,
|
||||
backgroundEffectNotice:
|
||||
"Background effects are not supported on this platform.",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const menu = within(document.body);
|
||||
const section = await menu.findByRole("group", {
|
||||
name: "Background effects",
|
||||
});
|
||||
// On the tiles' own group: the section's can't carry a description.
|
||||
await expect(
|
||||
within(section).getByRole("group"),
|
||||
).toHaveAccessibleDescription(
|
||||
"Background effects are not supported on this platform.",
|
||||
);
|
||||
for (const tile of within(section).getAllByRole("menuitemradio"))
|
||||
if (tile.textContent === "None") await expect(tile).toBeEnabled();
|
||||
else await expect(tile).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
/** Where only the slower route exists: offered and choosable, with the cost said. */
|
||||
export const BackgroundEffectsSlowInThisBrowser: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
backgroundEffectNotice:
|
||||
"Background effects run slowly on this platform, which may cause your video to stutter.",
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const menu = within(document.body);
|
||||
const section = await menu.findByRole("group", {
|
||||
name: "Background effects",
|
||||
});
|
||||
// On the tiles' own group: the section's can't carry a description.
|
||||
await expect(
|
||||
within(section).getByRole("group"),
|
||||
).toHaveAccessibleDescription(
|
||||
"Background effects run slowly on this platform, which may cause your video to stutter.",
|
||||
);
|
||||
for (const tile of within(section).getAllByRole("menuitemradio"))
|
||||
await expect(tile).not.toHaveAttribute("aria-disabled");
|
||||
await userEvent.click(
|
||||
within(section).getByRole("menuitemradio", { name: "Background 1" }),
|
||||
);
|
||||
await expect(args.onSelectBackgroundEffect).toHaveBeenCalledWith(
|
||||
"image:arc",
|
||||
);
|
||||
|
||||
const notice = document.body.querySelector(`.${styles.notice}`)!;
|
||||
const frame = document.body
|
||||
.querySelector("[role='menu']")!
|
||||
.getBoundingClientRect();
|
||||
await expect(frame.width).toBeLessThanOrEqual(296);
|
||||
await expect(notice.getBoundingClientRect().right).toBeLessThanOrEqual(
|
||||
frame.right,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The first effect of a session shows its wait on its own tile. */
|
||||
export const BackgroundEffectsSettling: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
selectedBackgroundEffect: "blur",
|
||||
backgroundEffectSettling: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const menu = within(document.body);
|
||||
const chosen = await menu.findByRole("menuitemradio", {
|
||||
name: "Blur",
|
||||
checked: true,
|
||||
});
|
||||
await expect(chosen).toHaveAttribute("aria-busy", "true");
|
||||
const marks = chosen.querySelectorAll("svg");
|
||||
await expect(marks).toHaveLength(1);
|
||||
await expect(getComputedStyle(marks[0]).animationName).not.toBe("none");
|
||||
const section = menu.getByRole("group", { name: "Background effects" });
|
||||
for (const other of within(section).getAllByRole("menuitemradio", {
|
||||
checked: false,
|
||||
}))
|
||||
await expect(other).toHaveAttribute("aria-busy", "false");
|
||||
},
|
||||
};
|
||||
|
||||
/** 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");
|
||||
},
|
||||
};
|
||||
|
||||
/** In a short call the effects scroll into view with the list. */
|
||||
export const BackgroundEffectsScrollWhenTheyDoNotFit: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
// It sits with the effects, so it must fit the same share of the call.
|
||||
backgroundEffectNotice:
|
||||
"Background effects run slowly on this platform, which may cause your video to stutter.",
|
||||
},
|
||||
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 last = await menu.findByRole("menuitemradio", {
|
||||
name: "Background 2",
|
||||
});
|
||||
const list = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
|
||||
await expect(last.getBoundingClientRect().top).toBeGreaterThan(
|
||||
list.getBoundingClientRect().bottom,
|
||||
);
|
||||
|
||||
// Scrolled under the section's heading, the tiles pass beneath it. By
|
||||
// position: Compound's heading is the section's hidden first child.
|
||||
const heading = menu.getByRole("group", { name: "Background effects" })
|
||||
.firstElementChild as HTMLElement;
|
||||
list.scrollTop = heading.offsetTop + 30;
|
||||
await waitFor(async () => {
|
||||
const box = heading.getBoundingClientRect();
|
||||
const drawn = document.elementFromPoint(
|
||||
box.left + box.width / 2,
|
||||
box.bottom - 2,
|
||||
);
|
||||
await expect(heading.contains(drawn)).toBe(true);
|
||||
});
|
||||
list.scrollTop = 0;
|
||||
|
||||
for (let i = 0; i < 10 && document.activeElement !== last; i++)
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
await expect(document.activeElement).toBe(last);
|
||||
const scrollport = list.getBoundingClientRect();
|
||||
const reached = last.getBoundingClientRect();
|
||||
await expect(reached.bottom).toBeLessThanOrEqual(scrollport.bottom + 1);
|
||||
await expect(reached.top).toBeGreaterThanOrEqual(scrollport.top - 1);
|
||||
|
||||
const callArea = canvasElement
|
||||
.querySelector<HTMLElement>("[style*='block-size: 300px']")!
|
||||
.getBoundingClientRect();
|
||||
const frame = document.body
|
||||
.querySelector("[role='menu']")!
|
||||
.getBoundingClientRect();
|
||||
await expect(frame.top).toBeGreaterThanOrEqual(callArea.top);
|
||||
await expect(frame.bottom).toBeLessThanOrEqual(callArea.bottom);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -485,8 +219,9 @@ export const SpeakerAndMicrophoneSections: Story = {
|
||||
await expect(
|
||||
document.body.querySelectorAll("[role='separator']"),
|
||||
).toHaveLength(0);
|
||||
const headings =
|
||||
document.body.querySelectorAll<HTMLElement>(SECTION_HEADING);
|
||||
const headings = document.body.querySelectorAll<HTMLElement>(
|
||||
`.${styles.sectionHeading}`,
|
||||
);
|
||||
await expect(headings).toHaveLength(2);
|
||||
const frame = menu.getBoundingClientRect();
|
||||
for (const heading of headings) {
|
||||
@@ -521,37 +256,6 @@ export const SpeakerAndMicrophoneSections: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** A long name wraps rather than widening the menu, which design sets. */
|
||||
export const LongDeviceNameWraps: Story = {
|
||||
args: {
|
||||
...SpeakerAndMicrophoneSections.args,
|
||||
outputOptions: [
|
||||
{
|
||||
label: {
|
||||
type: "name",
|
||||
name: "Logitech BRIO 4K Ultra HD Pro Business Webcam Speakers (046d:085e)",
|
||||
},
|
||||
id: "long",
|
||||
},
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
],
|
||||
selectedOutputOption: "long",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
const body = within(document.body);
|
||||
const long = await body.findByRole("menuitemradio", { name: /Logitech/ });
|
||||
const short = await body.findByRole("menuitemradio", { name: "Headset" });
|
||||
|
||||
const menu = document.body.querySelector("[role='menu']")!;
|
||||
await expect(Math.round(menu.getBoundingClientRect().width)).toBe(296);
|
||||
await expect(long.getBoundingClientRect().height).toBeGreaterThan(
|
||||
short.getBoundingClientRect().height,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OutputCannotBeChosen: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
@@ -698,14 +402,14 @@ export const ManyDevices: Story = {
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
const menu = within(document.body);
|
||||
|
||||
// The meter's box rather than its level: without a fake microphone (as on
|
||||
// The sticky wrapper rather than the meter: without a fake microphone (as on
|
||||
// WebKit) the meter shows a message instead of a level.
|
||||
const list = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
const sticky = await waitFor(() => {
|
||||
const element = document.body.querySelector<HTMLElement>(
|
||||
`.${meterStyles.meter}`,
|
||||
`.${styles.stickyMeter}`,
|
||||
);
|
||||
if (element === null) throw new Error("the meter has not rendered yet");
|
||||
return element;
|
||||
@@ -732,6 +436,10 @@ export const ManyDevices: Story = {
|
||||
.getBoundingClientRect();
|
||||
await expect(frame.top).toBeGreaterThanOrEqual(0);
|
||||
await expect(frame.bottom).toBeLessThanOrEqual(window.innerHeight + 1);
|
||||
|
||||
// The meter is the menu's one opaque part, so it must stay inside the frame.
|
||||
await expect(pinned.left).toBeGreaterThan(frame.left);
|
||||
await expect(pinned.right).toBeLessThan(frame.right);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -828,7 +536,7 @@ export const KeyboardReachesEveryDevice: Story = {
|
||||
|
||||
const meter = await waitFor(() => {
|
||||
const element = document.body.querySelector<HTMLElement>(
|
||||
`.${meterStyles.meter}`,
|
||||
`.${styles.stickyMeter}`,
|
||||
);
|
||||
if (element === null) throw new Error("the meter has not rendered yet");
|
||||
return element;
|
||||
@@ -837,7 +545,9 @@ export const KeyboardReachesEveryDevice: Story = {
|
||||
// Drawn over the list: the sticky headings and the meter.
|
||||
const overlays = [
|
||||
meter,
|
||||
...document.body.querySelectorAll<HTMLElement>(SECTION_HEADING),
|
||||
...document.body.querySelectorAll<HTMLElement>(
|
||||
`.${styles.sectionHeading}`,
|
||||
),
|
||||
];
|
||||
const items = within(document.body).getAllByRole("menuitemradio");
|
||||
|
||||
@@ -881,7 +591,10 @@ export const HeadingsStayWhileScrolling: Story = {
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
const group = await menu.findByRole("group", { name: "Microphone" });
|
||||
const heading = group.querySelector<HTMLElement>(":scope > [aria-hidden]")!;
|
||||
// By class: the heading is aria-hidden, and its group carries the name.
|
||||
const heading = group.querySelector<HTMLElement>(
|
||||
`.${styles.sectionHeading}`,
|
||||
)!;
|
||||
|
||||
// Far enough that the heading is only on screen if it is stuck there.
|
||||
list.scrollTop +=
|
||||
@@ -894,6 +607,13 @@ export const HeadingsStayWhileScrolling: Story = {
|
||||
await expect(heading.getBoundingClientRect().bottom).toBeGreaterThan(
|
||||
scrollport.top,
|
||||
);
|
||||
// Clear of the menu's frame.
|
||||
const frame = document.body
|
||||
.querySelector("[role='menu']")!
|
||||
.getBoundingClientRect();
|
||||
await expect(heading.getBoundingClientRect().left).toBeGreaterThan(
|
||||
frame.left,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -908,23 +628,31 @@ function overlapping(element: Element, overlays: Element[]): number {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** The effect tiles get the keyboard ring the device rows get. */
|
||||
export const FocusRingCoversTheEffectTiles: Story = {
|
||||
args: VideoUnmute.args,
|
||||
/** The blur toggle gets the keyboard ring too: it is the menu's child, not the list's. */
|
||||
export const FocusRingCoversTheBlurToggle: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
iconsAndLabels: "video",
|
||||
videoBlurEnabled: false,
|
||||
videoBlurToggleClick: fn(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const tile = await within(document.body).findByRole("menuitemradio", {
|
||||
name: "Blur",
|
||||
const toggle = await within(document.body).findByRole("menuitemcheckbox", {
|
||||
name: /Blur background/,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 6 && document.activeElement !== tile; i++)
|
||||
// Arrow down past the cameras to the toggle.
|
||||
for (let i = 0; i < 6 && document.activeElement !== toggle; i++)
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
await expect(document.activeElement).toBe(tile);
|
||||
await expect(outlineWidth(tile)).toBeGreaterThan(0);
|
||||
await expect(document.activeElement).toBe(toggle);
|
||||
// The same ring the device rows get.
|
||||
await expect(outlineWidth(toggle)).toBeGreaterThan(0);
|
||||
|
||||
await userEvent.hover(tile);
|
||||
await expect(document.activeElement).toBe(tile);
|
||||
await expect(outlineWidth(tile)).toBe(0);
|
||||
// And the pointer takes it away, with the toggle still focused.
|
||||
await userEvent.hover(toggle);
|
||||
await expect(document.activeElement).toBe(toggle);
|
||||
await expect(outlineWidth(toggle)).toBe(0);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,11 +5,10 @@ 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,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
@@ -23,18 +22,10 @@ import {
|
||||
MediaMuteAndSwitchButton,
|
||||
type MenuOptions,
|
||||
} from "./MediaMuteAndSwitchButton";
|
||||
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
|
||||
|
||||
const effects: BackgroundEffectGridItem[] = [
|
||||
{ id: "none", kind: "none" },
|
||||
{ id: "blur", kind: "blur" },
|
||||
{ id: "image:arc", kind: "image", imageUrl: "" },
|
||||
{ id: "image:glow", kind: "image", imageUrl: "" },
|
||||
];
|
||||
|
||||
interface RenderOptions {
|
||||
requestDeviceNames: () => void;
|
||||
}
|
||||
@@ -314,202 +305,30 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
expect(mic2ItemAfter.querySelector(".rotate")).toBeNull();
|
||||
});
|
||||
|
||||
test("background effects are one keyboard-operable labelled choice", async () => {
|
||||
test("renders menu with toggle control and calls toggle callback", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelectBackgroundEffect = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
const onVideoBlurToggle = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="video"
|
||||
enabled={true}
|
||||
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
|
||||
selectedOption="cam1"
|
||||
onSelect={vi.fn()}
|
||||
backgroundEffects={effects}
|
||||
selectedBackgroundEffect="none"
|
||||
onSelectBackgroundEffect={onSelectBackgroundEffect}
|
||||
videoBlurToggleClick={onVideoBlurToggle}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Camera" }));
|
||||
const section = screen.getByRole("group", { name: "Background effects" });
|
||||
within(section).getByRole("menuitemradio", { name: "None", checked: true });
|
||||
within(section).getByRole("menuitemradio", {
|
||||
name: "Background 1",
|
||||
checked: false,
|
||||
|
||||
const toggle = screen.getByRole("menuitemcheckbox", {
|
||||
name: "Blur background",
|
||||
});
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
const blur = within(section).getByRole("menuitemradio", { name: "Blur" });
|
||||
for (let i = 0; i < 6 && document.activeElement !== blur; i++)
|
||||
await user.keyboard("{ArrowDown}");
|
||||
expect(document.activeElement).toBe(blur);
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onSelectBackgroundEffect).toHaveBeenCalledWith("blur");
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
});
|
||||
await user.click(toggle);
|
||||
|
||||
test("does not choose the effect already in force again", 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}
|
||||
selectedBackgroundEffect="blur"
|
||||
onSelectBackgroundEffect={onSelectBackgroundEffect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Camera" }));
|
||||
await user.click(screen.getByRole("menuitemradio", { name: "Blur" }));
|
||||
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")
|
||||
.mockReturnValue("Mozilla/5.0 (Linux; Android 14)");
|
||||
try {
|
||||
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}
|
||||
selectedBackgroundEffect="none"
|
||||
onSelectBackgroundEffect={onSelectBackgroundEffect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Camera" }));
|
||||
const section = screen.getByRole("group", { name: "Background effects" });
|
||||
// A click alone: the drawer's drag handling reads layout jsdom lacks.
|
||||
fireEvent.click(
|
||||
within(section).getByRole("menuitemradio", { name: "Background 2" }),
|
||||
);
|
||||
expect(onSelectBackgroundEffect).toHaveBeenCalledWith("image:glow");
|
||||
} finally {
|
||||
userAgent.mockRestore();
|
||||
}
|
||||
expect(onVideoBlurToggle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("marks the selected menu item as checked", async () => {
|
||||
@@ -733,9 +552,7 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
]}
|
||||
selectedOption="cam1"
|
||||
onSelect={vi.fn()}
|
||||
backgroundEffects={effects}
|
||||
selectedBackgroundEffect="none"
|
||||
onSelectBackgroundEffect={vi.fn()}
|
||||
videoBlurToggleClick={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -743,10 +560,9 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
|
||||
screen.getByRole("menuitemradio", { name: "Camera 1", checked: true });
|
||||
screen.getByRole("menuitemradio", { name: "Camera 2", checked: false });
|
||||
within(screen.getByRole("group", { name: "Background effects" })).getByRole(
|
||||
"menuitemradio",
|
||||
{ name: "Blur" },
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("menuitemcheckbox", { name: "Blur background" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("marks focus as keyboard-driven only when the keyboard moved it", async () => {
|
||||
|
||||
@@ -7,8 +7,6 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type FC,
|
||||
@@ -16,18 +14,16 @@ import {
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuScrollArea,
|
||||
MenuScrollSection,
|
||||
MenuTitle,
|
||||
RadioInput,
|
||||
ToggleMenuItem,
|
||||
} from "@vector-im/compound-web";
|
||||
import {
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
InfoIcon,
|
||||
SpinnerIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
@@ -44,10 +40,6 @@ import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { useRootElement } from "../RootElementContext";
|
||||
import { observeElementSize$ } from "../utils/elementSize";
|
||||
import { LiveMicrophoneLevelMeter } from "./MicrophoneLevelMeter";
|
||||
import {
|
||||
BackgroundEffectGrid,
|
||||
type BackgroundEffectGridItem,
|
||||
} from "./BackgroundEffectGrid";
|
||||
|
||||
export interface MenuOptions {
|
||||
label: DeviceLabel | AudioOutputDeviceLabel;
|
||||
@@ -72,19 +64,8 @@ export interface MediaMuteAndSwitchButtonProps {
|
||||
selectedOutputOption?: string;
|
||||
/** Picks an output device. Undefined disables the speaker section. */
|
||||
onSelectOutput?: (id: string) => void;
|
||||
/** Camera menu only. None or an empty list leaves the section out. */
|
||||
backgroundEffects?: BackgroundEffectGridItem[];
|
||||
selectedBackgroundEffect?: string;
|
||||
/** Undefined where effects can't run: all but no effect are disabled. */
|
||||
onSelectBackgroundEffect?: (id: string) => void;
|
||||
/** What the user should know before choosing an effect, if anything. */
|
||||
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;
|
||||
/** Why the last file offered couldn't be used; a new object each time. */
|
||||
backgroundImageRefusal?: { text: string };
|
||||
videoBlurToggleClick?: () => void;
|
||||
videoBlurEnabled?: boolean;
|
||||
/**
|
||||
* 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`
|
||||
@@ -92,6 +73,8 @@ export interface MediaMuteAndSwitchButtonProps {
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
const BLUR_ID = "blur";
|
||||
|
||||
/** Id of the placeholder "Default" row, shown when the platform lists no outputs. */
|
||||
const DEFAULT_OUTPUT_ID = "default";
|
||||
|
||||
@@ -101,9 +84,6 @@ const LIST_SHARE_OF_CALL = 0.6;
|
||||
/** Smallest device list height in px, so a short call still shows more than one device. */
|
||||
const MIN_LIST_HEIGHT = 160;
|
||||
|
||||
/** Space kept between the menu and the call area's sides. */
|
||||
const MENU_MARGIN = 16;
|
||||
|
||||
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
enabled,
|
||||
busy,
|
||||
@@ -114,13 +94,8 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
outputOptions,
|
||||
selectedOutputOption,
|
||||
onSelectOutput,
|
||||
backgroundEffects,
|
||||
selectedBackgroundEffect,
|
||||
onSelectBackgroundEffect,
|
||||
backgroundEffectNotice,
|
||||
backgroundEffectSettling,
|
||||
onAddBackgroundImage,
|
||||
backgroundImageRefusal,
|
||||
videoBlurEnabled,
|
||||
videoBlurToggleClick,
|
||||
onSelect,
|
||||
}) => {
|
||||
// Requested but not yet selected. Keyed by kind too, since Chrome uses
|
||||
@@ -130,46 +105,14 @@ 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();
|
||||
const noticeId = useId();
|
||||
|
||||
/**
|
||||
* Records on the menu whether the keyboard or the pointer moved focus, for
|
||||
@@ -214,18 +157,18 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
return (): void => subscription.unsubscribe();
|
||||
}, [menuOpen, rootElement]);
|
||||
|
||||
// Kept clear at the list's foot, so a row reached by keyboard isn't under
|
||||
// the meter.
|
||||
const [meterHeight, meter] = useMeasuredHeight();
|
||||
|
||||
// Likewise at its head, for the sticky headings.
|
||||
const [headingHeight, heading] = useMeasuredHeight();
|
||||
|
||||
useEffect(() => {
|
||||
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
|
||||
}, [menuOpen, devices]);
|
||||
|
||||
const audioMeter = iconsAndLabels === "audio" && (
|
||||
<LiveMicrophoneLevelMeter
|
||||
deviceId={selectedOption}
|
||||
// Only while open, so the microphone isn't held all call.
|
||||
active={menuOpen}
|
||||
/>
|
||||
);
|
||||
const MuteButton = audioMeter ? MicButton : VideoButton;
|
||||
const MuteButton = iconsAndLabels === "audio" ? MicButton : VideoButton;
|
||||
const button = (
|
||||
<MuteButton
|
||||
enabled={enabled ?? false}
|
||||
@@ -236,12 +179,22 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
e.stopPropagation();
|
||||
}}
|
||||
disabled={isBusy || onMuteClick === undefined}
|
||||
data-testid={audioMeter ? "incall_mute" : "incall_videomute"}
|
||||
data-testid={
|
||||
iconsAndLabels === "audio" ? "incall_mute" : "incall_videomute"
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const showEffects =
|
||||
iconsAndLabels === "video" && (backgroundEffects?.length ?? 0) > 0;
|
||||
const toggles =
|
||||
iconsAndLabels === "video" && videoBlurToggleClick !== undefined
|
||||
? [
|
||||
{
|
||||
label: t("action.blur_background"),
|
||||
enabled: videoBlurEnabled ?? false,
|
||||
id: BLUR_ID,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
let optionsButtonLabel: string;
|
||||
let menuTitle: string;
|
||||
@@ -365,21 +318,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.
|
||||
@@ -388,8 +326,6 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
open={menuOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
side="top"
|
||||
collisionBoundary={rootElement}
|
||||
collisionPadding={MENU_MARGIN}
|
||||
trigger={
|
||||
<Button
|
||||
iconOnly
|
||||
@@ -404,85 +340,101 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MenuScrollArea
|
||||
<div
|
||||
ref={trackFocusSource}
|
||||
// Keeps the items the menu's own children for assistive tech.
|
||||
role="none"
|
||||
className={styles.deviceList}
|
||||
data-testid="device-list"
|
||||
style={
|
||||
{
|
||||
"--device-list-max-height":
|
||||
listMaxHeight === undefined ? undefined : `${listMaxHeight}px`,
|
||||
"--device-list-scroll-padding-end":
|
||||
meterHeight === undefined ? undefined : `${meterHeight}px`,
|
||||
"--device-list-scroll-padding-start":
|
||||
headingHeight === undefined ? undefined : `${headingHeight}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{audioMeter && speakerOptions && (
|
||||
<MenuScrollSection title={t("settings.devices.speaker")}>
|
||||
{deviceItems(
|
||||
"output",
|
||||
speakerOptions,
|
||||
selectedSpeaker,
|
||||
onSelectOutput,
|
||||
(n) => t("settings.devices.speaker_numbered", { n }),
|
||||
)}
|
||||
</MenuScrollSection>
|
||||
)}
|
||||
<MenuScrollSection
|
||||
title={optionsButtonLabel}
|
||||
stickyBottomItem={audioMeter}
|
||||
>
|
||||
{deviceItems(
|
||||
"input",
|
||||
options,
|
||||
selectedOption,
|
||||
onSelect,
|
||||
numberedLabel,
|
||||
)}
|
||||
</MenuScrollSection>
|
||||
{showEffects && (
|
||||
<MenuScrollSection title={t("settings.background_effects_header")}>
|
||||
<BackgroundEffectGrid
|
||||
effects={backgroundEffects ?? []}
|
||||
selected={selectedBackgroundEffect}
|
||||
onSelect={onSelectBackgroundEffect}
|
||||
settling={backgroundEffectSettling}
|
||||
describedBy={
|
||||
backgroundEffectNotice === undefined ? undefined : noticeId
|
||||
}
|
||||
onAdd={
|
||||
onAddBackgroundImage === undefined
|
||||
? undefined
|
||||
: (): void => {
|
||||
choosingFile.current = true;
|
||||
fileInput.current?.click();
|
||||
}
|
||||
}
|
||||
addLabel={t("action.add_background_image")}
|
||||
/>
|
||||
{/* 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)}
|
||||
/>
|
||||
{iconsAndLabels === "audio" && speakerOptions && (
|
||||
<>
|
||||
{/* A menu may only contain items, separators and groups, so each
|
||||
heading is a hidden part of a named group. */}
|
||||
<div role="group" aria-label={t("settings.devices.speaker")}>
|
||||
<div aria-hidden className={styles.sectionHeading}>
|
||||
<MenuTitle title={t("settings.devices.speaker")} />
|
||||
</div>
|
||||
)}
|
||||
{refusal === undefined &&
|
||||
backgroundEffectNotice !== undefined && (
|
||||
<div id={noticeId} role="none" className={styles.notice}>
|
||||
<InfoIcon width={20} height={20} />
|
||||
<span>{backgroundEffectNotice}</span>
|
||||
</div>
|
||||
{deviceItems(
|
||||
"output",
|
||||
speakerOptions,
|
||||
selectedSpeaker,
|
||||
onSelectOutput,
|
||||
(n) => t("settings.devices.speaker_numbered", { n }),
|
||||
)}
|
||||
</MenuScrollSection>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</MenuScrollArea>
|
||||
<div role="group" aria-label={optionsButtonLabel}>
|
||||
<div ref={heading} aria-hidden className={styles.sectionHeading}>
|
||||
<MenuTitle title={optionsButtonLabel} />
|
||||
</div>
|
||||
{/* Apart from the heading, so the sticky meter can't ride over it. */}
|
||||
<div role="none">
|
||||
{deviceItems(
|
||||
"input",
|
||||
options,
|
||||
selectedOption,
|
||||
onSelect,
|
||||
numberedLabel,
|
||||
)}
|
||||
{iconsAndLabels === "audio" && (
|
||||
<LiveMicrophoneLevelMeter
|
||||
ref={meter}
|
||||
deviceId={selectedOption}
|
||||
// Only while open, so the microphone isn't held all call.
|
||||
active={menuOpen}
|
||||
className={styles.stickyMeter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{toggles.length > 0 && <hr />}
|
||||
{toggles.map((toggle) => (
|
||||
<ToggleMenuItem
|
||||
label={toggle.label}
|
||||
onSelect={(e) => {
|
||||
videoBlurToggleClick?.();
|
||||
e.preventDefault();
|
||||
}}
|
||||
checked={toggle.enabled}
|
||||
key={toggle.id}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Follows an element's height. */
|
||||
function useMeasuredHeight(): [
|
||||
number | undefined,
|
||||
(element: HTMLElement | null) => (() => void) | undefined,
|
||||
] {
|
||||
const [height, setHeight] = useState<number>();
|
||||
const ref = useCallback(
|
||||
(element: HTMLElement | null): (() => void) | undefined => {
|
||||
if (element === null) return;
|
||||
const subscription = observeElementSize$(element)
|
||||
.pipe(
|
||||
map((size) => size.height),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
.subscribe(setHeight);
|
||||
return (): void => subscription.unsubscribe();
|
||||
},
|
||||
[],
|
||||
);
|
||||
return [height, ref];
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ Please see LICENSE in the repository root for full details.
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-3x);
|
||||
padding-block: var(--cpd-space-2x);
|
||||
/* Centres the icon on the radio column: less half the icon's extra width.
|
||||
Checked in the MeterAlignsWithTheDeviceRows story. */
|
||||
/* Centres the icon on the radio column: less the row's inset border and half
|
||||
the icon's extra width. Checked in the MeterAlignsWithTheDeviceRows story. */
|
||||
padding-inline-start: calc(
|
||||
var(--cpd-space-4x) -
|
||||
var(--cpd-space-4x) - var(--cpd-border-width-1) -
|
||||
(var(--meter-icon-size) - var(--device-control-size)) / 2
|
||||
);
|
||||
padding-inline-end: calc(var(--cpd-space-4x) * 2 + var(--cpd-space-2x));
|
||||
|
||||
@@ -5,9 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback, useLayoutEffect, useRef, useState, type FC } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type FC,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { Text } from "@vector-im/compound-web";
|
||||
import { MicOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { distinctUntilChanged, map } from "rxjs";
|
||||
@@ -22,6 +30,8 @@ export interface LiveMicrophoneLevelMeterProps {
|
||||
deviceId: string | undefined;
|
||||
/** Whether to hold a capture at all. */
|
||||
active: boolean;
|
||||
className?: string;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,18 +41,24 @@ export interface LiveMicrophoneLevelMeterProps {
|
||||
export const LiveMicrophoneLevelMeter: FC<LiveMicrophoneLevelMeterProps> = ({
|
||||
deviceId,
|
||||
active,
|
||||
className,
|
||||
ref,
|
||||
}) => {
|
||||
const state = useMicrophoneLevel(deviceId, active);
|
||||
return <MicrophoneLevelMeter state={state} />;
|
||||
return <MicrophoneLevelMeter state={state} className={className} ref={ref} />;
|
||||
};
|
||||
|
||||
export interface MicrophoneLevelMeterProps {
|
||||
state: MicrophoneState;
|
||||
className?: string;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/** The input level of a microphone, or why there is none. */
|
||||
export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
|
||||
state,
|
||||
className,
|
||||
ref,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Bars keep one size, so their count follows the width. Starts full so the
|
||||
@@ -88,7 +104,7 @@ export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
|
||||
}, [state, barCount, t]);
|
||||
|
||||
return (
|
||||
<div className={styles.meter}>
|
||||
<div ref={ref} className={classNames(styles.meter, className)}>
|
||||
<MicOnIcon className={styles.icon} aria-hidden />
|
||||
{state.type !== "level" ? (
|
||||
<Text size="sm" className={styles.message}>
|
||||
|
||||
@@ -9,7 +9,7 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = `
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_1_"
|
||||
aria-labelledby="_r_0_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -39,7 +39,7 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = `
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
data-state="closed"
|
||||
id="radix-_r_6_"
|
||||
id="radix-_r_5_"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
|
||||
@@ -95,6 +95,19 @@ 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" });
|
||||
|
||||
+12
-5
@@ -140,13 +140,20 @@ export function validateConfig(config: ConfigOptions): ConfigOptions {
|
||||
}
|
||||
|
||||
async function downloadConfig(fetchTarget: string): Promise<ConfigOptions> {
|
||||
const response = await fetch(fetchTarget);
|
||||
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 {};
|
||||
}
|
||||
|
||||
if (isFailure(response)) {
|
||||
// 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;
|
||||
// resolveConfig merges in the defaults.
|
||||
return {};
|
||||
}
|
||||
|
||||
return response.json();
|
||||
|
||||
@@ -5,24 +5,11 @@ 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, test, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { E2eeType } from "@element-hq/matrixrtc-sdk";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
getKeyForRoom,
|
||||
saveKeyForRoom,
|
||||
useRoomEncryptionSystem,
|
||||
} from "./sharedKeyManagement";
|
||||
|
||||
vi.mock("../ClientContext", () => {
|
||||
const room = { hasEncryptionStateEvent: (): boolean => false };
|
||||
const client = { getRoom: (): typeof room => room };
|
||||
return { useClient: (): { client: typeof client } => ({ client }) };
|
||||
});
|
||||
import { getKeyForRoom, saveKeyForRoom } from "./sharedKeyManagement";
|
||||
|
||||
const roomId = "!room:example.org";
|
||||
const otherRoomId = "!other:example.org";
|
||||
|
||||
describe("getKeyForRoom", () => {
|
||||
afterEach(() => {
|
||||
@@ -46,58 +33,4 @@ describe("getKeyForRoom", () => {
|
||||
test("has no key to offer for a room it has never seen", () => {
|
||||
expect(getKeyForRoom(roomId)).toBeNull();
|
||||
});
|
||||
|
||||
test("prefers the stored key when the parameters are for another room", () => {
|
||||
saveKeyForRoom(roomId, "stored");
|
||||
window.location.hash = `#?roomId=${encodeURIComponent(otherRoomId)}&password=other-room`;
|
||||
|
||||
expect(getKeyForRoom(roomId)).toBe("stored");
|
||||
});
|
||||
|
||||
test("ignores the password in the parameters for another room", () => {
|
||||
window.location.hash = `#?roomId=${encodeURIComponent(otherRoomId)}&password=other-room`;
|
||||
|
||||
expect(getKeyForRoom(roomId)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useRoomEncryptionSystem", () => {
|
||||
afterEach(() => {
|
||||
window.location.hash = "#";
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
test("picks up a key saved for the room after it rendered", () => {
|
||||
const { result } = renderHook(() => useRoomEncryptionSystem(roomId));
|
||||
expect(result.current).toEqual({ kind: E2eeType.NONE });
|
||||
|
||||
act(() => saveKeyForRoom(roomId, "stored"));
|
||||
|
||||
expect(result.current).toEqual({
|
||||
kind: E2eeType.SHARED_KEY,
|
||||
secret: "stored",
|
||||
});
|
||||
});
|
||||
|
||||
test("stores a key from the parameters under the room's key only", () => {
|
||||
window.location.hash = `#?roomId=${encodeURIComponent(roomId)}&password=from-the-link`;
|
||||
|
||||
renderHook(() => useRoomEncryptionSystem(roomId));
|
||||
|
||||
expect(Object.keys(localStorage)).toEqual([`room-shared-key-${roomId}`]);
|
||||
expect(getKeyForRoom(roomId)).toBe("from-the-link");
|
||||
});
|
||||
|
||||
test("keeps the stored key when the parameters are for another room", () => {
|
||||
saveKeyForRoom(roomId, "stored");
|
||||
window.location.hash = `#?roomId=${encodeURIComponent(otherRoomId)}&password=other-room`;
|
||||
|
||||
const { result } = renderHook(() => useRoomEncryptionSystem(roomId));
|
||||
|
||||
expect(result.current).toEqual({
|
||||
kind: E2eeType.SHARED_KEY,
|
||||
secret: "stored",
|
||||
});
|
||||
expect(localStorage.getItem(`room-shared-key-${roomId}`)).toBe("stored");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,27 +59,22 @@ const useRoomSharedKey = (
|
||||
|
||||
/**
|
||||
* The shared key for a room, preferring one supplied in the parameters Element
|
||||
* Call was started with over whatever is in local storage. If the parameters
|
||||
* are for another room, only the stored key is used, because the password in
|
||||
* the parameters belongs to that other room.
|
||||
* Call was started with over whatever is in local storage.
|
||||
*/
|
||||
function keyForRoom(
|
||||
roomId: string,
|
||||
paramsRoomId: string | null,
|
||||
password: string | null,
|
||||
): string | null {
|
||||
const storedKey = localStorage.getItem(
|
||||
getRoomSharedKeyLocalStorageKey(roomId),
|
||||
);
|
||||
if (roomId !== paramsRoomId) {
|
||||
if (roomId !== paramsRoomId)
|
||||
logger.warn(
|
||||
"requested key for a roomId which is not the current call room id (from the URL)",
|
||||
roomId,
|
||||
paramsRoomId,
|
||||
);
|
||||
return storedKey;
|
||||
}
|
||||
return password ?? storedKey;
|
||||
return (
|
||||
password ?? localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId))
|
||||
);
|
||||
}
|
||||
|
||||
export function getKeyForRoom(roomId: string): string | null {
|
||||
@@ -94,12 +89,16 @@ export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
||||
const { roomId: paramsRoomId, password } = useUrlParams();
|
||||
|
||||
const [storedPassword] = useRoomSharedKey(
|
||||
roomId,
|
||||
// TODO: this passes an already-prefixed key where a room ID is expected, so
|
||||
// the local storage key ends up prefixed twice and never matches what
|
||||
// saveKeyForRoom writes. Preserved as-is here to keep this commit a pure
|
||||
// refactor; the reactive read is effectively dead until it is fixed.
|
||||
getRoomSharedKeyLocalStorageKey(roomId),
|
||||
keyForRoom(roomId, paramsRoomId, password) ?? undefined,
|
||||
);
|
||||
|
||||
const room = client?.getRoom(roomId);
|
||||
const e2eeSystem = useMemo<EncryptionSystem>(() => {
|
||||
const e2eeSystem = <EncryptionSystem>useMemo(() => {
|
||||
if (!room) return { kind: E2eeType.NONE };
|
||||
if (storedPassword)
|
||||
return {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
+150
-100
@@ -5,25 +5,28 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import { arrangeTiles, type Bounds } from "./CallLayout";
|
||||
import { makeGridLayout } from "./GridLayout";
|
||||
import { makeSpotlightLandscapeLayout } from "./SpotlightLandscapeLayout";
|
||||
import { makeSpotlightPortraitLayout } from "./SpotlightPortraitLayout";
|
||||
import { type LayoutProps } from "./Grid";
|
||||
import { type DragCallback, type LayoutProps } from "./Grid";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type Alignment } from "../state/layout-types";
|
||||
import {
|
||||
type Alignment,
|
||||
type GridLayout,
|
||||
type SpotlightLandscapeLayout,
|
||||
type SpotlightPortraitLayout,
|
||||
} from "../state/layout-types";
|
||||
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
||||
import {
|
||||
GridTileViewModel,
|
||||
SpotlightTileViewModel,
|
||||
type TileViewModel,
|
||||
} from "../state/TileViewModel";
|
||||
import { makeOneOnOneMobileLayout } from "./OneOnOneMobileLayout";
|
||||
import { makeSpotlightExpandedLayout } from "./SpotlightExpandedLayout";
|
||||
import { makeOneOnOneDesktopLayout } from "./OneOnOneDesktopLayout";
|
||||
|
||||
// Grid supplies these hooks to the layouts it renders; on their own the
|
||||
// layouts have no grid to report to
|
||||
@@ -33,105 +36,152 @@ vi.mock("./Grid", async (importOriginal) => ({
|
||||
useVisibleTiles: (): void => {},
|
||||
}));
|
||||
|
||||
const minBounds$ = constant({ width: 800, height: 800 });
|
||||
describe("grid layout", () => {
|
||||
it("sizes the tiles from the minimum bounds", () => {
|
||||
const minBounds$ = new BehaviorSubject<Bounds>({ width: 800, height: 600 });
|
||||
const { scrolling: Scrolling } = makeGridLayout({ minBounds$ });
|
||||
render(<Scrolling model={gridModel()} Slot={Slot} />);
|
||||
const layer = screen.getByTestId("slot-0").parentElement!;
|
||||
expect(layer.style.width).toBe("800px");
|
||||
expect(layer.style.getPropertyValue("--width")).toBe(
|
||||
`${Math.floor(arrangeTiles(800, 600, 3).tileWidth)}px`,
|
||||
);
|
||||
|
||||
const spotlight = {
|
||||
vm: new SpotlightTileViewModel(
|
||||
constant([]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
),
|
||||
alignment$: constant<Alignment>({ inline: "end", block: "end" }),
|
||||
onDrag: () => {},
|
||||
};
|
||||
const grid = Array.from(
|
||||
{ length: 3 },
|
||||
() => new GridTileViewModel(constant({} as UserMediaViewModel)),
|
||||
act(() => minBounds$.next({ width: 400, height: 300 }));
|
||||
expect(layer.style.width).toBe("400px");
|
||||
expect(layer.style.getPropertyValue("--width")).toBe(
|
||||
`${Math.floor(arrangeTiles(400, 300, 3).tileWidth)}px`,
|
||||
);
|
||||
});
|
||||
|
||||
it("moves the spotlight only when a drag reaches another corner", () => {
|
||||
const model = gridModel();
|
||||
const { fixed: Fixed } = makeGridLayout({ minBounds$: bounds });
|
||||
render(<Fixed model={model} Slot={Slot} />);
|
||||
const alignments: Alignment[] = [];
|
||||
model.spotlightAlignment$.subscribe((a) => alignments.push(a));
|
||||
const drag = drags.get("spotlight")!;
|
||||
|
||||
// Still in the bottom right corner
|
||||
act(() => drag({ x: 0, y: 0, xRatio: 0.9, yRatio: 0.9 }));
|
||||
// Over to the bottom left
|
||||
act(() => drag({ x: 0, y: 0, xRatio: 0.1, yRatio: 0.9 }));
|
||||
// And a little further, still bottom left
|
||||
act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.8 }));
|
||||
// Up to the top left
|
||||
act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.1 }));
|
||||
|
||||
expect(alignments).toEqual([
|
||||
{ inline: "end", block: "end" },
|
||||
{ inline: "start", block: "end" },
|
||||
{ inline: "start", block: "start" },
|
||||
]);
|
||||
const slot = screen.getByTestId("slot-spotlight");
|
||||
expect(slot.getAttribute("data-inline-alignment")).toBe("start");
|
||||
expect(slot.getAttribute("data-block-alignment")).toBe("start");
|
||||
});
|
||||
});
|
||||
|
||||
describe("spotlight landscape layout", () => {
|
||||
it("puts the spotlight in the fixed layer and the grid in the scrolling one", () => {
|
||||
const { fixed: Fixed, scrolling: Scrolling } = makeSpotlightLandscapeLayout(
|
||||
{ minBounds$: bounds },
|
||||
);
|
||||
const model = landscapeModel();
|
||||
const { unmount } = render(<Fixed model={model} Slot={Slot} />);
|
||||
expect(screen.getByTestId("slot-spotlight")).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
render(<Scrolling model={model} Slot={Slot} />);
|
||||
expect(slotIds()).toEqual(model.grid.map((tile) => tile.id));
|
||||
});
|
||||
});
|
||||
|
||||
describe("spotlight portrait layout", () => {
|
||||
it("sizes the grid tiles from the minimum width", () => {
|
||||
const minBounds$ = new BehaviorSubject<Bounds>({ width: 300, height: 700 });
|
||||
const { scrolling: Scrolling } = makeSpotlightPortraitLayout({
|
||||
minBounds$,
|
||||
});
|
||||
const model = portraitModel();
|
||||
render(<Scrolling model={model} Slot={Slot} />);
|
||||
expect(slotIds()).toEqual(model.grid.map((tile) => tile.id));
|
||||
const layer = screen.getByTestId(`slot-${model.grid[0].id}`).parentElement!
|
||||
.parentElement!;
|
||||
expect(layer.style.getPropertyValue("--grid-tile-width")).toBe(
|
||||
`${Math.floor(arrangeTiles(300, 300, 3).tileWidth)}px`,
|
||||
);
|
||||
|
||||
act(() => minBounds$.next({ width: 500, height: 700 }));
|
||||
expect(layer.style.getPropertyValue("--grid-tile-width")).toBe(
|
||||
`${Math.floor(arrangeTiles(500, 500, 3).tileWidth)}px`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const bounds = constant<Bounds>({ width: 800, height: 600 });
|
||||
|
||||
// The layouts only pass tile view models through to their slots
|
||||
const media = {} as UserMediaViewModel;
|
||||
const spotlight = new SpotlightTileViewModel(
|
||||
constant([]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
);
|
||||
const pip = {
|
||||
alignment$: constant<Alignment>({ inline: "end", block: "end" }),
|
||||
onDrag: () => {},
|
||||
vm: { id: "pip" } as GridTileViewModel,
|
||||
size$: constant("sm" as const),
|
||||
};
|
||||
const tiles = (): GridTileViewModel[] =>
|
||||
Array.from({ length: 3 }, () => new GridTileViewModel(constant(media)));
|
||||
|
||||
function gridModel(): GridLayout {
|
||||
return {
|
||||
type: "grid",
|
||||
spotlight,
|
||||
grid: tiles(),
|
||||
spotlightAlignment$: new BehaviorSubject<Alignment>({
|
||||
inline: "end",
|
||||
block: "end",
|
||||
}),
|
||||
setVisibleTiles: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function landscapeModel(): SpotlightLandscapeLayout {
|
||||
return {
|
||||
type: "spotlight-landscape",
|
||||
spotlight,
|
||||
grid: tiles(),
|
||||
setVisibleTiles: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function portraitModel(): SpotlightPortraitLayout {
|
||||
return {
|
||||
type: "spotlight-portrait",
|
||||
spotlight,
|
||||
grid: tiles(),
|
||||
setVisibleTiles: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// Slots record their drag callback so a test can drag them, and show the id
|
||||
// of the tile they hold
|
||||
const drags = new Map<string, DragCallback | undefined>();
|
||||
const Slot: LayoutProps<unknown, TileViewModel, HTMLDivElement>["Slot"] = ({
|
||||
id,
|
||||
model,
|
||||
onDrag,
|
||||
children,
|
||||
...props
|
||||
}) => (
|
||||
<div data-testid={`slot-${id}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
function snapshotLayout<Model>(layout: CallLayout<Model>, model: Model): void {
|
||||
const {
|
||||
foreground,
|
||||
scrolling: Scrolling,
|
||||
fixed: Fixed,
|
||||
} = layout({ minBounds$ });
|
||||
const scrollingLayer = <Scrolling model={model} Slot={Slot} />;
|
||||
const fixedLayer = <Fixed model={model} Slot={Slot} />;
|
||||
const { container } = render(
|
||||
foreground === "fixed" ? (
|
||||
<>
|
||||
{scrollingLayer}
|
||||
{fixedLayer}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{fixedLayer}
|
||||
{scrollingLayer}
|
||||
</>
|
||||
),
|
||||
}) => {
|
||||
drags.set(id, onDrag);
|
||||
return (
|
||||
<div data-testid={`slot-${id}`} {...props}>
|
||||
{model instanceof GridTileViewModel ? model.id : "spotlight"}
|
||||
</div>
|
||||
);
|
||||
expect(container).toMatchSnapshot();
|
||||
};
|
||||
|
||||
function slotIds(): string[] {
|
||||
return screen
|
||||
.getAllByTestId(/^slot-/)
|
||||
.map((slot) => slot.textContent ?? "")
|
||||
.filter((id) => id !== "spotlight");
|
||||
}
|
||||
|
||||
test("grid layout", () =>
|
||||
snapshotLayout(makeGridLayout, {
|
||||
type: "grid",
|
||||
spotlight,
|
||||
grid,
|
||||
setVisibleTiles: () => {},
|
||||
}));
|
||||
|
||||
test("spotlight landscape layout", () =>
|
||||
snapshotLayout(makeSpotlightLandscapeLayout, {
|
||||
type: "spotlight-landscape",
|
||||
spotlight,
|
||||
grid,
|
||||
setVisibleTiles: () => {},
|
||||
}));
|
||||
|
||||
test("spotlight portrait layout", () =>
|
||||
snapshotLayout(makeSpotlightPortraitLayout, {
|
||||
type: "spotlight-portrait",
|
||||
spotlight,
|
||||
grid,
|
||||
setVisibleTiles: () => {},
|
||||
}));
|
||||
|
||||
test("spotlight expanded layout", () =>
|
||||
snapshotLayout(makeSpotlightExpandedLayout, {
|
||||
type: "spotlight-expanded",
|
||||
spotlight: spotlight.vm,
|
||||
pip,
|
||||
}));
|
||||
|
||||
test("one-on-one desktop layout", () =>
|
||||
snapshotLayout(makeOneOnOneDesktopLayout, {
|
||||
type: "one-on-one-desktop",
|
||||
spotlight: { id: "spotlight" } as GridTileViewModel,
|
||||
pip,
|
||||
}));
|
||||
|
||||
test("one-on-one mobile layout", () =>
|
||||
snapshotLayout(makeOneOnOneMobileLayout, {
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: spotlight.vm,
|
||||
pip,
|
||||
}));
|
||||
|
||||
+29
-12
@@ -40,7 +40,6 @@ import { useMergedRefs } from "../useMergedRefs";
|
||||
import { TileWrapper } from "./TileWrapper";
|
||||
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { type DragCallback } from "../state/layout-types";
|
||||
|
||||
const MAX_ANIMATED_TILES = 50; // Capped for performance reasons
|
||||
|
||||
@@ -84,10 +83,7 @@ interface DragState {
|
||||
cursorY: number;
|
||||
}
|
||||
|
||||
export interface SlotProps<Model> extends Omit<
|
||||
ComponentProps<"div">,
|
||||
"onDrag"
|
||||
> {
|
||||
interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
|
||||
id: string;
|
||||
model: Model;
|
||||
onDrag?: DragCallback;
|
||||
@@ -185,6 +181,27 @@ export interface TileProps<Model, R extends HTMLElement> {
|
||||
model: Model;
|
||||
}
|
||||
|
||||
interface Drag {
|
||||
/**
|
||||
* The X coordinate of the dragged tile in grid space.
|
||||
*/
|
||||
x: number;
|
||||
/**
|
||||
* The Y coordinate of the dragged tile in grid space.
|
||||
*/
|
||||
y: number;
|
||||
/**
|
||||
* The X coordinate of the dragged tile, as a scalar of the grid width.
|
||||
*/
|
||||
xRatio: number;
|
||||
/**
|
||||
* The Y coordinate of the dragged tile, as a scalar of the grid height.
|
||||
*/
|
||||
yRatio: number;
|
||||
}
|
||||
|
||||
export type DragCallback = (drag: Drag) => void;
|
||||
|
||||
interface LayoutMemoProps<
|
||||
LayoutModel,
|
||||
TileModel,
|
||||
@@ -447,13 +464,13 @@ export function Grid<
|
||||
)
|
||||
.catch(logger.error);
|
||||
|
||||
callback({
|
||||
x: tileX,
|
||||
y: tileY,
|
||||
xRatio: tileX / (gridBounds.width - tile.width),
|
||||
yRatio: tileY / (gridBounds.height - tile.height),
|
||||
endOfGesture,
|
||||
});
|
||||
if (endOfGesture)
|
||||
callback({
|
||||
x: tileX,
|
||||
y: tileY,
|
||||
xRatio: tileX / (gridBounds.width - tile.width),
|
||||
yRatio: tileY / (gridBounds.height - tile.height),
|
||||
});
|
||||
};
|
||||
|
||||
// Callback for useDrag. We could call useDrag here, but the default
|
||||
|
||||
+25
-30
@@ -8,21 +8,15 @@ Please see LICENSE in the repository root for full details.
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
type FC,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
|
||||
import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts";
|
||||
import styles from "./GridLayout.module.css";
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import { type SlotProps, useUpdateLayout, useVisibleTiles } from "./Grid";
|
||||
import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import {
|
||||
type SpotlightTileViewModel,
|
||||
type TileViewModel,
|
||||
} from "../state/TileViewModel.ts";
|
||||
import { type FloatingTile } from "../state/FloatingTile.ts";
|
||||
|
||||
interface GridCSSProperties extends CSSProperties {
|
||||
"--gap": string;
|
||||
@@ -30,27 +24,6 @@ interface GridCSSProperties extends CSSProperties {
|
||||
"--height": string;
|
||||
}
|
||||
|
||||
interface SpotlightSlotProps {
|
||||
model: FloatingTile & { vm: SpotlightTileViewModel };
|
||||
Slot: ComponentType<SlotProps<TileViewModel>>;
|
||||
}
|
||||
|
||||
const SpotlightSlot: FC<SpotlightSlotProps> = ({ model, Slot }) => {
|
||||
useUpdateLayout();
|
||||
const alignment = useBehavior(model.alignment$);
|
||||
|
||||
return (
|
||||
<Slot
|
||||
className={styles.slot}
|
||||
id="spotlight"
|
||||
model={model.vm}
|
||||
onDrag={model.onDrag}
|
||||
data-block-alignment={alignment.block}
|
||||
data-inline-alignment={alignment.inline}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* An implementation of the "grid" layout, in which all participants are shown
|
||||
* together in a scrolling grid.
|
||||
@@ -64,10 +37,32 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||
// lives
|
||||
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
|
||||
useUpdateLayout();
|
||||
const alignment = useBehavior(model.spotlightAlignment$);
|
||||
|
||||
const onDragSpotlight: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) => {
|
||||
const block = yRatio < 0.5 ? "start" : "end";
|
||||
const inline = xRatio < 0.5 ? "start" : "end";
|
||||
// A drag reports the same alignment on every move; only a change is
|
||||
// worth a re-render
|
||||
const current = model.spotlightAlignment$.value;
|
||||
if (current.block !== block || current.inline !== inline)
|
||||
model.spotlightAlignment$.next({ block, inline });
|
||||
},
|
||||
[model.spotlightAlignment$],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.fixed}>
|
||||
{model.spotlight && (
|
||||
<SpotlightSlot model={model.spotlight} Slot={Slot} />
|
||||
<Slot
|
||||
className={styles.slot}
|
||||
id="spotlight"
|
||||
model={model.spotlight}
|
||||
onDrag={onDragSpotlight}
|
||||
data-block-alignment={alignment.block}
|
||||
data-inline-alignment={alignment.inline}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,13 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { type ReactNode, useCallback, useMemo } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts";
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import styles from "./OneOnOneDesktopLayout.module.css";
|
||||
import { useUpdateLayout } from "./Grid";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
/**
|
||||
@@ -37,12 +37,21 @@ export const makeOneOnOneDesktopLayout: CallLayout<
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const { width, height } = useBehavior(minBounds$);
|
||||
const pipAlignment = useBehavior(model.pip.alignment$);
|
||||
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||
const { tileWidth, tileHeight } = useMemo(
|
||||
() => arrangeTiles(width, height, 1),
|
||||
[width, height],
|
||||
);
|
||||
|
||||
const onDragLocalTile: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
model.pipAlignment$.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[model.pipAlignment$],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<Slot
|
||||
@@ -53,9 +62,9 @@ export const makeOneOnOneDesktopLayout: CallLayout<
|
||||
>
|
||||
<Slot
|
||||
className={classNames(styles.slot, styles.local)}
|
||||
id={model.pip.vm.id}
|
||||
model={model.pip.vm}
|
||||
onDrag={model.pip.onDrag}
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
onDrag={onDragLocalTile}
|
||||
data-block-alignment={pipAlignment.block}
|
||||
data-inline-alignment={pipAlignment.inline}
|
||||
/>
|
||||
|
||||
@@ -6,43 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ComponentType, type FC, type ReactNode } from "react";
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type OneOnOneMobileLayout as OneOnOneMobileLayoutModel } from "../state/layout-types.ts";
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import styles from "./OneOnOneMobileLayout.module.css";
|
||||
import { type SlotProps, useUpdateLayout } from "./Grid";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { type FloatingTile } from "../state/FloatingTile.ts";
|
||||
import {
|
||||
type GridTileViewModel,
|
||||
type TileViewModel,
|
||||
} from "../state/TileViewModel.ts";
|
||||
import { type Behavior } from "../state/Behavior.ts";
|
||||
|
||||
interface PipSlotProps {
|
||||
model: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> };
|
||||
Slot: ComponentType<SlotProps<TileViewModel>>;
|
||||
}
|
||||
|
||||
const PipSlot: FC<PipSlotProps> = ({ model, Slot }) => {
|
||||
useUpdateLayout();
|
||||
const size = useBehavior(model.size$);
|
||||
const alignment = useBehavior(model.alignment$);
|
||||
|
||||
return (
|
||||
<Slot
|
||||
className={classNames(styles.pip)}
|
||||
id={model.vm.id}
|
||||
model={model.vm}
|
||||
onDrag={model.onDrag}
|
||||
data-size={size}
|
||||
data-block-alignment={alignment.block}
|
||||
data-inline-alignment={alignment.inline}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* An implementation of the "one-on-one" layout for mobile platforms, in which
|
||||
@@ -73,9 +44,30 @@ export const makeOneOnOneMobileLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const pipSize = useBehavior(model.pipSize$);
|
||||
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||
const onDragLocalTile: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
model.pipAlignment$.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[model.pipAlignment$],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
|
||||
{model.pip && (
|
||||
<Slot
|
||||
className={classNames(styles.pip)}
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
onDrag={onDragLocalTile}
|
||||
data-size={pipSize}
|
||||
data-block-alignment={pipAlignment.block}
|
||||
data-inline-alignment={pipAlignment.inline}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -5,39 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode, type ComponentType, type FC } from "react";
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
|
||||
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/layout-types.ts";
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import { type SlotProps, useUpdateLayout } from "./Grid";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import styles from "./SpotlightExpandedLayout.module.css";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { type FloatingTile } from "../state/FloatingTile.ts";
|
||||
import {
|
||||
type GridTileViewModel,
|
||||
type TileViewModel,
|
||||
} from "../state/TileViewModel.ts";
|
||||
|
||||
interface PipSlotProps {
|
||||
model: FloatingTile & { vm: GridTileViewModel };
|
||||
Slot: ComponentType<SlotProps<TileViewModel>>;
|
||||
}
|
||||
|
||||
const PipSlot: FC<PipSlotProps> = ({ model, Slot }) => {
|
||||
useUpdateLayout();
|
||||
const alignment = useBehavior(model.alignment$);
|
||||
|
||||
return (
|
||||
<Slot
|
||||
className={styles.pip}
|
||||
id={model.vm.id}
|
||||
model={model.vm}
|
||||
onDrag={model.onDrag}
|
||||
data-block-alignment={alignment.block}
|
||||
data-inline-alignment={alignment.inline}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* An implementation of the "expanded spotlight" layout, in which the spotlight
|
||||
@@ -54,6 +28,7 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<Slot
|
||||
@@ -71,9 +46,29 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||
|
||||
const onDragPip: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
model.pipAlignment$.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[model.pipAlignment$],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
|
||||
{model.pip && (
|
||||
<Slot
|
||||
className={styles.pip}
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
onDrag={onDragPip}
|
||||
data-block-alignment={pipAlignment.block}
|
||||
data-inline-alignment={pipAlignment.inline}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
<Slot
|
||||
className={styles.slot}
|
||||
id="spotlight"
|
||||
model={model.spotlight.vm}
|
||||
model={model.spotlight}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.grid} />
|
||||
@@ -59,7 +59,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<SpotlightSlot media$={model.spotlight.vm.media$} />
|
||||
<SpotlightSlot media$={model.spotlight.media$} />
|
||||
<div className={styles.grid}>
|
||||
{model.grid.map((m) => (
|
||||
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
|
||||
|
||||
@@ -43,7 +43,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
||||
<Slot
|
||||
className={styles.slot}
|
||||
id="spotlight"
|
||||
model={model.spotlight.vm}
|
||||
model={model.spotlight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,7 +65,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
||||
width,
|
||||
model.grid.length,
|
||||
);
|
||||
const withIndicators = useBehavior(model.spotlight.vm.media$).length > 1;
|
||||
const withIndicators = useBehavior(model.spotlight.media$).length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`grid layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_scrolling_e8555d"
|
||||
style="width: 800px; --gap: 20px; --width: 370px; --height: 277px;"
|
||||
>
|
||||
<div
|
||||
class="_slot_e8555d"
|
||||
data-testid="slot-0"
|
||||
/>
|
||||
<div
|
||||
class="_slot_e8555d"
|
||||
data-testid="slot-1"
|
||||
/>
|
||||
<div
|
||||
class="_slot_e8555d"
|
||||
data-testid="slot-2"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="_fixed_e8555d"
|
||||
>
|
||||
<div
|
||||
class="_slot_e8555d"
|
||||
data-block-alignment="end"
|
||||
data-inline-alignment="end"
|
||||
data-testid="slot-spotlight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`one-on-one desktop layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_layer_185815"
|
||||
>
|
||||
<div
|
||||
class="_container_185815"
|
||||
data-testid="slot-spotlight"
|
||||
style="width: 760px; height: 570px;"
|
||||
>
|
||||
<div
|
||||
class="_slot_185815 _local_185815"
|
||||
data-block-alignment="end"
|
||||
data-inline-alignment="end"
|
||||
data-testid="slot-pip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`one-on-one mobile layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_layer_c81c30"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_c81c30"
|
||||
data-testid="slot-spotlight"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="_layer_c81c30"
|
||||
>
|
||||
<div
|
||||
class="_pip_c81c30"
|
||||
data-block-alignment="end"
|
||||
data-inline-alignment="end"
|
||||
data-size="sm"
|
||||
data-testid="slot-pip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`spotlight expanded layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_layer_e70191"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_e70191"
|
||||
data-testid="slot-spotlight"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="_layer_e70191"
|
||||
>
|
||||
<div
|
||||
class="_pip_e70191"
|
||||
data-block-alignment="end"
|
||||
data-inline-alignment="end"
|
||||
data-testid="slot-pip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`spotlight landscape layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_layer_29809c"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_29809c"
|
||||
>
|
||||
<div
|
||||
class="_slot_29809c"
|
||||
data-testid="slot-spotlight"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="_grid_29809c"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="_layer_29809c"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_29809c"
|
||||
/>
|
||||
<div
|
||||
class="_grid_29809c"
|
||||
>
|
||||
<div
|
||||
class="_slot_29809c"
|
||||
data-testid="slot-0"
|
||||
/>
|
||||
<div
|
||||
class="_slot_29809c"
|
||||
data-testid="slot-1"
|
||||
/>
|
||||
<div
|
||||
class="_slot_29809c"
|
||||
data-testid="slot-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`spotlight portrait layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_layer_065bbc"
|
||||
style="--grid-gap: 20px; --grid-tile-width: 370px; --grid-tile-height: 277px;"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_065bbc"
|
||||
/>
|
||||
<div
|
||||
class="_grid_065bbc"
|
||||
>
|
||||
<div
|
||||
class="_slot_065bbc"
|
||||
data-testid="slot-0"
|
||||
/>
|
||||
<div
|
||||
class="_slot_065bbc"
|
||||
data-testid="slot-1"
|
||||
/>
|
||||
<div
|
||||
class="_slot_065bbc"
|
||||
data-testid="slot-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_layer_065bbc"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_065bbc"
|
||||
>
|
||||
<div
|
||||
class="_slot_065bbc"
|
||||
data-testid="slot-spotlight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
mockMediaDevices,
|
||||
mockRtcMembership,
|
||||
MockRTCSession,
|
||||
testScope,
|
||||
} from "../utils/test";
|
||||
import { CallView } from "./CallView";
|
||||
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
|
||||
@@ -62,10 +61,7 @@ import {
|
||||
} from "../HostBridge";
|
||||
import { type JoinCallData } from "../widget";
|
||||
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
||||
import {
|
||||
BackgroundEffectsProvider,
|
||||
createAppBackgroundEffects,
|
||||
} from "../tracks/BackgroundEffectsContext";
|
||||
import { ProcessorProvider } from "../tracks/TrackProcessorContext";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { constant } from "../state/Behavior";
|
||||
|
||||
@@ -197,9 +193,7 @@ function createCallView(
|
||||
<HostBridgeProvider value={hostBridge}>
|
||||
<TooltipProvider>
|
||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||
<BackgroundEffectsProvider
|
||||
effects={createAppBackgroundEffects(testScope())}
|
||||
>
|
||||
<ProcessorProvider>
|
||||
{options.withErrorBoundary ? (
|
||||
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
|
||||
{callView}
|
||||
@@ -207,7 +201,7 @@ function createCallView(
|
||||
) : (
|
||||
callView
|
||||
)}
|
||||
</BackgroundEffectsProvider>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>
|
||||
</HostBridgeProvider>
|
||||
|
||||
@@ -72,7 +72,6 @@ import { useRootElement } from "../RootElementContext.ts";
|
||||
import { useHostBridge } from "../HostBridge.ts";
|
||||
import { useMuteStates } from "../state/useMuteStates.ts";
|
||||
import { useLeaveToHome } from "../LeaveToHomeContext.ts";
|
||||
import { useLocalMedia } from "../useLocalMedia.ts";
|
||||
|
||||
/**
|
||||
* If there already are this many participants in the call, we automatically mute
|
||||
@@ -274,8 +273,6 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
|
||||
const mediaDevices = useMediaDevices();
|
||||
const latestMuteStates = useLatest(muteStates);
|
||||
// Opened by the lobby, published by the call: one capture for both
|
||||
const media = useLocalMedia();
|
||||
|
||||
const enterRTCSessionOrError = useCallback(
|
||||
async (rtcSession: MatrixRTCSession): Promise<void> => {
|
||||
@@ -503,7 +500,6 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
<LobbyView
|
||||
client={client}
|
||||
matrixInfo={matrixInfo}
|
||||
media={media}
|
||||
muteStates={muteStates}
|
||||
onEnter={() => setJoined(true)}
|
||||
confineToRoom={confineToRoom}
|
||||
@@ -533,7 +529,6 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
rtcSession={rtcSession as MatrixRTCSession}
|
||||
matrixRoom={room}
|
||||
onLeft={onLeft}
|
||||
media={media}
|
||||
muteStates={muteStates}
|
||||
e2eeSystem={e2eeSystem}
|
||||
//otelGroupCallMembership={otelGroupCallMembership}
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
type MockRTCSession,
|
||||
mockRTCSlot,
|
||||
mockRTCMember,
|
||||
testScope,
|
||||
} from "../utils/test";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import {
|
||||
@@ -49,10 +48,7 @@ import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
|
||||
import { AppBar } from "../AppBar";
|
||||
import { type MatrixInfo } from "./VideoPreview";
|
||||
import {
|
||||
BackgroundEffectsProvider,
|
||||
createAppBackgroundEffects,
|
||||
} from "../tracks/BackgroundEffectsContext";
|
||||
import { ProcessorProvider } from "../tracks/TrackProcessorContext";
|
||||
import { initializeWidget } from "../widget";
|
||||
import { RootElementProvider } from "../RootElementContext";
|
||||
|
||||
@@ -231,18 +227,6 @@ describe("InCallView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Media that opened nothing: the join captures as it always did
|
||||
function fakeLocalMedia(): MatrixRTCSdk.LocalMedia {
|
||||
return {
|
||||
requests: [],
|
||||
tracks$: constant([]),
|
||||
capture: vi.fn(async () => Promise.resolve([])),
|
||||
release: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
takeTracks: () => new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("ActiveCall", () => {
|
||||
it("creates the view models and renders the call", async () => {
|
||||
const mediaDevices = mockMediaDevices({});
|
||||
@@ -250,9 +234,7 @@ describe("ActiveCall", () => {
|
||||
const { findByTestId } = render(
|
||||
<BrowserRouter>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<BackgroundEffectsProvider
|
||||
effects={createAppBackgroundEffects(testScope())}
|
||||
>
|
||||
<ProcessorProvider>
|
||||
<TooltipProvider>
|
||||
<ActiveCall
|
||||
client={matrixRoom.client}
|
||||
@@ -262,11 +244,10 @@ describe("ActiveCall", () => {
|
||||
matrixInfo={matrixInfo}
|
||||
onShareClick={null}
|
||||
e2eeSystem={{ kind: E2eeType.NONE }}
|
||||
media={fakeLocalMedia()}
|
||||
onLeft={(): void => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</BackgroundEffectsProvider>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
@@ -301,9 +282,7 @@ describe("ActiveCall", () => {
|
||||
<BrowserRouter>
|
||||
<RootElementProvider value={root}>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<BackgroundEffectsProvider
|
||||
effects={createAppBackgroundEffects(testScope())}
|
||||
>
|
||||
<ProcessorProvider>
|
||||
<TooltipProvider>
|
||||
<ActiveCall
|
||||
client={matrixRoom.client}
|
||||
@@ -313,11 +292,10 @@ describe("ActiveCall", () => {
|
||||
matrixInfo={matrixInfo}
|
||||
onShareClick={null}
|
||||
e2eeSystem={{ kind: E2eeType.NONE }}
|
||||
media={fakeLocalMedia()}
|
||||
onLeft={(): void => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</BackgroundEffectsProvider>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</RootElementProvider>
|
||||
</BrowserRouter>,
|
||||
|
||||
+6
-20
@@ -50,7 +50,6 @@ import { Grid, type TileProps } from "../grid/Grid";
|
||||
import { SpotlightTile } from "../tile/SpotlightTile";
|
||||
import {
|
||||
type EncryptionSystem,
|
||||
type LocalMedia,
|
||||
type MatrixRTCMode,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
@@ -110,7 +109,7 @@ import { useBehavior } from "../useBehavior.ts";
|
||||
import { useValueBehavior } from "../useValueBehavior.ts";
|
||||
import { Toast } from "../Toast.tsx";
|
||||
import overlayStyles from "../Overlay.module.css";
|
||||
import { useBackgroundEffectsState$ } from "../tracks/BackgroundEffectsContext";
|
||||
import { useTrackProcessorState$ } from "../tracks/TrackProcessorContext.tsx";
|
||||
import { type Layout } from "../state/layout-types.ts";
|
||||
import { ObservableScope } from "../state/ObservableScope.ts";
|
||||
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
|
||||
@@ -134,8 +133,6 @@ export interface ActiveCallProps extends Omit<
|
||||
"vm" | "footerVm" | "developerSettingsVm"
|
||||
> {
|
||||
e2eeSystem: EncryptionSystem;
|
||||
/** The tracks the lobby opened, for the join to publish. */
|
||||
media: LocalMedia;
|
||||
// TODO refactor those reasons into an enum
|
||||
onLeft: (
|
||||
reason: "user" | "timeout" | "decline" | "allOthersLeft" | "error",
|
||||
@@ -153,7 +150,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const urlParams = useUrlParams();
|
||||
const hostBridge = useHostBridge();
|
||||
const mediaDevices = useMediaDevices();
|
||||
const backgroundEffectsState$ = useBackgroundEffectsState$();
|
||||
const trackProcessorState$ = useTrackProcessorState$();
|
||||
// The element we have to draw the call in: the page, or the container a host
|
||||
// gave us. Its size, not the window's, decides how the call is laid out.
|
||||
const rootElement = useRootElement();
|
||||
@@ -173,16 +170,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
);
|
||||
const rtcParticipation = slot.join({
|
||||
...rtcParticipationOptions(urlParams),
|
||||
// Without a lobby nothing was captured and the join captures itself
|
||||
publish:
|
||||
props.media.requests.length > 0
|
||||
? props.media
|
||||
: initialPublishRequests(
|
||||
props.muteStates,
|
||||
mediaDevices,
|
||||
capture,
|
||||
backgroundEffectsState$.value.processor,
|
||||
),
|
||||
publish: initialPublishRequests(props.muteStates, mediaDevices, capture),
|
||||
});
|
||||
|
||||
const vm = createCallViewModel$(
|
||||
@@ -196,7 +184,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
encryptionSystem: props.e2eeSystem,
|
||||
capture,
|
||||
videoProcessor$: scope.behavior(
|
||||
backgroundEffectsState$.pipe(map((state) => state.processor)),
|
||||
trackProcessorState$.pipe(map((state) => state.processor)),
|
||||
),
|
||||
hostBridge,
|
||||
autoLeaveWhenOthersLeft,
|
||||
@@ -226,9 +214,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
urlParams,
|
||||
hostBridge,
|
||||
mediaDevices,
|
||||
backgroundEffectsState$,
|
||||
trackProcessorState$,
|
||||
props.client,
|
||||
props.media,
|
||||
rootElement,
|
||||
]);
|
||||
|
||||
@@ -241,7 +228,6 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
vm,
|
||||
props.muteStates,
|
||||
mediaDevices,
|
||||
backgroundEffectsState$.pipe(map(({ settling }) => settling ?? false)),
|
||||
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
|
||||
{ showControls: urlParams.showControls, header: urlParams.header },
|
||||
);
|
||||
@@ -259,7 +245,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
props.onLeft,
|
||||
urlParams,
|
||||
mediaDevices,
|
||||
backgroundEffectsState$,
|
||||
trackProcessorState$,
|
||||
props.client,
|
||||
vm,
|
||||
]);
|
||||
|
||||
@@ -10,41 +10,23 @@ import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { type MatrixClient, type RoomSummary } from "matrix-js-sdk";
|
||||
import {
|
||||
type Behavior,
|
||||
constant,
|
||||
type LocalMedia,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import { KnockLobbyView } from "./KnockLobbyView";
|
||||
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import { type ProcessorState } from "../tracks/TrackProcessorContext";
|
||||
import { mockMediaDevices } from "../utils/test";
|
||||
|
||||
// One instance each: the lobby keys effects on them
|
||||
const stable = vi.hoisted(() => ({
|
||||
processorState$: undefined as unknown,
|
||||
media: undefined as unknown,
|
||||
vi.mock("@livekit/components-react", () => ({
|
||||
usePreviewTracks: (): unknown[] => [],
|
||||
}));
|
||||
vi.mock("../tracks/BackgroundEffectsContext", () => ({
|
||||
useBackgroundEffectsState$: (): Behavior<BackgroundEffectsState> =>
|
||||
(stable.processorState$ ??= constant({
|
||||
supported: false,
|
||||
processor: undefined,
|
||||
})) as Behavior<BackgroundEffectsState>,
|
||||
}));
|
||||
// Media that opens no device: the view is what is under test
|
||||
vi.mock("../useLocalMedia", () => ({
|
||||
useLocalMedia: (): LocalMedia =>
|
||||
(stable.media ??= {
|
||||
requests: [],
|
||||
tracks$: constant([]),
|
||||
capture: vi.fn(async () => Promise.resolve([])),
|
||||
release: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
takeTracks: () => new Map(),
|
||||
}) as LocalMedia,
|
||||
|
||||
vi.mock("../tracks/TrackProcessorContext", () => ({
|
||||
useTrackProcessor: (): ProcessorState => ({
|
||||
supported: false,
|
||||
processor: undefined,
|
||||
}),
|
||||
useTrackProcessorSync: (): void => {},
|
||||
}));
|
||||
|
||||
vi.mock("react-use-measure", () => ({
|
||||
|
||||
@@ -13,7 +13,6 @@ import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { useMuteStates } from "../state/useMuteStates";
|
||||
import { useLocalMedia } from "../useLocalMedia";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
@@ -50,7 +49,6 @@ export const KnockLobbyView: FC<Props> = ({
|
||||
}): ReactNode => {
|
||||
const { t } = useTranslation();
|
||||
const muteStates = useMuteStates();
|
||||
const media = useLocalMedia();
|
||||
|
||||
if (muteStates === null) return null;
|
||||
|
||||
@@ -87,7 +85,6 @@ export const KnockLobbyView: FC<Props> = ({
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={hideHeader}
|
||||
participantCount={null}
|
||||
media={media}
|
||||
muteStates={muteStates}
|
||||
onShareClick={null}
|
||||
/>
|
||||
|
||||
+10
-26
@@ -12,11 +12,6 @@ import { of } from "rxjs";
|
||||
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import {
|
||||
type Behavior,
|
||||
constant,
|
||||
type LocalMedia,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { axe } from "vitest-axe";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
@@ -33,7 +28,7 @@ import {
|
||||
} from "../utils/test";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import { type ProcessorState } from "../tracks/TrackProcessorContext";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import lobbyStyles from "./LobbyView.module.css";
|
||||
import headerStyles from "../Header.module.css";
|
||||
@@ -42,27 +37,17 @@ import { AppBar } from "../AppBar";
|
||||
// Somewhere to go home to, so that the lobby offers the way back
|
||||
const leaveToHome = vi.fn();
|
||||
|
||||
// One instance: the view keys an effect on it
|
||||
const processorState$ = vi.hoisted(() => ({ current: undefined as unknown }));
|
||||
vi.mock("../tracks/BackgroundEffectsContext", () => ({
|
||||
useBackgroundEffectsState$: (): Behavior<BackgroundEffectsState> =>
|
||||
(processorState$.current ??= constant({
|
||||
supported: false,
|
||||
processor: undefined,
|
||||
})) as Behavior<BackgroundEffectsState>,
|
||||
vi.mock("@livekit/components-react", () => ({
|
||||
usePreviewTracks: (): unknown[] => [],
|
||||
}));
|
||||
|
||||
// Media that opens no device: the view is what is under test
|
||||
function fakeLocalMedia(): LocalMedia {
|
||||
return {
|
||||
requests: [],
|
||||
tracks$: constant([]),
|
||||
capture: vi.fn(async () => Promise.resolve([])),
|
||||
release: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
takeTracks: () => new Map(),
|
||||
};
|
||||
}
|
||||
vi.mock("../tracks/TrackProcessorContext", () => ({
|
||||
useTrackProcessor: (): ProcessorState => ({
|
||||
supported: false,
|
||||
processor: undefined,
|
||||
}),
|
||||
useTrackProcessorSync: (): void => {},
|
||||
}));
|
||||
|
||||
vi.mock("react-use-measure", () => ({
|
||||
default: (): [() => void, object] => [(): void => {}, {}],
|
||||
@@ -110,7 +95,6 @@ function renderLobbyView(
|
||||
<LobbyView
|
||||
client={mockClient}
|
||||
matrixInfo={matrixInfo}
|
||||
media={fakeLocalMedia()}
|
||||
muteStates={muteStates}
|
||||
onEnter={() => {}}
|
||||
confineToRoom={false}
|
||||
|
||||
+72
-47
@@ -5,18 +5,26 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, useCallback, useState, type JSX, useEffect } from "react";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type JSX,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import classNames from "classnames";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { usePreviewTracks } from "@livekit/components-react";
|
||||
import {
|
||||
constant,
|
||||
type LocalMedia,
|
||||
type LocalVideoMediaTrack,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { map } from "rxjs";
|
||||
type CreateLocalTracksOptions,
|
||||
type LocalVideoTrack,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { convertToLivekitProcessor } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import inCallStyles from "./InCallView.module.css";
|
||||
import styles from "./LobbyView.module.css";
|
||||
@@ -31,26 +39,22 @@ import { LeaveToHomeLink } from "../button/LeaveToHomeLink";
|
||||
import { useLeaveToHome } from "../LeaveToHomeContext";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { ObservableScope } from "../state/ObservableScope";
|
||||
import { useBackgroundEffectsState$ } from "../tracks/BackgroundEffectsContext";
|
||||
import { useInitial } from "../useInitial";
|
||||
import {
|
||||
useTrackProcessor,
|
||||
useTrackProcessorSync,
|
||||
} from "../tracks/TrackProcessorContext";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { CallFooter, type FooterSnapshot } from "../components/CallFooter";
|
||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
import { createLobbyFooterViewModel } from "../components/CallFooterViewModel";
|
||||
import { type ViewModel } from "../state/ViewModel";
|
||||
import { useAppBarPrimaryButtonIconKind } from "../AppBar";
|
||||
import {
|
||||
createLobbyMediaViewModel,
|
||||
type LobbyMediaViewModel,
|
||||
} from "../state/LobbyMediaViewModel";
|
||||
import { captureSettings } from "./InCallView";
|
||||
|
||||
const noVideoTrack = constant<LocalVideoMediaTrack | null>(null);
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
matrixInfo: MatrixInfo;
|
||||
/** The media to open the devices on; the join takes it over. */
|
||||
media: LocalMedia;
|
||||
muteStates: MuteStates;
|
||||
onEnter: () => void;
|
||||
enterLabel?: JSX.Element | string;
|
||||
@@ -64,7 +68,6 @@ interface Props {
|
||||
export const LobbyView: FC<Props> = ({
|
||||
client,
|
||||
matrixInfo,
|
||||
media,
|
||||
muteStates,
|
||||
onEnter,
|
||||
enterLabel,
|
||||
@@ -84,6 +87,7 @@ export const LobbyView: FC<Props> = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
useAppBarPrimaryButtonIconKind("back");
|
||||
const audioEnabled = useBehavior(muteStates.audio.enabled$);
|
||||
const videoEnabled = useBehavior(muteStates.video.enabled$);
|
||||
const toggleAudio = useBehavior(muteStates.audio.toggle$);
|
||||
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
||||
@@ -122,28 +126,55 @@ export const LobbyView: FC<Props> = ({
|
||||
|
||||
const devices = useMediaDevices();
|
||||
const videoInputId = useBehavior(devices.videoInput.selected$)?.id;
|
||||
const backgroundEffectsState$ = useBackgroundEffectsState$();
|
||||
|
||||
const [mediaVm, setMediaVm] = useState<LobbyMediaViewModel | null>(null);
|
||||
useEffect(() => {
|
||||
const mediaScope = new ObservableScope();
|
||||
setMediaVm(
|
||||
createLobbyMediaViewModel(
|
||||
mediaScope,
|
||||
media,
|
||||
muteStates,
|
||||
devices,
|
||||
captureSettings(),
|
||||
mediaScope.behavior(
|
||||
backgroundEffectsState$.pipe(map((state) => state.processor)),
|
||||
),
|
||||
),
|
||||
);
|
||||
return (): void => {
|
||||
mediaScope.end();
|
||||
};
|
||||
}, [media, muteStates, devices, backgroundEffectsState$]);
|
||||
const videoTrack = useBehavior(mediaVm?.videoTrack$ ?? noVideoTrack);
|
||||
// Capture the audio options as they were when we first mounted, because
|
||||
// we're not doing anything with the audio anyway so we don't need to
|
||||
// re-open the devices when they change (see below).
|
||||
const initialAudioOptions = useInitial(
|
||||
() =>
|
||||
audioEnabled && {
|
||||
deviceId: getValue(devices.audioInput.selected$)?.id,
|
||||
},
|
||||
);
|
||||
|
||||
const { processor } = useTrackProcessor();
|
||||
|
||||
const initialProcessor = useInitial(() => processor);
|
||||
const localTrackOptions = useMemo<CreateLocalTracksOptions>(
|
||||
() => ({
|
||||
// The only reason we request audio here is to get the audio permission
|
||||
// request over with at the same time. But changing the audio settings
|
||||
// shouldn't cause this hook to recreate the track, which is why we
|
||||
// reference the initial values here.
|
||||
// We also pass in a clone because livekit mutates the object passed in,
|
||||
// which would cause the devices to be re-opened on the next render.
|
||||
audio: Object.assign({}, initialAudioOptions),
|
||||
video: videoEnabled && {
|
||||
deviceId: videoInputId,
|
||||
processor:
|
||||
initialProcessor && convertToLivekitProcessor(initialProcessor),
|
||||
},
|
||||
}),
|
||||
[initialAudioOptions, videoEnabled, videoInputId, initialProcessor],
|
||||
);
|
||||
|
||||
const onError = useCallback(
|
||||
(error: Error) => {
|
||||
logger.error("Error while creating preview Tracks:", error);
|
||||
muteStates.audio.setEnabled$.value?.(false);
|
||||
muteStates.video.setEnabled$.value?.(false);
|
||||
},
|
||||
[muteStates],
|
||||
);
|
||||
|
||||
const tracks = usePreviewTracks(localTrackOptions, onError);
|
||||
|
||||
const videoTrack = useMemo(
|
||||
() =>
|
||||
(tracks?.find((t) => t.kind === Track.Kind.Video) ??
|
||||
null) as LocalVideoTrack | null,
|
||||
[tracks],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoTrack && videoInputId === undefined) {
|
||||
@@ -154,6 +185,8 @@ export const LobbyView: FC<Props> = ({
|
||||
}
|
||||
}, [devices, videoInputId, videoTrack]);
|
||||
|
||||
useTrackProcessorSync(videoTrack);
|
||||
|
||||
const [footerVm, setFooterVm] = useState<ViewModel<FooterSnapshot> | null>(
|
||||
null,
|
||||
);
|
||||
@@ -164,7 +197,6 @@ export const LobbyView: FC<Props> = ({
|
||||
footerScope,
|
||||
muteStates,
|
||||
devices,
|
||||
backgroundEffectsState$.pipe(map(({ settling }) => settling ?? false)),
|
||||
openSettings,
|
||||
hangup,
|
||||
// Logo and header are connected: only show the logo in SPA with header.
|
||||
@@ -174,14 +206,7 @@ export const LobbyView: FC<Props> = ({
|
||||
return (): void => {
|
||||
footerScope.end();
|
||||
};
|
||||
}, [
|
||||
devices,
|
||||
backgroundEffectsState$,
|
||||
hangup,
|
||||
hideHeader,
|
||||
muteStates,
|
||||
openSettings,
|
||||
]);
|
||||
}, [devices, hangup, hideHeader, muteStates, openSettings]);
|
||||
|
||||
// TODO: Unify this component with InCallView, so we can get slick joining
|
||||
// animations and don't have to feel bad about reusing its CSS
|
||||
|
||||
+15
-11
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { useEffect, useMemo, useRef, type FC, type ReactNode } from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { constant, type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
import { facingModeFromLocalTrack, type LocalVideoTrack } from "livekit-client";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -15,9 +15,6 @@ import { TileAvatar } from "../tile/TileAvatar";
|
||||
import styles from "./VideoPreview.module.css";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import videoPlaceholder from "../graphics/video-placeholder.gif";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
const noFacingMode = constant(undefined);
|
||||
|
||||
export type MatrixInfo = {
|
||||
userId: string;
|
||||
@@ -33,7 +30,7 @@ export type MatrixInfo = {
|
||||
interface Props {
|
||||
matrixInfo: MatrixInfo;
|
||||
videoEnabled: boolean;
|
||||
videoTrack: LocalVideoMediaTrack | null;
|
||||
videoTrack: LocalVideoTrack | null;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -49,12 +46,14 @@ export const VideoPreview: FC<Props> = ({
|
||||
const videoEl = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = videoEl.current;
|
||||
if (!element || !videoTrack) return;
|
||||
videoTrack.attach(element);
|
||||
return (): void => videoTrack.detach(element);
|
||||
// Effect to connect the videoTrack with the video element.
|
||||
if (videoEl.current) {
|
||||
videoTrack?.attach(videoEl.current);
|
||||
}
|
||||
return (): void => {
|
||||
videoTrack?.detach();
|
||||
};
|
||||
}, [videoTrack]);
|
||||
const facingMode = useBehavior(videoTrack?.facingMode$ ?? noFacingMode);
|
||||
|
||||
const cameraIsStarting = useMemo(
|
||||
() => videoEnabled && !videoTrack,
|
||||
@@ -64,7 +63,12 @@ export const VideoPreview: FC<Props> = ({
|
||||
return (
|
||||
<div className={classNames(styles.preview)} ref={previewRef}>
|
||||
<video
|
||||
className={facingMode === "user" ? styles.mirror : undefined}
|
||||
className={
|
||||
videoTrack &&
|
||||
facingModeFromLocalTrack(videoTrack).facingMode === "user"
|
||||
? styles.mirror
|
||||
: undefined
|
||||
}
|
||||
ref={videoEl}
|
||||
muted
|
||||
playsInline
|
||||
|
||||
@@ -1,93 +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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import { SettingsModal } from "./SettingsModal";
|
||||
import { backgroundEffect } from "./settings";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import { mockMediaDevices } from "../utils/test";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
|
||||
vi.mock("../tracks/BackgroundEffectsContext", () => ({
|
||||
useBackgroundEffects: (): BackgroundEffectsState => ({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Behaviors, as the device menus read them.
|
||||
const noDevices = {
|
||||
available$: constant(new Map()),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
};
|
||||
|
||||
function renderVideoTab(): void {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<MediaDevicesContext
|
||||
value={mockMediaDevices({
|
||||
requestDeviceNames: vi.fn(),
|
||||
audioInput: noDevices,
|
||||
audioOutput: noDevices,
|
||||
videoInput: noDevices,
|
||||
} as unknown as Partial<MediaDevices>)}
|
||||
>
|
||||
<SettingsModal
|
||||
open
|
||||
onDismiss={vi.fn()}
|
||||
tab="video"
|
||||
onTabChange={vi.fn()}
|
||||
client={
|
||||
{
|
||||
getUserId: () => "@user:example.org",
|
||||
getUser: () => null,
|
||||
} as unknown as MatrixClient
|
||||
}
|
||||
/>
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const blurControl = (): HTMLElement =>
|
||||
screen.getByRole("checkbox", { name: "Blur the background of the video" });
|
||||
|
||||
describe("SettingsModal", () => {
|
||||
afterEach(() => backgroundEffect.setValue("none"));
|
||||
|
||||
it("blur control turns blur on and off", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderVideoTab();
|
||||
expect(blurControl()).not.toBeChecked();
|
||||
|
||||
await user.click(blurControl());
|
||||
expect(backgroundEffect.getValue()).toBe("blur");
|
||||
expect(blurControl()).toBeChecked();
|
||||
|
||||
await user.click(blurControl());
|
||||
expect(backgroundEffect.getValue()).toBe("none");
|
||||
expect(blurControl()).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("blur control reflects and replaces an image background", async () => {
|
||||
const user = userEvent.setup();
|
||||
backgroundEffect.setValue("image:arc");
|
||||
renderVideoTab();
|
||||
expect(blurControl()).not.toBeChecked();
|
||||
|
||||
await user.click(blurControl());
|
||||
expect(backgroundEffect.getValue()).toBe("blur");
|
||||
});
|
||||
});
|
||||
@@ -21,17 +21,13 @@ import { useHostBridge } from "../HostBridge";
|
||||
import {
|
||||
useSetting,
|
||||
soundEffectVolume as soundEffectVolumeSetting,
|
||||
backgroundEffect as backgroundEffectSetting,
|
||||
backgroundBlur as backgroundBlurSetting,
|
||||
developerMode,
|
||||
} from "./settings";
|
||||
import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
|
||||
import { Slider } from "../Slider";
|
||||
import { DeviceSelection } from "./DeviceSelection";
|
||||
import { useBackgroundEffects } from "../tracks/BackgroundEffectsContext";
|
||||
import {
|
||||
parseEffect,
|
||||
serializeEffect,
|
||||
} from "../tracks/backgroundEffectCatalog";
|
||||
import { useTrackProcessor } from "../tracks/TrackProcessorContext";
|
||||
import {
|
||||
DeveloperSettingsTab,
|
||||
type DeveloperSettingsSnapshot,
|
||||
@@ -82,11 +78,9 @@ export const SettingsModal: FC<Props> = ({
|
||||
|
||||
// Generate a `Checkbox` input to turn blur on or off.
|
||||
const BlurCheckbox: React.FC = (): ReactNode => {
|
||||
const { supported } = useBackgroundEffects();
|
||||
const { supported } = useTrackProcessor();
|
||||
|
||||
// The camera menu's setting, of which this control sees only blur.
|
||||
const [effect, setEffect] = useSetting(backgroundEffectSetting);
|
||||
const blurActive = parseEffect(effect).kind === "blur";
|
||||
const [blurActive, setBlurActive] = useSetting(backgroundBlurSetting);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -100,12 +94,8 @@ export const SettingsModal: FC<Props> = ({
|
||||
supported ? "" : t("settings.blur_not_supported_by_browser")
|
||||
}
|
||||
type="checkbox"
|
||||
checked={blurActive}
|
||||
onChange={(b): void =>
|
||||
setEffect(
|
||||
serializeEffect({ kind: b.target.checked ? "blur" : "none" }),
|
||||
)
|
||||
}
|
||||
checked={!!blurActive}
|
||||
onChange={(b): void => setBlurActive(b.target.checked)}
|
||||
disabled={!supported}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
@@ -13,7 +13,6 @@ import { type Behavior } from "../state/Behavior";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { MatrixRTCMode } from "../config/ConfigOptions";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type EffectId } from "../tracks/backgroundEffectCatalog";
|
||||
|
||||
export class Setting<T> {
|
||||
public constructor(
|
||||
@@ -117,14 +116,7 @@ export const videoInput = new Setting<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Only read, to carry blur over for anyone who had it on.
|
||||
const backgroundBlur = new Setting<boolean>("background-blur", false);
|
||||
|
||||
/** The chosen background effect, in the form `parseEffect` reads. */
|
||||
export const backgroundEffect = new Setting<EffectId>(
|
||||
"background-effect",
|
||||
backgroundBlur.getValue() ? "blur" : "none",
|
||||
);
|
||||
export const backgroundBlur = new Setting<boolean>("background-blur", false);
|
||||
|
||||
export const showHandRaisedTimer = new Setting<boolean>(
|
||||
"hand-raised-show-timer",
|
||||
|
||||
@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { type AudioOutputOptions, MediaDevices } from "./MediaDevices";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
import { createAppBackgroundEffects } from "../tracks/BackgroundEffectsContext";
|
||||
|
||||
/**
|
||||
* The top-level state holder for the application.
|
||||
@@ -17,7 +16,6 @@ export class AppViewModel {
|
||||
this.scope,
|
||||
this.audioOutputOptions,
|
||||
);
|
||||
public readonly backgroundEffects = createAppBackgroundEffects(this.scope);
|
||||
|
||||
// TODO: Move more application logic here. The CallViewModel, at the very
|
||||
// least, ought to be accessible from this object.
|
||||
|
||||
@@ -1,240 +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 { BehaviorSubject, distinctUntilChanged, map } from "rxjs";
|
||||
import { type BackgroundProcessorWrapper } from "@livekit/track-processors";
|
||||
|
||||
import {
|
||||
type BackgroundEffects,
|
||||
createBackgroundEffects,
|
||||
type BackgroundEffectsOptions,
|
||||
type BackgroundEffectsState,
|
||||
} from "./BackgroundEffects";
|
||||
import { type AddedBackground } from "../tracks/backgroundImages";
|
||||
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
|
||||
import { flushPromises, testScope, withTestScheduler } from "../utils/test";
|
||||
|
||||
/** A pipeline that records what it is switched to. */
|
||||
function fakePipeline(): {
|
||||
pipeline: BackgroundProcessorWrapper;
|
||||
switches: unknown[];
|
||||
/** Holds the next switch until it resolves, as a picture loading does. */
|
||||
holdNext: () => () => void;
|
||||
} {
|
||||
const switches: unknown[] = [];
|
||||
let slow: Promise<void> | undefined;
|
||||
const pipeline = {
|
||||
switchTo: vi.fn(async (options: unknown) => {
|
||||
switches.push(options);
|
||||
const held = slow;
|
||||
slow = undefined;
|
||||
await held;
|
||||
}),
|
||||
} as unknown as BackgroundProcessorWrapper;
|
||||
return {
|
||||
pipeline,
|
||||
switches,
|
||||
holdNext: () => {
|
||||
let release!: () => void;
|
||||
slow = new Promise((resolve) => (release = resolve));
|
||||
return release;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** One letter per state: idle, waiting for a frame, attached. */
|
||||
function letter(state: BackgroundEffectsState): string {
|
||||
if (state.processor === undefined) return "i";
|
||||
return state.settling ? "w" : "a";
|
||||
}
|
||||
|
||||
describe("the pipeline's state", () => {
|
||||
function testState({
|
||||
effect,
|
||||
firstFrame = "",
|
||||
expected,
|
||||
}: {
|
||||
effect: string;
|
||||
firstFrame?: string;
|
||||
expected: string;
|
||||
}): void {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
const transformer = {
|
||||
onFirstFrame: undefined as (() => void) | undefined,
|
||||
};
|
||||
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,
|
||||
});
|
||||
schedule(firstFrame, { f: () => transformer.onFirstFrame?.() });
|
||||
expectObservable(
|
||||
effects.state$.pipe(map(letter), distinctUntilChanged()),
|
||||
).toBe(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it("defaults to no effect", () => testState({ effect: "n", expected: "i" }));
|
||||
|
||||
it("waits from the first effect until a frame carries it", () =>
|
||||
testState({
|
||||
effect: " nb-n-b",
|
||||
firstFrame: " --f",
|
||||
expected: " iwa",
|
||||
}));
|
||||
|
||||
it("attaches on first use and stays attached", () =>
|
||||
testState({ effect: "nb-n", firstFrame: "--f", expected: "iwa" }));
|
||||
});
|
||||
|
||||
describe("background effects", () => {
|
||||
let effect$: BehaviorSubject<string>;
|
||||
let added$: BehaviorSubject<AddedBackground[] | undefined>;
|
||||
let setEffect: (raw: string) => void;
|
||||
let fake: ReturnType<typeof fakePipeline>;
|
||||
|
||||
function build(
|
||||
options: Partial<BackgroundEffectsOptions> = {},
|
||||
): BackgroundEffects {
|
||||
return createBackgroundEffects(testScope(), {
|
||||
supported: true,
|
||||
effect$,
|
||||
setEffect,
|
||||
added$,
|
||||
pipeline: fake.pipeline,
|
||||
transformer: { onFirstFrame: undefined },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
const choose = async (raw: string): Promise<void> => {
|
||||
effect$.next(raw);
|
||||
await flushPromises();
|
||||
};
|
||||
const blur = async (on: boolean): Promise<void> =>
|
||||
choose(on ? "blur" : "none");
|
||||
|
||||
beforeEach(() => {
|
||||
effect$ = new BehaviorSubject("none");
|
||||
added$ = new BehaviorSubject<AddedBackground[] | undefined>(undefined);
|
||||
setEffect = vi.fn((raw: string) => effect$.next(raw));
|
||||
fake = fakePipeline();
|
||||
});
|
||||
|
||||
it("puts a shipped background on as that picture", async () => {
|
||||
build();
|
||||
await choose(`image:${shippedBackgrounds[0].id}`);
|
||||
expect(fake.switches).toEqual([
|
||||
{
|
||||
mode: "virtual-background",
|
||||
imagePath: shippedBackgrounds[0].imagePath,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("switches in place rather than reattaching", async () => {
|
||||
const effects = build();
|
||||
await blur(true);
|
||||
const pipeline = effects.state$.value.processor;
|
||||
await blur(false);
|
||||
await blur(true);
|
||||
|
||||
expect(effects.state$.value.processor).toBe(pipeline);
|
||||
expect(fake.switches).toEqual([
|
||||
{ mode: "background-blur", blurRadius: 15 },
|
||||
{ mode: "disabled" },
|
||||
{ mode: "background-blur", blurRadius: 15 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps switching after a switch fails", async () => {
|
||||
vi.mocked(fake.pipeline.switchTo).mockRejectedValueOnce(new Error("lost"));
|
||||
build();
|
||||
await blur(true);
|
||||
await blur(false);
|
||||
expect(fake.switches).toEqual([{ mode: "disabled" }]);
|
||||
});
|
||||
|
||||
it("switches one at a time, skipping those overtaken", async () => {
|
||||
const finished = fake.holdNext();
|
||||
build();
|
||||
await blur(true);
|
||||
await blur(false);
|
||||
await blur(true);
|
||||
await blur(false);
|
||||
expect(fake.switches).toHaveLength(1);
|
||||
|
||||
finished();
|
||||
await flushPromises();
|
||||
expect(fake.switches).toEqual([
|
||||
{ mode: "background-blur", blurRadius: 15 },
|
||||
{ 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");
|
||||
});
|
||||
});
|
||||
@@ -1,178 +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 {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
Observable,
|
||||
scan,
|
||||
} from "rxjs";
|
||||
import {
|
||||
type BackgroundProcessorWrapper,
|
||||
type SwitchBackgroundProcessorOptions,
|
||||
} from "@livekit/track-processors";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
import {
|
||||
fromLivekitProcessor,
|
||||
type VideoProcessor,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
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;
|
||||
|
||||
export interface BackgroundEffectsState {
|
||||
supported: boolean | undefined;
|
||||
processor: undefined | VideoProcessor;
|
||||
/** From the first effect chosen until a frame carrying it is drawn. */
|
||||
settling?: boolean;
|
||||
}
|
||||
|
||||
export interface BackgroundEffectsOptions {
|
||||
/** Whether this browser can run a pipeline at all. */
|
||||
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. */
|
||||
transformer: { onFirstFrame: (() => void) | undefined };
|
||||
}
|
||||
|
||||
/** The background effect pipeline, as the camera tracks and the menus see it. */
|
||||
export interface BackgroundEffects {
|
||||
readonly state$: Behavior<BackgroundEffectsState>;
|
||||
}
|
||||
|
||||
/** 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,
|
||||
): BackgroundEffects {
|
||||
// Wrapped once, so the call and the preview see one processor throughout.
|
||||
const processor = fromLivekitProcessor(pipeline);
|
||||
const choice$ = effect$.pipe(map(parseEffect));
|
||||
const wanted$ = choice$.pipe(
|
||||
map((effect) => effect.kind !== "none"),
|
||||
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);
|
||||
transformer.onFirstFrame = (): void => subscriber.next(true);
|
||||
return (): void => {
|
||||
transformer.onFirstFrame = undefined;
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const state$ = scope.behavior(
|
||||
combineLatest([wanted$, drewAFrame$]).pipe(
|
||||
scan<[boolean, boolean], BackgroundEffectsState>(
|
||||
(previous, [wanted, drewAFrame]) => {
|
||||
// Attached the first time an effect is wanted and never detached
|
||||
// after, so someone who never turns one on pays for none of it.
|
||||
const enable =
|
||||
previous.processor !== undefined || (supported && wanted);
|
||||
return {
|
||||
supported,
|
||||
processor: enable ? processor : undefined,
|
||||
settling: enable && !drewAFrame,
|
||||
};
|
||||
},
|
||||
{ supported, processor: undefined },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
distinctUntilChanged(deepCompare),
|
||||
),
|
||||
);
|
||||
// In turn: a picture's switch ends once it loads, so an earlier one could land last.
|
||||
scope.reconcile(switchOptions$, async (options) => {
|
||||
if (options === undefined) return;
|
||||
try {
|
||||
await pipeline.switchTo(options);
|
||||
} catch (e) {
|
||||
logger.warn("Failed to switch background effect", e);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
default:
|
||||
return { mode: "disabled" };
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,6 @@ import { type Layout } from "../layout-types.ts";
|
||||
import {
|
||||
mockMatrixRoomMember,
|
||||
mockMediaDevices,
|
||||
mockMuteStates,
|
||||
flushPromises,
|
||||
mockRemoteParticipant,
|
||||
withTestScheduler,
|
||||
mockRtcMembership,
|
||||
@@ -64,15 +62,7 @@ import {
|
||||
import { MatrixRTCMode } from "../../config/ConfigOptions.ts";
|
||||
import { initializeWidget } from "../../widget.ts";
|
||||
import { computeUrlParams } from "../../UrlParams.ts";
|
||||
import {
|
||||
callViewModelOptionsFromParams,
|
||||
initialPublishRequests,
|
||||
} from "./CallViewModel.ts";
|
||||
import { type Handler, MuteState } from "../MuteStates.ts";
|
||||
import {
|
||||
type RTCParticipation,
|
||||
type VideoProcessor,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { callViewModelOptionsFromParams } from "./CallViewModel.ts";
|
||||
|
||||
initializeWidget();
|
||||
|
||||
@@ -165,7 +155,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
case "grid":
|
||||
return combineLatest(
|
||||
[
|
||||
l.spotlight?.vm.media$ ?? constant(undefined),
|
||||
l.spotlight?.media$ ?? constant(undefined),
|
||||
...l.grid.map((vm) => vm.media$),
|
||||
],
|
||||
(spotlight, ...grid) => ({
|
||||
@@ -177,7 +167,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
case "spotlight-landscape":
|
||||
case "spotlight-portrait":
|
||||
return combineLatest(
|
||||
[l.spotlight.vm.media$, ...l.grid.map((vm) => vm.media$)],
|
||||
[l.spotlight.media$, ...l.grid.map((vm) => vm.media$)],
|
||||
(spotlight, ...grid) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight.map((vm) => vm.id),
|
||||
@@ -186,7 +176,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
);
|
||||
case "spotlight-expanded":
|
||||
return combineLatest(
|
||||
[l.spotlight.media$, l.pip?.vm.media$ ?? constant(undefined)],
|
||||
[l.spotlight.media$, l.pip?.media$ ?? constant(undefined)],
|
||||
(spotlight, pip) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight.map((vm) => vm.id),
|
||||
@@ -195,7 +185,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
);
|
||||
case "one-on-one-desktop":
|
||||
return combineLatest(
|
||||
[l.spotlight.media$, l.pip.vm.media$],
|
||||
[l.spotlight.media$, l.pip.media$],
|
||||
(spotlight, pip) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight.id,
|
||||
@@ -206,8 +196,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
return combineLatest(
|
||||
[
|
||||
l.spotlight.media$,
|
||||
l.pip?.vm.media$ ?? constant(undefined),
|
||||
l.pip?.size$ ?? constant(undefined),
|
||||
l.pip?.media$ ?? constant(undefined),
|
||||
l.pipSize$,
|
||||
],
|
||||
(spotlight, pip, pipSize) => ({
|
||||
type: l.type,
|
||||
@@ -516,7 +506,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: undefined,
|
||||
pipSize: "lg",
|
||||
},
|
||||
b: {
|
||||
type: "one-on-one-mobile",
|
||||
@@ -554,7 +544,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: undefined,
|
||||
pipSize: "lg",
|
||||
},
|
||||
});
|
||||
// It wouldn't be clear whether Alice or Bob is the remote video tile,
|
||||
@@ -602,7 +592,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: undefined,
|
||||
pipSize: "sm",
|
||||
},
|
||||
b: {
|
||||
type: "spotlight-landscape",
|
||||
@@ -801,7 +791,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: undefined,
|
||||
pipSize: "lg",
|
||||
},
|
||||
b: {
|
||||
// In a larger window, expect the one-on-one desktop layout
|
||||
@@ -1674,50 +1664,3 @@ describe("callViewModelOptionsFromParams", () => {
|
||||
expect(callViewModelOptionsFromParams(params).hideScreensharing).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a camera with a video processor", () => {
|
||||
const withCallViewModel = withCallViewModelInMode(MatrixRTCMode.Matrix_2_0);
|
||||
const processor = { name: "effect" } as VideoProcessor;
|
||||
|
||||
/** The handler the view model gives the camera's mute switch. */
|
||||
const cameraHandler = (setHandler: {
|
||||
mock: { calls: [Handler][] };
|
||||
}): Handler => setHandler.mock.calls[1][0];
|
||||
|
||||
it("starts each camera track with the processor", () => {
|
||||
const muteStates = mockMuteStates();
|
||||
vi.spyOn(muteStates.video.enabled$, "value", "get").mockReturnValue(true);
|
||||
expect(
|
||||
initialPublishRequests(
|
||||
muteStates,
|
||||
mockMediaDevices({}),
|
||||
{},
|
||||
processor,
|
||||
).find((request) => request.source === "camera"),
|
||||
).toMatchObject({ processor });
|
||||
});
|
||||
|
||||
// The SDK builds the processor on the camera's restart, before the room
|
||||
// sees a frame, so the track is unmuted rather than published anew
|
||||
it("turns on a camera turned off before the processor was chosen", async () => {
|
||||
const setHandler = vi.spyOn(MuteState.prototype, "setHandler");
|
||||
onTestFinished(() => setHandler.mockRestore());
|
||||
const videoProcessor$ = new BehaviorSubject<VideoProcessor | undefined>(
|
||||
undefined,
|
||||
);
|
||||
let participation: RTCParticipation | undefined;
|
||||
withCallViewModel(
|
||||
{},
|
||||
(_vm, _session, { rtcParticipation }) => {
|
||||
participation = rtcParticipation;
|
||||
},
|
||||
{ videoProcessor$ },
|
||||
);
|
||||
videoProcessor$.next(processor);
|
||||
await cameraHandler(setHandler)(true);
|
||||
await flushPromises();
|
||||
|
||||
expect(participation!.unpublish).not.toHaveBeenCalled();
|
||||
expect(participation!.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type EncryptionSystem,
|
||||
generateItems,
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalMediaTrack,
|
||||
type LocalRTCMember,
|
||||
type LocalVideoMediaTrack,
|
||||
type RTCParticipation,
|
||||
@@ -77,19 +78,14 @@ import {
|
||||
type ReactionInfo,
|
||||
type ReactionOption,
|
||||
} from "../../reactions";
|
||||
import { shallowEquals as arrayShallowEquals } from "../../utils/array";
|
||||
import { type MediaDevices } from "../MediaDevices";
|
||||
import { type MuteStates } from "../MuteStates";
|
||||
import {
|
||||
followDevice,
|
||||
followMuteState,
|
||||
followProcessor,
|
||||
type TrackSupply,
|
||||
} from "../localMediaControls";
|
||||
import { shallowEquals as shallowArrayEquals } from "../../utils/array";
|
||||
import { shallowEquals as shallowObjectEquals } from "../../utils/object";
|
||||
import { type MediaDevice, type MediaDevices } from "../MediaDevices";
|
||||
import { type Handler, type MuteStates } from "../MuteStates";
|
||||
import { HeaderStyle, type UrlParams } from "../../UrlParams";
|
||||
import { type HostBridge, nullHostBridge } from "../../HostBridge";
|
||||
import {
|
||||
layoutShallowEquals,
|
||||
type Alignment,
|
||||
type Layout,
|
||||
type LayoutMedia,
|
||||
type WindowMode,
|
||||
@@ -130,7 +126,6 @@ import {
|
||||
type RingingMediaViewModel,
|
||||
} from "../media/RingingMediaViewModel.ts";
|
||||
import { type GridTileViewModel } from "../TileViewModel.ts";
|
||||
import { createFloatingTile } from "../FloatingTile.ts";
|
||||
|
||||
/** How a call captures and encodes the tracks it publishes. */
|
||||
export interface CaptureSettings {
|
||||
@@ -140,16 +135,14 @@ export interface CaptureSettings {
|
||||
}
|
||||
|
||||
/**
|
||||
* What to publish for a microphone or camera: the device the user picked, the
|
||||
* host's capture settings and, for the camera, the video processor, so that
|
||||
* the track starts with it rather than letting the room through while it is
|
||||
* built.
|
||||
* What to publish for a microphone or camera: the device the user picked and
|
||||
* the host's capture settings. The video processor is not in here; the call
|
||||
* attaches it to the camera track once that exists.
|
||||
*/
|
||||
export function publishRequest(
|
||||
source: "microphone" | "camera",
|
||||
mediaDevices: MediaDevices,
|
||||
capture: CaptureSettings,
|
||||
processor?: VideoProcessor,
|
||||
): PublishRequest {
|
||||
return source === "microphone"
|
||||
? {
|
||||
@@ -161,7 +154,6 @@ export function publishRequest(
|
||||
source,
|
||||
deviceId: mediaDevices.videoInput.selected$.value?.id,
|
||||
capture: capture.camera,
|
||||
...(processor && { processor }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,13 +162,12 @@ export function initialPublishRequests(
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
capture: CaptureSettings,
|
||||
processor?: VideoProcessor,
|
||||
): PublishRequest[] {
|
||||
const requests: PublishRequest[] = [];
|
||||
if (muteStates.audio.enabled$.value)
|
||||
requests.push(publishRequest("microphone", mediaDevices, capture));
|
||||
if (muteStates.video.enabled$.value)
|
||||
requests.push(publishRequest("camera", mediaDevices, capture, processor));
|
||||
requests.push(publishRequest("camera", mediaDevices, capture));
|
||||
return requests;
|
||||
}
|
||||
|
||||
@@ -512,41 +503,72 @@ export function createCallViewModel$(
|
||||
"screenShare",
|
||||
) as Behavior<LocalVideoMediaTrack | undefined>;
|
||||
|
||||
const supply = (source: "microphone" | "camera"): TrackSupply => ({
|
||||
publish: async () =>
|
||||
rtcParticipation.publish(
|
||||
publishRequest(
|
||||
source,
|
||||
mediaDevices,
|
||||
capture,
|
||||
source === "camera" ? videoProcessor$.value : undefined,
|
||||
),
|
||||
),
|
||||
// The mute switches drive our tracks, which report back what the device
|
||||
// allowed, so a denied permission flips the switch back. A source is muted
|
||||
// and unmuted while its track exists and published when it does not.
|
||||
const followMuteState = (
|
||||
muteState: {
|
||||
setHandler: (handler: Handler) => void;
|
||||
enabled$: Behavior<boolean>;
|
||||
},
|
||||
track$: Behavior<LocalMediaTrack | undefined>,
|
||||
source: "microphone" | "camera",
|
||||
): void => {
|
||||
// What the switch asks for, ahead of what it shows: the switch follows
|
||||
// only once the handler has resolved
|
||||
let desired = muteState.enabled$.value;
|
||||
muteState.setHandler(async (enabled) => {
|
||||
desired = enabled;
|
||||
const track = track$.value;
|
||||
if (track !== undefined) {
|
||||
desired = await track.setEnabled(enabled);
|
||||
return desired;
|
||||
}
|
||||
if (!enabled) return false;
|
||||
try {
|
||||
await rtcParticipation.publish(
|
||||
publishRequest(source, mediaDevices, capture),
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`Failed to publish the ${source}`, e);
|
||||
desired = false;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
// A track that arrives after its switch was flipped off catches up with it
|
||||
track$.pipe(scope.bind()).subscribe((track) => {
|
||||
if (track !== undefined && !desired)
|
||||
track.setEnabled(false).catch((e) => {
|
||||
logger.error(`Failed to mute the ${source}`, e);
|
||||
});
|
||||
});
|
||||
};
|
||||
followMuteState(muteStates.audio, microphone$, "microphone");
|
||||
followMuteState(muteStates.video, camera$, "camera");
|
||||
scope.onEnd(() => {
|
||||
muteStates.audio.unsetHandler();
|
||||
muteStates.video.unsetHandler();
|
||||
});
|
||||
followMuteState(
|
||||
scope,
|
||||
muteStates.audio,
|
||||
microphone$,
|
||||
supply("microphone"),
|
||||
"microphone",
|
||||
logger,
|
||||
);
|
||||
followMuteState(
|
||||
scope,
|
||||
muteStates.video,
|
||||
camera$,
|
||||
supply("camera"),
|
||||
"camera",
|
||||
logger,
|
||||
);
|
||||
followDevice(
|
||||
scope,
|
||||
microphone$,
|
||||
mediaDevices.audioInput,
|
||||
"microphone",
|
||||
logger,
|
||||
);
|
||||
followDevice(scope, camera$, mediaDevices.videoInput, "camera", logger);
|
||||
|
||||
// The device the user picked follows onto the track as it is made and as the
|
||||
// choice changes; the SDK does not watch the settings itself
|
||||
const followDevice = (
|
||||
track$: Behavior<LocalMediaTrack | undefined>,
|
||||
device: MediaDevice<unknown, { id: string }>,
|
||||
source: "microphone" | "camera",
|
||||
): void => {
|
||||
combineLatest([track$, device.selected$])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([track, selected]) => {
|
||||
if (track === undefined || selected === undefined) return;
|
||||
track.setDevice(selected.id).catch((e) => {
|
||||
logger.error(`Failed to switch the ${source} device`, e);
|
||||
});
|
||||
});
|
||||
};
|
||||
followDevice(microphone$, mediaDevices.audioInput, "microphone");
|
||||
followDevice(camera$, mediaDevices.videoInput, "camera");
|
||||
|
||||
// The output device follows the selection onto the client the same way. A
|
||||
// host that routes audio itself picks the output outside the browser, so the
|
||||
@@ -561,7 +583,15 @@ export function createCallViewModel$(
|
||||
});
|
||||
});
|
||||
|
||||
followProcessor(scope, camera$, videoProcessor$, logger);
|
||||
// Attached once the camera track exists rather than carried in the publish
|
||||
// request, so that a new track and a change of processor take the same path
|
||||
combineLatest([camera$, videoProcessor$])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([camera, processor]) => {
|
||||
camera?.setProcessor(processor).catch((e) => {
|
||||
logger.error("Failed to set the video processor", e);
|
||||
});
|
||||
});
|
||||
|
||||
const members$ = scope.behavior<(LocalRTCMember | RemoteRTCMember)[]>(
|
||||
combineLatest(
|
||||
@@ -800,7 +830,7 @@ export function createCallViewModel$(
|
||||
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(arrayShallowEquals),
|
||||
distinctUntilChanged(shallowArrayEquals),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -832,18 +862,13 @@ export function createCallViewModel$(
|
||||
[ringingMedia$, screenShares$, spotlightSpeaker$, localUserMediaForPip$],
|
||||
(ringing, screenShares, speaker, localPip) =>
|
||||
computeSpotlight({ ringing, screenShares, speaker, localPip }),
|
||||
).pipe(
|
||||
distinctUntilChanged(
|
||||
(a, b) =>
|
||||
arrayShallowEquals(a.spotlight, b.spotlight) && a.pip === b.pip,
|
||||
),
|
||||
),
|
||||
).pipe(distinctUntilChanged(shallowObjectEquals)),
|
||||
);
|
||||
|
||||
const spotlight$ = scope.behavior<MediaViewModel[]>(
|
||||
spotlightAndPip$.pipe(
|
||||
map(({ spotlight }) => spotlight),
|
||||
distinctUntilChanged<MediaViewModel[]>(arrayShallowEquals),
|
||||
distinctUntilChanged<MediaViewModel[]>(shallowArrayEquals),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1004,7 +1029,7 @@ export function createCallViewModel$(
|
||||
desktop: platform === "desktop",
|
||||
}),
|
||||
),
|
||||
distinctUntilChanged(layoutShallowEquals),
|
||||
distinctUntilChanged(shallowObjectEquals<LayoutMedia>),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1196,28 +1221,32 @@ export function createCallViewModel$(
|
||||
);
|
||||
|
||||
/**
|
||||
* State of the floating spotlight tile, if present.
|
||||
* The alignment of the floating spotlight tile, if present.
|
||||
*/
|
||||
const floatingSpotlightTile = createFloatingTile(scope, {
|
||||
const spotlightAlignment$ = new BehaviorSubject<Alignment>({
|
||||
inline: "end",
|
||||
block: "end",
|
||||
});
|
||||
/**
|
||||
* State of the small picture-in-picture tile, if present, when in landscape.
|
||||
* The size of the small picture-in-picture tile, if present, when in portrait.
|
||||
*/
|
||||
const landscapePipTile = createFloatingTile(scope, {
|
||||
const portraitPipSize$ = scope.behavior(
|
||||
showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))),
|
||||
);
|
||||
/**
|
||||
* The alignment of the small picture-in-picture tile, if present, when in portrait.
|
||||
*/
|
||||
const portraitPipAlignment$ = new BehaviorSubject<Alignment>({
|
||||
inline: "end",
|
||||
block: "end",
|
||||
});
|
||||
/**
|
||||
* The alignment of the small picture-in-picture tile, if present, when in landscape.
|
||||
*/
|
||||
const landscapePipAlignment$ = new BehaviorSubject<Alignment>({
|
||||
inline: "end",
|
||||
block: "start",
|
||||
});
|
||||
/**
|
||||
* State of the small picture-in-picture tile, if present, when in portrait.
|
||||
*/
|
||||
const portraitPipTile = {
|
||||
...createFloatingTile(scope, { inline: "end", block: "end" }),
|
||||
size$: scope.behavior(
|
||||
showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))),
|
||||
),
|
||||
};
|
||||
|
||||
// There is a cyclical dependency here: the layout algorithms want to know
|
||||
// which tiles are on screen, but to know which tiles are on screen we have to
|
||||
@@ -1247,7 +1276,7 @@ export function createCallViewModel$(
|
||||
case "spotlight-portrait":
|
||||
[layout, newTiles] = gridLikeLayout(
|
||||
media,
|
||||
floatingSpotlightTile,
|
||||
spotlightAlignment$,
|
||||
visibleTiles,
|
||||
setVisibleTiles,
|
||||
prevTiles,
|
||||
@@ -1257,25 +1286,26 @@ export function createCallViewModel$(
|
||||
case "spotlight-expanded":
|
||||
[layout, newTiles] = spotlightExpandedLayout(
|
||||
media,
|
||||
landscapePipTile,
|
||||
landscapePipAlignment$,
|
||||
prevTiles,
|
||||
);
|
||||
break;
|
||||
case "one-on-one-desktop":
|
||||
[layout, newTiles] = oneOnOneDesktopLayout(
|
||||
media,
|
||||
landscapePipTile,
|
||||
landscapePipAlignment$,
|
||||
prevTiles,
|
||||
);
|
||||
pip = layout.pip.vm;
|
||||
pip = layout.pip;
|
||||
break;
|
||||
case "one-on-one-mobile":
|
||||
[layout, newTiles] = oneOnOneMobileLayout(
|
||||
media,
|
||||
portraitPipTile,
|
||||
portraitPipSize$,
|
||||
portraitPipAlignment$,
|
||||
prevTiles,
|
||||
);
|
||||
pip = layout.pip?.vm;
|
||||
pip = layout.pip;
|
||||
break;
|
||||
case "pip":
|
||||
[layout, newTiles] = pipLayout(media, prevTiles);
|
||||
@@ -1300,7 +1330,7 @@ export function createCallViewModel$(
|
||||
layoutInternals$.pipe(
|
||||
map(({ layout }) => layout),
|
||||
// Drop redundant layout updates before they would hit React.
|
||||
distinctUntilChanged(layoutShallowEquals),
|
||||
distinctUntilChanged(shallowObjectEquals<Layout>),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,38 +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 { test } from "vitest";
|
||||
import { withTestScheduler } from "../utils/test";
|
||||
import { createFloatingTile } from "./FloatingTile";
|
||||
|
||||
test("FloatingTile changes alignment on drag", () =>
|
||||
withTestScheduler(({ scope, expectObservable, schedule }) => {
|
||||
// Tile starts in the top left
|
||||
const { alignment$, onDrag } = createFloatingTile(scope, {
|
||||
inline: "start",
|
||||
block: "start",
|
||||
});
|
||||
// Drag to all four corners, and then repeatedly into the top left.
|
||||
schedule("-↗↘↙↖↖", {
|
||||
"↗": () =>
|
||||
onDrag({ x: 100, y: 0, xRatio: 1, yRatio: 0, endOfGesture: false }),
|
||||
"↘": () =>
|
||||
onDrag({ x: 100, y: 100, xRatio: 1, yRatio: 1, endOfGesture: false }),
|
||||
"↙": () =>
|
||||
onDrag({ x: 0, y: 100, xRatio: 0, yRatio: 1, endOfGesture: false }),
|
||||
"↖": () =>
|
||||
onDrag({ x: 0, y: 0, xRatio: 0, yRatio: 0, endOfGesture: false }),
|
||||
});
|
||||
// Alignment should change accordingly. Notably, the alignment should *not*
|
||||
// update when repeatedly dragged into the same corner, for performance.
|
||||
expectObservable(alignment$).toBe("↖↗↘↙↖-", {
|
||||
"↗": { inline: "end", block: "start" },
|
||||
"↘": { inline: "end", block: "end" },
|
||||
"↙": { inline: "start", block: "end" },
|
||||
"↖": { inline: "start", block: "start" },
|
||||
});
|
||||
}));
|
||||
@@ -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 { distinctUntilChanged, map, startWith, Subject } from "rxjs";
|
||||
|
||||
import { type Behavior } from "./Behavior";
|
||||
import { type DragCallback, type Alignment, type Drag } from "./layout-types";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
|
||||
/**
|
||||
* Represents a floating tile which may be dragged to different corners of the
|
||||
* call interface.
|
||||
*/
|
||||
export interface FloatingTile {
|
||||
alignment$: Behavior<Alignment>;
|
||||
onDrag: DragCallback;
|
||||
}
|
||||
|
||||
export function createFloatingTile(
|
||||
scope: ObservableScope,
|
||||
defaultAlignment: Alignment,
|
||||
): FloatingTile {
|
||||
const dragEvents$ = new Subject<Drag>();
|
||||
return {
|
||||
alignment$: scope.behavior(
|
||||
dragEvents$.pipe(
|
||||
map<Drag, Alignment>(({ xRatio, yRatio }) => ({
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
})),
|
||||
startWith(defaultAlignment),
|
||||
distinctUntilChanged(
|
||||
(a1, a2) => a1.inline === a2.inline && a1.block === a2.block,
|
||||
),
|
||||
),
|
||||
),
|
||||
onDrag: (drag) => dragEvents$.next(drag),
|
||||
};
|
||||
}
|
||||
@@ -5,9 +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 Layout, type LayoutMedia } from "./layout-types.ts";
|
||||
import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
type Alignment,
|
||||
type Layout,
|
||||
type LayoutMedia,
|
||||
} from "./layout-types.ts";
|
||||
import { type TileStore } from "./TileStore";
|
||||
import { type FloatingTile } from "./FloatingTile.ts";
|
||||
|
||||
export type GridLikeLayoutType =
|
||||
| "grid"
|
||||
@@ -20,7 +25,7 @@ export type GridLikeLayoutType =
|
||||
*/
|
||||
export function gridLikeLayout(
|
||||
media: LayoutMedia & { type: GridLikeLayoutType },
|
||||
floatingSpotlightTile: FloatingTile,
|
||||
spotlightAlignment$: BehaviorSubject<Alignment>,
|
||||
visibleTiles: number,
|
||||
setVisibleTiles: (value: number) => void,
|
||||
prevTiles: TileStore,
|
||||
@@ -37,11 +42,9 @@ export function gridLikeLayout(
|
||||
return [
|
||||
{
|
||||
type: media.type,
|
||||
spotlight: tiles.spotlightTile && {
|
||||
vm: tiles.spotlightTile,
|
||||
...floatingSpotlightTile,
|
||||
},
|
||||
spotlight: tiles.spotlightTile,
|
||||
grid: tiles.gridTiles,
|
||||
spotlightAlignment$,
|
||||
setVisibleTiles,
|
||||
} as Layout & { type: GridLikeLayoutType },
|
||||
tiles,
|
||||
|
||||
@@ -1,167 +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 { describe, expect, it, vi } from "vitest";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
import {
|
||||
constant,
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalMedia,
|
||||
type LocalVideoMediaTrack,
|
||||
type PublishRequest,
|
||||
type VideoProcessor,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import { createLobbyMediaViewModel } from "./LobbyMediaViewModel";
|
||||
import { type MediaDevices } from "./MediaDevices";
|
||||
import { MuteStates } from "./MuteStates";
|
||||
import { nullHostBridge } from "../HostBridge";
|
||||
import { flushPromises, mockMediaDevices, testScope } from "../utils/test";
|
||||
|
||||
describe("LobbyMediaViewModel", () => {
|
||||
it("opens what starts enabled in one capture and follows the devices", async () => {
|
||||
const { media, vm, mediaDevices } = setup();
|
||||
await flushPromises();
|
||||
|
||||
expect(media.capture).toHaveBeenCalledExactlyOnceWith([
|
||||
expect.objectContaining({ source: "microphone", deviceId: "mic" }),
|
||||
// With the processor, so that the camera starts with it
|
||||
expect.objectContaining({ source: "camera", deviceId: "cam", processor }),
|
||||
]);
|
||||
const camera = vm.videoTrack$.value!;
|
||||
expect(camera.source).toBe("camera");
|
||||
expect(camera.setDevice).toHaveBeenCalledWith("cam");
|
||||
expect(camera.setProcessor).toHaveBeenCalledWith(processor);
|
||||
mediaDevices.videoInput.selected$ satisfies unknown;
|
||||
});
|
||||
|
||||
it("closes the camera when its switch goes off and reopens it when on", async () => {
|
||||
const { media, vm, muteStates } = setup();
|
||||
await flushPromises();
|
||||
|
||||
muteStates.video.setEnabled$.value?.(false);
|
||||
await flushPromises();
|
||||
expect(media.release).toHaveBeenCalledWith("camera");
|
||||
expect(vm.videoTrack$.value).toBeNull();
|
||||
|
||||
muteStates.video.setEnabled$.value?.(true);
|
||||
await flushPromises();
|
||||
expect(media.capture).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ source: "camera" }),
|
||||
]);
|
||||
expect(vm.videoTrack$.value?.source).toBe("camera");
|
||||
});
|
||||
|
||||
it("mutes the microphone in place", async () => {
|
||||
const { media, muteStates } = setup();
|
||||
await flushPromises();
|
||||
const microphone = media.tracks$.value.find(
|
||||
(t) => t.source === "microphone",
|
||||
)!;
|
||||
|
||||
muteStates.audio.setEnabled$.value?.(false);
|
||||
await flushPromises();
|
||||
|
||||
expect(microphone.setEnabled).toHaveBeenCalledWith(false);
|
||||
expect(media.release).not.toHaveBeenCalled();
|
||||
expect(muteStates.audio.enabled$.value).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves both switches off where the devices cannot be opened", async () => {
|
||||
const { muteStates } = setup(async () =>
|
||||
Promise.reject(new Error("NotAllowedError")),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(muteStates.audio.enabled$.value).toBe(false);
|
||||
expect(muteStates.video.enabled$.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const processor = { name: "blur" } as VideoProcessor;
|
||||
|
||||
function setup(
|
||||
capture: (
|
||||
requests: PublishRequest[],
|
||||
) => Promise<(LocalAudioMediaTrack | LocalVideoMediaTrack)[]> = async (
|
||||
requests,
|
||||
) => Promise.resolve(requests.map(fakeTrack)),
|
||||
): {
|
||||
media: LocalMedia;
|
||||
vm: ReturnType<typeof createLobbyMediaViewModel>;
|
||||
muteStates: MuteStates;
|
||||
mediaDevices: MediaDevices;
|
||||
} {
|
||||
const scope = testScope();
|
||||
const tracks$ = new BehaviorSubject<
|
||||
(LocalAudioMediaTrack | LocalVideoMediaTrack)[]
|
||||
>([]);
|
||||
const media: LocalMedia = {
|
||||
requests: [],
|
||||
tracks$,
|
||||
capture: vi.fn(async (requests: PublishRequest[]) => {
|
||||
const tracks = await capture(requests);
|
||||
tracks$.next([
|
||||
...tracks$.value.filter(
|
||||
(t) => !tracks.some((n) => n.source === t.source),
|
||||
),
|
||||
...tracks,
|
||||
]);
|
||||
return tracks;
|
||||
}),
|
||||
release: vi.fn((source) =>
|
||||
tracks$.next(tracks$.value.filter((t) => t.source !== source)),
|
||||
),
|
||||
stop: vi.fn(),
|
||||
takeTracks: () => new Map(),
|
||||
};
|
||||
const mediaDevices = mockMediaDevices({
|
||||
audioInput: device("mic"),
|
||||
videoInput: device("cam"),
|
||||
} as Partial<MediaDevices>);
|
||||
const muteStates = new MuteStates(
|
||||
scope,
|
||||
mediaDevices,
|
||||
{ audioEnabled: true, videoEnabled: true },
|
||||
nullHostBridge,
|
||||
);
|
||||
const vm = createLobbyMediaViewModel(
|
||||
scope,
|
||||
media,
|
||||
muteStates,
|
||||
mediaDevices,
|
||||
{},
|
||||
constant(processor),
|
||||
);
|
||||
return { media, vm, muteStates, mediaDevices };
|
||||
}
|
||||
|
||||
function device(id: string): unknown {
|
||||
return {
|
||||
available$: of(new Map([[id, { type: "name", name: id }]])),
|
||||
selected$: new BehaviorSubject({ id }),
|
||||
select: (): void => {},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeTrack(
|
||||
request: PublishRequest,
|
||||
): LocalAudioMediaTrack | LocalVideoMediaTrack {
|
||||
const muted$ = new BehaviorSubject(false);
|
||||
return {
|
||||
source: request.source,
|
||||
kind: request.source === "microphone" ? "audio" : "video",
|
||||
id: request.source,
|
||||
muted$,
|
||||
setEnabled: vi.fn(async (enabled: boolean) => {
|
||||
muted$.next(!enabled);
|
||||
return Promise.resolve(enabled);
|
||||
}),
|
||||
setDevice: vi.fn(async () => Promise.resolve()),
|
||||
setProcessor: vi.fn(async () => Promise.resolve()),
|
||||
} as unknown as LocalAudioMediaTrack | LocalVideoMediaTrack;
|
||||
}
|
||||
@@ -1,120 +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 {
|
||||
type Behavior,
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalMedia,
|
||||
type LocalVideoMediaTrack,
|
||||
type ObservableScope,
|
||||
type PublishRequest,
|
||||
trackBySource$,
|
||||
type VideoProcessor,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { combineLatest, debounceTime, map } from "rxjs";
|
||||
|
||||
import {
|
||||
type CaptureSettings,
|
||||
publishRequest,
|
||||
} from "./CallViewModel/CallViewModel";
|
||||
import { type MediaDevices } from "./MediaDevices";
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { followDevice, followProcessor } from "./localMediaControls";
|
||||
|
||||
export interface LobbyMediaViewModel {
|
||||
/** The camera, once open, for the preview. */
|
||||
videoTrack$: Behavior<LocalVideoMediaTrack | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The local media of the lobby: the devices the mute switches ask for, opened
|
||||
* ahead of the join on the media the join is then given, so that the call
|
||||
* publishes the very tracks the user previewed.
|
||||
*/
|
||||
export function createLobbyMediaViewModel(
|
||||
scope: ObservableScope,
|
||||
media: LocalMedia,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
capture: CaptureSettings,
|
||||
videoProcessor$: Behavior<VideoProcessor | undefined>,
|
||||
): LobbyMediaViewModel {
|
||||
const logger = rootLogger.getChild("[LobbyMedia]");
|
||||
const microphone$ = trackBySource$(
|
||||
scope,
|
||||
media.tracks$,
|
||||
"microphone",
|
||||
) as Behavior<LocalAudioMediaTrack | undefined>;
|
||||
const camera$ = trackBySource$(scope, media.tracks$, "camera") as Behavior<
|
||||
LocalVideoMediaTrack | undefined
|
||||
>;
|
||||
|
||||
// One could ask why this is not just done via followMuteStates.
|
||||
// The main purpose why this code exists is to handle the native browser permission
|
||||
// prompts properly.
|
||||
// no prompt if everything is muted, shared prompt if both are requested.
|
||||
const opening = new Set<PublishRequest["source"]>();
|
||||
combineLatest([
|
||||
muteStates.audio.enabled$,
|
||||
muteStates.video.enabled$,
|
||||
microphone$,
|
||||
camera$,
|
||||
])
|
||||
.pipe(debounceTime(0), scope.bind())
|
||||
.subscribe(([audio, video, microphone, camera]) => {
|
||||
const open: PublishRequest[] = [];
|
||||
if (audio && microphone === undefined && !opening.has("microphone"))
|
||||
open.push(publishRequest("microphone", mediaDevices, capture));
|
||||
if (video && camera === undefined && !opening.has("camera"))
|
||||
open.push(
|
||||
publishRequest(
|
||||
"camera",
|
||||
mediaDevices,
|
||||
capture,
|
||||
videoProcessor$.value,
|
||||
),
|
||||
);
|
||||
if (open.length > 0) {
|
||||
for (const { source } of open) opening.add(source);
|
||||
media
|
||||
.capture(open)
|
||||
.catch((e) => {
|
||||
logger.error("Failed to open the devices", e);
|
||||
// A device that cannot be opened leaves its switch off
|
||||
for (const { source } of open)
|
||||
(source === "microphone"
|
||||
? muteStates.audio
|
||||
: muteStates.video
|
||||
).setEnabled$.value?.(false);
|
||||
})
|
||||
.finally(() => {
|
||||
for (const { source } of open) opening.delete(source);
|
||||
});
|
||||
}
|
||||
// The camera is closed rather than muted, so that its light goes off
|
||||
if (!video && camera !== undefined) media.release("camera");
|
||||
if (microphone !== undefined && microphone.muted$.value === audio)
|
||||
microphone.setEnabled(audio).catch((e) => {
|
||||
logger.error("Failed to mute the microphone", e);
|
||||
});
|
||||
});
|
||||
|
||||
followDevice(
|
||||
scope,
|
||||
microphone$,
|
||||
mediaDevices.audioInput,
|
||||
"microphone",
|
||||
logger,
|
||||
);
|
||||
followDevice(scope, camera$, mediaDevices.videoInput, "camera", logger);
|
||||
followProcessor(scope, camera$, videoProcessor$, logger);
|
||||
|
||||
return {
|
||||
videoTrack$: scope.behavior(camera$.pipe(map((camera) => camera ?? null))),
|
||||
};
|
||||
}
|
||||
@@ -6,19 +6,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
type Alignment,
|
||||
type OneOnOneDesktopLayout,
|
||||
type OneOnOneDesktopLayoutMedia,
|
||||
} from "./layout-types";
|
||||
import { type TileStore } from "./TileStore";
|
||||
import { type FloatingTile } from "./FloatingTile";
|
||||
|
||||
/**
|
||||
* Produces a one-on-one desktop layout with the given media.
|
||||
*/
|
||||
export function oneOnOneDesktopLayout(
|
||||
media: OneOnOneDesktopLayoutMedia,
|
||||
pipTile: FloatingTile,
|
||||
pipAlignment$: BehaviorSubject<Alignment>,
|
||||
prevTiles: TileStore,
|
||||
): [OneOnOneDesktopLayout, TileStore] {
|
||||
const update = prevTiles.from(2);
|
||||
@@ -30,7 +32,8 @@ export function oneOnOneDesktopLayout(
|
||||
{
|
||||
type: media.type,
|
||||
spotlight: tiles.gridTilesByMedia.get(media.spotlight)!,
|
||||
pip: { vm: tiles.gridTilesByMedia.get(media.pip)!, ...pipTile },
|
||||
pip: tiles.gridTilesByMedia.get(media.pip)!,
|
||||
pipAlignment$,
|
||||
},
|
||||
tiles,
|
||||
];
|
||||
|
||||
@@ -6,20 +6,23 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
type Alignment,
|
||||
type OneOnOneMobileLayout,
|
||||
type OneOnOneMobileLayoutMedia,
|
||||
} from "./layout-types";
|
||||
import { type TileStore } from "./TileStore";
|
||||
import { type Behavior } from "./Behavior";
|
||||
import { type FloatingTile } from "./FloatingTile";
|
||||
|
||||
/**
|
||||
* Produces a one-on-one mobile layout with the given media.
|
||||
*/
|
||||
export function oneOnOneMobileLayout(
|
||||
media: OneOnOneMobileLayoutMedia,
|
||||
pipTile: FloatingTile & { size$: Behavior<"sm" | "lg"> },
|
||||
pipSize$: Behavior<"sm" | "lg">,
|
||||
pipAlignment$: BehaviorSubject<Alignment>,
|
||||
prevTiles: TileStore,
|
||||
): [OneOnOneMobileLayout, TileStore] {
|
||||
const update = prevTiles.from(media.pip === undefined ? 0 : 1);
|
||||
@@ -27,12 +30,13 @@ export function oneOnOneMobileLayout(
|
||||
if (media.pip !== undefined) update.registerGridTile(media.pip);
|
||||
const tiles = update.build();
|
||||
|
||||
const pipVm = media.pip && tiles.gridTilesByMedia.get(media.pip);
|
||||
return [
|
||||
{
|
||||
type: media.type,
|
||||
spotlight: tiles.spotlightTile!,
|
||||
pip: pipVm && { vm: pipVm, ...pipTile },
|
||||
pip: media.pip && tiles.gridTilesByMedia.get(media.pip),
|
||||
pipSize$,
|
||||
pipAlignment$,
|
||||
},
|
||||
tiles,
|
||||
];
|
||||
|
||||
@@ -5,19 +5,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
type Alignment,
|
||||
type SpotlightExpandedLayout,
|
||||
type SpotlightExpandedLayoutMedia,
|
||||
} from "./layout-types";
|
||||
import { type TileStore } from "./TileStore";
|
||||
import { type FloatingTile } from "./FloatingTile";
|
||||
|
||||
/**
|
||||
* Produces an expanded spotlight layout with the given media.
|
||||
*/
|
||||
export function spotlightExpandedLayout(
|
||||
media: SpotlightExpandedLayoutMedia,
|
||||
pipTile: FloatingTile,
|
||||
pipAlignment$: BehaviorSubject<Alignment>,
|
||||
prevTiles: TileStore,
|
||||
): [SpotlightExpandedLayout, TileStore] {
|
||||
const update = prevTiles.from(1);
|
||||
@@ -25,12 +27,12 @@ export function spotlightExpandedLayout(
|
||||
if (media.pip !== undefined) update.registerPipTile(media.pip);
|
||||
const tiles = update.build();
|
||||
|
||||
const pipVm = tiles.gridTiles.at(0);
|
||||
return [
|
||||
{
|
||||
type: media.type,
|
||||
spotlight: tiles.spotlightTile!,
|
||||
pip: pipVm && { vm: pipVm, ...pipTile },
|
||||
pip: tiles.gridTiles.at(0),
|
||||
pipAlignment$,
|
||||
},
|
||||
tiles,
|
||||
];
|
||||
|
||||
@@ -1,82 +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 { test, expect } from "vitest";
|
||||
import { layoutShallowEquals, type Layout } from "./layout-types";
|
||||
import {
|
||||
type SpotlightTileViewModel,
|
||||
type GridTileViewModel,
|
||||
} from "./TileViewModel";
|
||||
import { type FloatingTile } from "./FloatingTile";
|
||||
|
||||
const spotlightTile = {} as unknown as SpotlightTileViewModel;
|
||||
const gridTile = {} as unknown as GridTileViewModel;
|
||||
const pipTile = {} as unknown as FloatingTile & { vm: GridTileViewModel };
|
||||
|
||||
const spotlightExpanded: Layout = {
|
||||
type: "spotlight-expanded",
|
||||
spotlight: spotlightTile,
|
||||
};
|
||||
|
||||
const spotlightPortrait: Layout = {
|
||||
type: "spotlight-portrait",
|
||||
spotlight: { vm: spotlightTile },
|
||||
grid: [gridTile],
|
||||
setVisibleTiles: () => {},
|
||||
};
|
||||
|
||||
test("layoutShallowEquals considers a layout to be equal to its shallow clone", () =>
|
||||
expect(layoutShallowEquals(spotlightExpanded, { ...spotlightExpanded })).toBe(
|
||||
true,
|
||||
));
|
||||
|
||||
test("layoutShallowEquals detects a missing key", () => {
|
||||
expect(
|
||||
layoutShallowEquals(spotlightExpanded, {
|
||||
...spotlightExpanded,
|
||||
pip: pipTile,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
layoutShallowEquals(
|
||||
{ ...spotlightExpanded, pip: pipTile },
|
||||
spotlightExpanded,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("layoutShallowEquals considers tile wrappers with equal contents to be equal", () =>
|
||||
expect(
|
||||
layoutShallowEquals(spotlightPortrait, {
|
||||
...spotlightPortrait,
|
||||
spotlight: { vm: spotlightTile },
|
||||
}),
|
||||
).toBe(true));
|
||||
|
||||
test("layoutShallowEquals detects tile wrappers with different contents", () =>
|
||||
expect(
|
||||
layoutShallowEquals(spotlightPortrait, {
|
||||
...spotlightPortrait,
|
||||
spotlight: { vm: {} as unknown as SpotlightTileViewModel },
|
||||
}),
|
||||
).toBe(false));
|
||||
|
||||
test("layoutShallowEquals considers grid arrays with equal contents to be equal", () =>
|
||||
expect(
|
||||
layoutShallowEquals(spotlightPortrait, {
|
||||
...spotlightPortrait,
|
||||
grid: [...spotlightPortrait.grid],
|
||||
}),
|
||||
).toBe(true));
|
||||
|
||||
test("layoutShallowEquals detects grid arrays with different contents", () =>
|
||||
expect(
|
||||
layoutShallowEquals(spotlightPortrait, {
|
||||
...spotlightPortrait,
|
||||
grid: [...spotlightPortrait.grid, gridTile],
|
||||
}),
|
||||
).toBe(false));
|
||||
+13
-65
@@ -5,6 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
|
||||
import { type MediaViewModel } from "./media/MediaViewModel.ts";
|
||||
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts";
|
||||
@@ -14,9 +16,6 @@ import {
|
||||
type SpotlightTileViewModel,
|
||||
} from "./TileViewModel.ts";
|
||||
import { type Behavior } from "./Behavior.ts";
|
||||
import { type FloatingTile } from "./FloatingTile.ts";
|
||||
import { shallowEquals } from "../utils/object.ts";
|
||||
import { shallowEquals as arrayShallowEquals } from "../utils/array.ts";
|
||||
|
||||
/**
|
||||
* The general shape of the space the call is drawn in. Called a window because
|
||||
@@ -87,48 +86,24 @@ export interface Alignment {
|
||||
block: "start" | "end";
|
||||
}
|
||||
|
||||
export interface Drag {
|
||||
/**
|
||||
* The X offset of the dragged tile relative to its container.
|
||||
*/
|
||||
x: number;
|
||||
/**
|
||||
* The Y offset of the dragged tile relative to its container.
|
||||
*/
|
||||
y: number;
|
||||
/**
|
||||
* The X coordinate of the dragged tile, as a scalar of the container's width.
|
||||
*/
|
||||
xRatio: number;
|
||||
/**
|
||||
* The Y coordinate of the dragged tile, as a scalar of the container's height.
|
||||
*/
|
||||
yRatio: number;
|
||||
/**
|
||||
* Whether this event ends the drag gesture.
|
||||
*/
|
||||
endOfGesture: boolean;
|
||||
}
|
||||
|
||||
export type DragCallback = (drag: Drag) => void;
|
||||
|
||||
export interface GridLayout {
|
||||
type: "grid";
|
||||
spotlight?: FloatingTile & { vm: SpotlightTileViewModel };
|
||||
spotlight?: SpotlightTileViewModel;
|
||||
grid: GridTileViewModel[];
|
||||
spotlightAlignment$: BehaviorSubject<Alignment>;
|
||||
setVisibleTiles: (value: number) => void;
|
||||
}
|
||||
|
||||
export interface SpotlightLandscapeLayout {
|
||||
type: "spotlight-landscape";
|
||||
spotlight: { vm: SpotlightTileViewModel };
|
||||
spotlight: SpotlightTileViewModel;
|
||||
grid: GridTileViewModel[];
|
||||
setVisibleTiles: (value: number) => void;
|
||||
}
|
||||
|
||||
export interface SpotlightPortraitLayout {
|
||||
type: "spotlight-portrait";
|
||||
spotlight: { vm: SpotlightTileViewModel };
|
||||
spotlight: SpotlightTileViewModel;
|
||||
grid: GridTileViewModel[];
|
||||
setVisibleTiles: (value: number) => void;
|
||||
}
|
||||
@@ -136,19 +111,23 @@ export interface SpotlightPortraitLayout {
|
||||
export interface SpotlightExpandedLayout {
|
||||
type: "spotlight-expanded";
|
||||
spotlight: SpotlightTileViewModel;
|
||||
pip?: FloatingTile & { vm: GridTileViewModel };
|
||||
pip?: GridTileViewModel;
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
}
|
||||
|
||||
export interface OneOnOneDesktopLayout {
|
||||
type: "one-on-one-desktop";
|
||||
spotlight: GridTileViewModel;
|
||||
pip: FloatingTile & { vm: GridTileViewModel };
|
||||
pip: GridTileViewModel;
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
}
|
||||
|
||||
export interface OneOnOneMobileLayout {
|
||||
type: "one-on-one-mobile";
|
||||
spotlight: SpotlightTileViewModel;
|
||||
pip?: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> };
|
||||
pip?: GridTileViewModel;
|
||||
pipSize$: Behavior<"sm" | "lg">;
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
}
|
||||
|
||||
export interface PipLayout {
|
||||
@@ -168,34 +147,3 @@ export type Layout =
|
||||
| OneOnOneDesktopLayout
|
||||
| OneOnOneMobileLayout
|
||||
| PipLayout;
|
||||
|
||||
/**
|
||||
* Tests whether the top-level properties, tile wrapper properties, and array
|
||||
* elements of layout `a` are equal to those of layout `b`. Useful for deduping
|
||||
* redundant layout updates.
|
||||
*/
|
||||
export function layoutShallowEquals<T extends Layout | LayoutMedia>(
|
||||
a: T,
|
||||
b: T,
|
||||
): boolean {
|
||||
return shallowEquals<T>(a, b, (aValue, bValue) => {
|
||||
// Special case for tile wrapper objects so we can detect when the
|
||||
// spotlight or PiP tile is essentially the same
|
||||
if (
|
||||
typeof aValue === "object" &&
|
||||
aValue !== null &&
|
||||
"vm" in aValue &&
|
||||
typeof bValue === "object" &&
|
||||
bValue !== null &&
|
||||
"vm" in bValue
|
||||
)
|
||||
return shallowEquals(aValue, bValue);
|
||||
|
||||
// Special case for arrays so we can detect when the grid tile arrays are
|
||||
// essentially the same
|
||||
if (Array.isArray(aValue) && Array.isArray(bValue))
|
||||
return arrayShallowEquals(aValue, bValue);
|
||||
|
||||
return aValue === bValue;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,111 +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 {
|
||||
type Behavior,
|
||||
type LocalMediaTrack,
|
||||
type LocalVideoMediaTrack,
|
||||
type ObservableScope,
|
||||
type VideoProcessor,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { combineLatest } from "rxjs";
|
||||
|
||||
import { type MediaDevice } from "./MediaDevices";
|
||||
import { type Handler } from "./MuteStates";
|
||||
|
||||
/** How a mute switch gets a track where there is none. */
|
||||
export interface TrackSupply {
|
||||
publish: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mute switch drives the track, which reports back what the device
|
||||
* allowed, so a denied permission flips the switch back. A source is muted
|
||||
* and unmuted while its track exists and published when it does not.
|
||||
*/
|
||||
export function followMuteState(
|
||||
scope: ObservableScope,
|
||||
muteState: {
|
||||
setHandler: (handler: Handler) => void;
|
||||
unsetHandler: () => void;
|
||||
enabled$: Behavior<boolean>;
|
||||
},
|
||||
track$: Behavior<LocalMediaTrack | undefined>,
|
||||
supply: TrackSupply,
|
||||
source: "microphone" | "camera",
|
||||
logger: Logger,
|
||||
): void {
|
||||
// What the switch asks for, ahead of what it shows: the switch follows
|
||||
// only once the handler has resolved
|
||||
let desired = muteState.enabled$.value;
|
||||
muteState.setHandler(async (enabled) => {
|
||||
desired = enabled;
|
||||
const track = track$.value;
|
||||
if (track !== undefined) {
|
||||
desired = await track.setEnabled(enabled);
|
||||
return desired;
|
||||
}
|
||||
if (!enabled) return false;
|
||||
try {
|
||||
await supply.publish();
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(`Failed to publish the ${source}`, e);
|
||||
desired = false;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
scope.onEnd(() => muteState.unsetHandler());
|
||||
// A track that arrives after its switch was flipped off catches up with it
|
||||
track$.pipe(scope.bind()).subscribe((track) => {
|
||||
if (track !== undefined && !desired)
|
||||
track.setEnabled(false).catch((e) => {
|
||||
logger.error(`Failed to mute the ${source}`, e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The device the user picked follows onto the track as it is made and as the
|
||||
* choice changes; the SDK does not watch the settings itself.
|
||||
*/
|
||||
export function followDevice(
|
||||
scope: ObservableScope,
|
||||
track$: Behavior<LocalMediaTrack | undefined>,
|
||||
device: MediaDevice<unknown, { id: string }>,
|
||||
source: "microphone" | "camera",
|
||||
logger: Logger,
|
||||
): void {
|
||||
combineLatest([track$, device.selected$])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([track, selected]) => {
|
||||
if (track === undefined || selected === undefined) return;
|
||||
track.setDevice(selected.id).catch((e) => {
|
||||
logger.error(`Failed to switch the ${source} device`, e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attached once the camera track exists rather than carried in the publish
|
||||
* request, so that a new track and a change of processor take the same path.
|
||||
*/
|
||||
export function followProcessor(
|
||||
scope: ObservableScope,
|
||||
camera$: Behavior<LocalVideoMediaTrack | undefined>,
|
||||
videoProcessor$: Behavior<VideoProcessor | undefined>,
|
||||
logger: Logger,
|
||||
): void {
|
||||
combineLatest([camera$, videoProcessor$])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([camera, processor]) => {
|
||||
camera?.setProcessor(processor).catch((e) => {
|
||||
logger.error("Failed to set the video processor", e);
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user