mirror of
https://github.com/vector-im/element-call.git
synced 2026-11-07 00:52:28 +00:00
Compare commits
92
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9cffb60dd | ||
|
|
06f07fb808 | ||
|
|
eee101ffba | ||
|
|
4e779ac4f5 | ||
|
|
7fb222ce60 | ||
|
|
5fdb920ea8 | ||
|
|
813dd4a49e | ||
|
|
21b54501de | ||
|
|
3d57920556 | ||
|
|
d68e761f7a | ||
|
|
cc994abeb3 | ||
|
|
33f4d5043f | ||
|
|
1e17a6411c | ||
|
|
0d60eabef2 | ||
|
|
22083299fa | ||
|
|
52d5b69487 | ||
|
|
a0aaa8e09a | ||
|
|
146dd3aea1 | ||
|
|
3f5f707ca7 | ||
|
|
922a9bbbab | ||
|
|
69c57bd534 | ||
|
|
734ecbbecf | ||
|
|
00463c3ede | ||
|
|
1aa91bf27c | ||
|
|
f930f73c40 | ||
|
|
3914aec53f | ||
|
|
321340a192 | ||
|
|
818906eef8 | ||
|
|
60a8fa5464 | ||
|
|
e339098939 | ||
|
|
1dad38edd2 | ||
|
|
517edc12ac | ||
|
|
dff4e0f194 | ||
|
|
ba27855a43 | ||
|
|
d46840ce81 | ||
|
|
c30bb324e3 | ||
|
|
b4a632b6d5 | ||
|
|
7b6f697c84 | ||
|
|
aa9125f0a0 | ||
|
|
e78b4b2e05 | ||
|
|
6cffe649e2 | ||
|
|
844051e24c | ||
|
|
a91471b5bc | ||
|
|
346074ce32 | ||
|
|
b9c10ac533 | ||
|
|
79bd2890a4 | ||
|
|
216bd4b911 | ||
|
|
a6764b4e49 | ||
|
|
35ce907e1f | ||
|
|
94ee13c639 | ||
|
|
8a987b4d46 | ||
|
|
0fbd77438a | ||
|
|
1dec2b0b1e | ||
|
|
19efe87908 | ||
|
|
f331c75126 | ||
|
|
6658dcb677 | ||
|
|
6a7f2ca603 | ||
|
|
b8c7f4ebfe | ||
|
|
287f711d07 | ||
|
|
8ef1dee256 | ||
|
|
26b09adc64 | ||
|
|
b7655417d7 | ||
|
|
2dd000bab0 | ||
|
|
d6e3fefe82 | ||
|
|
38ef1add48 | ||
|
|
b27c11c381 | ||
|
|
cffdb58521 | ||
|
|
2abe7ea02f | ||
|
|
b7bc218b19 | ||
|
|
17caded88b | ||
|
|
85424f76b1 | ||
|
|
c713b13994 | ||
|
|
c233ac6e70 | ||
|
|
2e6c7d1419 | ||
|
|
6793a4b46e | ||
|
|
619f5ac3a2 | ||
|
|
5780597b83 | ||
|
|
f2976be103 | ||
|
|
76da59be40 | ||
|
|
c6ad8b2ac7 | ||
|
|
d33a1eb059 | ||
|
|
9fcba5a704 | ||
|
|
dc1459e45e | ||
|
|
dfff8be38e | ||
|
|
9e6f2e89a8 | ||
|
|
7a48b09c4c | ||
|
|
96333dbc3a | ||
|
|
bda7b86c39 | ||
|
|
ee507ab3ac | ||
|
|
4441bb5e8c | ||
|
|
f2f49315eb | ||
|
|
99256fa01c |
@@ -109,4 +109,12 @@ 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,12 +60,18 @@ 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.
|
||||
*/
|
||||
export function scopeStylesToRoot(): Plugin {
|
||||
interface Options {
|
||||
/** Stylesheets left as they are: a host's own, which it doesn't scope. */
|
||||
exclude?: RegExp[];
|
||||
}
|
||||
|
||||
export function scopeStylesToRoot({ exclude = [] }: Options = {}): Plugin {
|
||||
return {
|
||||
postcssPlugin: "element-call-scope-styles-to-root",
|
||||
Once(root) {
|
||||
const isModule =
|
||||
root.source?.input.file?.endsWith(".module.css") ?? false;
|
||||
const file = root.source?.input.file ?? "";
|
||||
if (exclude.some((pattern) => pattern.test(file))) return;
|
||||
const isModule = file.endsWith(".module.css");
|
||||
root.walkRules((rule) => {
|
||||
if (isRelative(rule)) return;
|
||||
rule.selector = (isModule ? scopeBare : scopeAll).processSync(
|
||||
|
||||
+11
-12
@@ -72,9 +72,9 @@ import {
|
||||
useUrlParams,
|
||||
} from "../src/UrlParams";
|
||||
import { MediaDevicesContext } from "../src/MediaDevicesContext";
|
||||
import { MediaDevices } from "../src/state/MediaDevices";
|
||||
import { AppViewModel } from "../src/state/AppViewModel";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { ProcessorProvider } from "../src/tracks/TrackProcessorContext";
|
||||
import { BackgroundEffectsProvider } from "../src/tracks/BackgroundEffectsContext";
|
||||
import { Config } from "../src/config/Config";
|
||||
import { type ConfigOptions } from "../src/config/ConfigOptions";
|
||||
import { i18n } from "../src/utils/i18n";
|
||||
@@ -194,14 +194,12 @@ 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 [mediaDevices, setMediaDevices] = useState<MediaDevices | null>(null);
|
||||
const [vm, setVm] = useState<AppViewModel | null>(null);
|
||||
useEffect(() => {
|
||||
const scope = new ObservableScope();
|
||||
setMediaDevices(
|
||||
new MediaDevices(scope, { controlledAudioDevices, callIntent }),
|
||||
);
|
||||
setVm(new AppViewModel(scope, { controlledAudioDevices, callIntent }));
|
||||
return (): void => {
|
||||
setMediaDevices(null);
|
||||
setVm(null);
|
||||
scope.end();
|
||||
};
|
||||
}, [controlledAudioDevices, callIntent]);
|
||||
@@ -222,8 +220,7 @@ 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 && mediaDevices !== null;
|
||||
const ready = container !== null && rtcSession !== null && vm !== null;
|
||||
const announcedLoaded = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!ready || announcedLoaded.current) return;
|
||||
@@ -254,8 +251,10 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
<Decoration>
|
||||
<TooltipProvider>
|
||||
<ClientProvider client={client}>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<BackgroundEffectsProvider
|
||||
effects={vm.backgroundEffects}
|
||||
>
|
||||
<CallView
|
||||
client={client}
|
||||
rtcSession={rtcSession}
|
||||
@@ -264,7 +263,7 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
preload={params.preload}
|
||||
skipLobby={params.skipLobby}
|
||||
/>
|
||||
</ProcessorProvider>
|
||||
</BackgroundEffectsProvider>
|
||||
</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:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:a79801206c628a912d57fa6f355d5c5ab9c6c40c2d1b6208d4192678fb6e80c7
|
||||
element-web-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:a79801206c628a912d57fa6f355d5c5ab9c6c40c2d1b6208d4192678fb6e80c7
|
||||
|
||||
@@ -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.36.0" }
|
||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.37.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.5-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.6-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
+4
-3
@@ -7,13 +7,13 @@
|
||||
"features": ["plural_postfix_us", "filter_untranslated"],
|
||||
"files": [
|
||||
{
|
||||
"pattern": "locales/en/*.json",
|
||||
"pattern": "locales/en/app.json",
|
||||
"lang": "inherited"
|
||||
},
|
||||
{
|
||||
"group": "existing",
|
||||
"pattern": "locales/*/*.json",
|
||||
"excludes": ["locales/en/*.json"],
|
||||
"pattern": "locales/*/app.json",
|
||||
"excludes": ["locales/en/app.json"],
|
||||
"lang": "${autodetectLang}"
|
||||
}
|
||||
]
|
||||
@@ -22,6 +22,7 @@
|
||||
"download": {
|
||||
"files": [
|
||||
{
|
||||
"conditions": ["equals: ${file}, app.json"],
|
||||
"output": "locales/${langLsrDash}/${file}"
|
||||
}
|
||||
],
|
||||
|
||||
+8
-1
@@ -3,7 +3,6 @@
|
||||
"user_menu": "User menu"
|
||||
},
|
||||
"action": {
|
||||
"blur_background": "Blur background",
|
||||
"close": "Close",
|
||||
"copy_link": "Copy link",
|
||||
"edit": "Edit",
|
||||
@@ -23,6 +22,13 @@
|
||||
"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>",
|
||||
@@ -212,6 +218,7 @@
|
||||
"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",
|
||||
|
||||
+2
-3
@@ -57,7 +57,6 @@
|
||||
"@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",
|
||||
@@ -88,7 +87,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.4.0",
|
||||
"@vector-im/compound-web": "^10.5.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/browser-playwright": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
@@ -107,7 +106,7 @@
|
||||
"livekit-client": "^2.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#3f05dc4d104a3dfc753c5e3549a18c74fab4b076",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01",
|
||||
"matrix-widget-api": "^1.18.0",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
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 Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { SpaHelpers } from "./spa-helpers.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",
|
||||
);
|
||||
|
||||
type Colour = [number, number, number];
|
||||
|
||||
test.describe("background effects", () => {
|
||||
test("pre-join preview shows the chosen effect", async ({ page }) => {
|
||||
test.slow();
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Effect user", "Background effects");
|
||||
const preview = page.locator("video").first();
|
||||
const camera = await averageColour(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;
|
||||
});
|
||||
}
|
||||
|
||||
/** The mean colour of a video's current frame or an image, drawn small. */
|
||||
async function averageColour(element: Locator): Promise<Colour> {
|
||||
return element.evaluate(
|
||||
async (source: HTMLVideoElement | HTMLImageElement) => {
|
||||
if (source instanceof HTMLImageElement) await source.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 32;
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.drawImage(source, 0, 0, 32, 32);
|
||||
const { data } = context.getImageData(0, 0, 32, 32);
|
||||
const sum = [0, 0, 0];
|
||||
for (let i = 0; i < data.length; i += 4)
|
||||
for (let c = 0; c < 3; c++) sum[c] += data[i + c];
|
||||
const pixels = data.length / 4;
|
||||
return sum.map((s) => s / pixels) as Colour;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function distance(a: Colour, b: Colour): number {
|
||||
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||
}
|
||||
@@ -125,25 +125,24 @@ test("leaves the host's own page unstyled", async ({ page }) => {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
const host = await page.evaluate(() => {
|
||||
const html = getComputedStyle(document.documentElement);
|
||||
const body = getComputedStyle(document.body);
|
||||
const sub = document.body.appendChild(document.createElement("sub"));
|
||||
const verticalAlign = getComputedStyle(sub).verticalAlign;
|
||||
sub.remove();
|
||||
return {
|
||||
lineHeight: html.lineHeight,
|
||||
fontFeatureSettings: body.fontFeatureSettings,
|
||||
token: html.getPropertyValue("--cpd-color-text-primary"),
|
||||
verticalAlign,
|
||||
token: getComputedStyle(document.documentElement).getPropertyValue(
|
||||
"--font-scale",
|
||||
),
|
||||
};
|
||||
});
|
||||
expect(host).toEqual({
|
||||
lineHeight: "normal",
|
||||
fontFeatureSettings: "normal",
|
||||
token: "",
|
||||
});
|
||||
expect(host).toEqual({ verticalAlign: "sub", token: "" });
|
||||
|
||||
// While inside the container, the same rules do apply
|
||||
const root = panes.first().locator("[data-element-call-root]");
|
||||
|
||||
Generated
+38
-116
@@ -40,9 +40,6 @@ 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))
|
||||
@@ -66,7 +63,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.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))
|
||||
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))
|
||||
'@sentry/react':
|
||||
specifier: ^8.0.0
|
||||
version: 8.55.2(react@19.2.8)
|
||||
@@ -134,11 +131,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.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)
|
||||
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)
|
||||
'@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.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))
|
||||
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))
|
||||
'@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)
|
||||
@@ -191,8 +188,8 @@ importers:
|
||||
specifier: ^1.9.1
|
||||
version: 1.9.2
|
||||
matrix-js-sdk:
|
||||
specifier: github:matrix-org/matrix-js-sdk#3f05dc4d104a3dfc753c5e3549a18c74fab4b076
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076
|
||||
specifier: github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
|
||||
matrix-widget-api:
|
||||
specifier: ^1.18.0
|
||||
version: 1.20.0
|
||||
@@ -397,8 +394,8 @@ packages:
|
||||
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/runtime@8.0.0':
|
||||
resolution: {integrity: sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==}
|
||||
'@babel/runtime@8.0.8':
|
||||
resolution: {integrity: sha512-dEu8nGlRqjsJsvviUUoSMfcRHpwomCiRlXsc2mPfJ79+ffTpDm+JtfUtTMzUrqCApHQnCsVPD3gT1ZT8RgBRCQ==}
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
|
||||
@@ -408,10 +405,6 @@ 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'}
|
||||
@@ -1184,19 +1177,6 @@ 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==}
|
||||
|
||||
@@ -3314,8 +3294,8 @@ packages:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@vector-im/compound-web@10.4.0':
|
||||
resolution: {integrity: sha512-Tt5hsNK7ulhGdRuy9lzSm5C+T8M/4O4kLDFLkaU+Hi1kVWj6tj/5awW+U+GvtpWRh5llbHN2HRCh79+gQhek+A==}
|
||||
'@vector-im/compound-web@10.5.0':
|
||||
resolution: {integrity: sha512-dcxIAKtTKBLKz2Y83mNkXyYx3shtSs80KvdGKN5ryFvo6OnVOhHPEHiQNbaPQ0EDjc6xStjY9+oz2Fh4c+kniQ==}
|
||||
peerDependencies:
|
||||
'@fontsource/inconsolata': ^5
|
||||
'@fontsource/inter': ^5
|
||||
@@ -3421,11 +3401,6 @@ 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'}
|
||||
@@ -3697,10 +3672,6 @@ 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'}
|
||||
@@ -4536,9 +4507,6 @@ 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==}
|
||||
|
||||
@@ -4694,9 +4662,6 @@ 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==}
|
||||
|
||||
@@ -4759,9 +4724,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/3f05dc4d104a3dfc753c5e3549a18c74fab4b076:
|
||||
resolution: {gitHosted: true, integrity: sha512-Ly8eS7fNNKDo9KmPvTkE49KvD8iMUmrVWJMNERNuxc8UksMLMG36NLz3fs8HytdwrB7AKPfyVo392VZsxILDIg==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076}
|
||||
version: 44.0.0
|
||||
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
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
matrix-widget-api@1.20.0:
|
||||
@@ -5988,12 +5953,6 @@ 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==}
|
||||
|
||||
@@ -6357,10 +6316,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.8
|
||||
'@babel/parser': 7.29.11
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/traverse': 7.29.8(supports-color@7.2.0)
|
||||
'@babel/types': 7.29.8
|
||||
'@babel/traverse': 7.29.10(supports-color@7.2.0)
|
||||
'@babel/types': 7.29.11
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
@@ -6372,8 +6331,8 @@ snapshots:
|
||||
|
||||
'@babel/generator@7.29.8':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.8
|
||||
'@babel/types': 7.29.8
|
||||
'@babel/parser': 7.29.11
|
||||
'@babel/types': 7.29.11
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jsesc: 3.1.0
|
||||
@@ -6390,8 +6349,8 @@ snapshots:
|
||||
|
||||
'@babel/helper-module-imports@7.29.7(supports-color@7.2.0)':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.8(supports-color@7.2.0)
|
||||
'@babel/types': 7.29.8
|
||||
'@babel/traverse': 7.29.10(supports-color@7.2.0)
|
||||
'@babel/types': 7.29.11
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6400,7 +6359,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.8(supports-color@7.2.0)
|
||||
'@babel/traverse': 7.29.10(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6417,7 +6376,7 @@ snapshots:
|
||||
'@babel/helpers@7.29.7':
|
||||
dependencies:
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.8
|
||||
'@babel/types': 7.29.11
|
||||
|
||||
'@babel/parser@7.29.11':
|
||||
dependencies:
|
||||
@@ -6433,13 +6392,13 @@ snapshots:
|
||||
|
||||
'@babel/runtime@7.29.7': {}
|
||||
|
||||
'@babel/runtime@8.0.0': {}
|
||||
'@babel/runtime@8.0.8': {}
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/parser': 7.29.8
|
||||
'@babel/types': 7.29.8
|
||||
'@babel/parser': 7.29.11
|
||||
'@babel/types': 7.29.11
|
||||
|
||||
'@babel/traverse@7.29.10(supports-color@7.2.0)':
|
||||
dependencies:
|
||||
@@ -6448,19 +6407,7 @@ snapshots:
|
||||
'@babel/helper-globals': 7.29.7
|
||||
'@babel/parser': 7.29.11
|
||||
'@babel/template': 7.29.7
|
||||
'@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
|
||||
'@babel/types': 7.29.11
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -7203,18 +7150,6 @@ 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':
|
||||
@@ -8167,13 +8102,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.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))':
|
||||
'@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))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@7.2.0)
|
||||
picomatch: 4.0.4
|
||||
rolldown: 1.2.15
|
||||
optionalDependencies:
|
||||
'@babel/runtime': 8.0.0
|
||||
'@babel/runtime': 8.0.8
|
||||
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': {}
|
||||
@@ -8903,7 +8838,7 @@ snapshots:
|
||||
'@types/react': 19.2.18
|
||||
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)':
|
||||
'@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)':
|
||||
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)
|
||||
@@ -8925,12 +8860,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.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))':
|
||||
'@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))':
|
||||
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.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))
|
||||
'@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
|
||||
|
||||
'@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)':
|
||||
@@ -9055,14 +8990,12 @@ snapshots:
|
||||
|
||||
'@webcontainer/env@1.1.1': {}
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.18.0):
|
||||
acorn-jsx@5.3.2(acorn@8.19.0):
|
||||
dependencies:
|
||||
acorn: 8.18.0
|
||||
acorn: 8.19.0
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
acorn@8.18.0: {}
|
||||
|
||||
acorn@8.19.0: {}
|
||||
|
||||
agent-base@6.0.2(supports-color@7.2.0):
|
||||
@@ -9354,8 +9287,6 @@ 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
|
||||
@@ -9733,8 +9664,8 @@ snapshots:
|
||||
|
||||
espree@9.6.1:
|
||||
dependencies:
|
||||
acorn: 8.18.0
|
||||
acorn-jsx: 5.3.2(acorn@8.18.0)
|
||||
acorn: 8.19.0
|
||||
acorn-jsx: 5.3.2(acorn@8.19.0)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
esprima@4.0.1: {}
|
||||
@@ -10247,8 +10178,6 @@ snapshots:
|
||||
|
||||
jose@6.2.13: {}
|
||||
|
||||
jose@6.2.9: {}
|
||||
|
||||
js-tokens@10.0.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
@@ -10409,8 +10338,6 @@ snapshots:
|
||||
|
||||
lodash-es@4.18.1: {}
|
||||
|
||||
lodash.debounce@4.0.8: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
log-symbols@7.0.1:
|
||||
@@ -10468,9 +10395,9 @@ snapshots:
|
||||
|
||||
matrix-events-sdk@0.0.1: {}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076:
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.0
|
||||
'@babel/runtime': 8.0.8
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
|
||||
another-json: 0.2.0
|
||||
bs58: 6.0.0
|
||||
@@ -11701,7 +11628,7 @@ snapshots:
|
||||
terser@5.46.1:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.11
|
||||
acorn: 8.18.0
|
||||
acorn: 8.19.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
|
||||
@@ -11869,11 +11796,6 @@ 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,8 +44,9 @@ const TARGETS = [
|
||||
{
|
||||
config: "vite-component.config.ts",
|
||||
what: "the component build",
|
||||
sources: ["src", "component"],
|
||||
excluded: ["component/dev"],
|
||||
// `sdk` too: the build resolves `@element-hq/matrixrtc-sdk` to its source
|
||||
sources: ["src", "component", "sdk"],
|
||||
excluded: ["component/dev", "sdk/dev"],
|
||||
mustBeExternal: [
|
||||
"react",
|
||||
"react-dom",
|
||||
|
||||
+10
-5
@@ -126,10 +126,15 @@ 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. 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.
|
||||
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.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
@@ -538,7 +543,7 @@ backend is handed the whole session's view and gives back media:
|
||||
type MemberMediaFields = Pick<RTCMember, "tracks$" | "encryptionError$">;
|
||||
|
||||
export interface MediaBackend {
|
||||
readonly transportType: string; // "livekit"
|
||||
readonly transportTypes: readonly string[]; // ["m.livekit", "livekit"]
|
||||
/** Authenticate with the transport the client found; the probe for delegation runs here. */
|
||||
prepareLocalTransport(
|
||||
transport: Transport,
|
||||
|
||||
+7
-2
@@ -61,8 +61,13 @@ export {
|
||||
createLivekitBackend,
|
||||
type LivekitBackendOptions,
|
||||
} from "./src/media-backend/livekit/LivekitBackend";
|
||||
// For a host that creates LiveKit tracks itself, such as a preview before joining
|
||||
export { convertToLivekitProcessor } from "./src/media-backend/livekit/videoProcessor";
|
||||
// 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 developer panel to check a transport before using it
|
||||
export {
|
||||
getSFUConfig as authenticateWithTransport,
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/*
|
||||
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 = {
|
||||
transportType: "livekit",
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
prepareLocalTransport: async () => fake.prepared.promise,
|
||||
delegateDelayedLeave: fake.delegateDelayedLeave,
|
||||
local: {
|
||||
|
||||
@@ -136,7 +136,8 @@ 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.transportType === "livekit"
|
||||
backend.transportTypes.includes("m.livekit") ||
|
||||
backend.transportTypes.includes("livekit")
|
||||
? findRemotePublishLivekitTransport({
|
||||
client,
|
||||
// A snapshot: if nobody with a usable SFU is here yet, the user has
|
||||
@@ -152,7 +153,7 @@ export function createRTCParticipation(
|
||||
const preparedTransport$ = from(
|
||||
discoverLocalTransport(
|
||||
client,
|
||||
backend.transportType,
|
||||
backend.transportTypes,
|
||||
options,
|
||||
transportFromBackends,
|
||||
logger,
|
||||
|
||||
+10
-6
@@ -20,6 +20,7 @@ import { type Observable } from "rxjs";
|
||||
import { type Behavior } from "./reactive/Behavior";
|
||||
import {
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalMedia,
|
||||
type LocalMemberMedia,
|
||||
type LocalVideoMediaTrack,
|
||||
type MediaSource,
|
||||
@@ -84,12 +85,15 @@ export interface RTCSlot {
|
||||
|
||||
export interface RTCParticipationOptions {
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
publish: PublishRequest[];
|
||||
publish: PublishRequest[] | LocalMedia;
|
||||
/**
|
||||
* LEGACY
|
||||
*
|
||||
@@ -230,7 +234,7 @@ export interface DataMessage {
|
||||
* backend-specific developer panel and for connection diagnostics.
|
||||
*/
|
||||
export interface TransportMetadata {
|
||||
/** `"livekit"` today. */
|
||||
/** `"m.livekit"` or `"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,
|
||||
"livekit",
|
||||
["m.livekit", "livekit"],
|
||||
{ transport: hostTransport },
|
||||
fromBackends(),
|
||||
logger,
|
||||
@@ -61,7 +61,7 @@ describe("discoverLocalTransport", () => {
|
||||
await expect(
|
||||
discoverLocalTransport(
|
||||
client([homeserverTransport]),
|
||||
"livekit",
|
||||
["m.livekit", "livekit"],
|
||||
{ fallbackTransport: hostTransport },
|
||||
fromBackends(),
|
||||
logger,
|
||||
@@ -73,7 +73,7 @@ describe("discoverLocalTransport", () => {
|
||||
await expect(
|
||||
discoverLocalTransport(
|
||||
client([{ type: "not_livekit" }]),
|
||||
"livekit",
|
||||
["m.livekit", "livekit"],
|
||||
{ fallbackTransport: hostTransport },
|
||||
fromBackends(),
|
||||
logger,
|
||||
@@ -87,7 +87,7 @@ describe("discoverLocalTransport", () => {
|
||||
await expect(
|
||||
discoverLocalTransport(
|
||||
client([homeserverTransport]),
|
||||
"livekit",
|
||||
["m.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([]),
|
||||
"livekit",
|
||||
["m.livekit", "livekit"],
|
||||
{},
|
||||
fromBackends(),
|
||||
logger,
|
||||
|
||||
@@ -15,14 +15,15 @@ import { TransportAutoDiscovery } from "./TransportAutoDiscovery";
|
||||
|
||||
/**
|
||||
* The transport the local member advertises: the one the host names, else the
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* @throws {MatrixRTCTransportMissingError} where none of these exists.
|
||||
*/
|
||||
export async function discoverLocalTransport(
|
||||
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
|
||||
transportType: string,
|
||||
transportTypes: readonly string[],
|
||||
{
|
||||
transport: override,
|
||||
fallbackTransport,
|
||||
@@ -34,7 +35,7 @@ export async function discoverLocalTransport(
|
||||
override ??
|
||||
(await new TransportAutoDiscovery({
|
||||
client,
|
||||
transportType,
|
||||
transportTypes,
|
||||
fallbackTransport,
|
||||
logger,
|
||||
}).discoverPreferredTransport()) ??
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportType: "livekit",
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
fallbackTransport: configTransport,
|
||||
logger: rootLogger,
|
||||
});
|
||||
@@ -75,6 +75,29 @@ 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
|
||||
@@ -92,7 +115,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportType: "livekit",
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
fallbackTransport: configTransport,
|
||||
logger: rootLogger,
|
||||
});
|
||||
@@ -116,7 +139,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportType: "livekit",
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
fallbackTransport: configTransport,
|
||||
logger: rootLogger,
|
||||
});
|
||||
@@ -133,7 +156,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportType: "livekit",
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
fallbackTransport: undefined,
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
@@ -17,27 +17,27 @@ type TransportDiscoveryClient = Pick<
|
||||
|
||||
export interface TransportAutoDiscoveryProps {
|
||||
client: TransportDiscoveryClient;
|
||||
/** The transport type the media backend serves. */
|
||||
transportType: string;
|
||||
/** A transport to use when the homeserver advertises none of the type. */
|
||||
/** 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. */
|
||||
fallbackTransport?: Transport;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export class TransportAutoDiscovery {
|
||||
private readonly client: TransportDiscoveryClient;
|
||||
private readonly transportType: string;
|
||||
private readonly transportTypes: readonly string[];
|
||||
private readonly fallbackTransport: Transport | undefined;
|
||||
private readonly logger: Logger;
|
||||
|
||||
public constructor({
|
||||
client,
|
||||
transportType,
|
||||
transportTypes,
|
||||
fallbackTransport,
|
||||
logger,
|
||||
}: TransportAutoDiscoveryProps) {
|
||||
this.client = client;
|
||||
this.transportType = transportType;
|
||||
this.transportTypes = transportTypes;
|
||||
this.fallbackTransport = fallbackTransport;
|
||||
this.logger = logger.getChild("[TransportAutoDiscovery]");
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export class TransportAutoDiscovery {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the first transport of the backend's type from the homeserver.
|
||||
* Fetches the first transport of the backend's most preferred 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,17 +76,16 @@ export class TransportAutoDiscovery {
|
||||
const transportList = await doNetworkOperationWithRetry(async () =>
|
||||
client._unstable_getRTCTransports(),
|
||||
);
|
||||
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,
|
||||
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,
|
||||
);
|
||||
} catch (ex) {
|
||||
this.logger.info(`Failed to use getRTCTransports end point: ${ex}`);
|
||||
}
|
||||
|
||||
@@ -185,3 +185,45 @@ 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,6 +20,7 @@ import { type Observable } from "rxjs";
|
||||
import { type ResolvedTransport, type RTCMember } from "../api";
|
||||
import {
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalMedia,
|
||||
type LocalMemberMedia,
|
||||
type LocalVideoMediaTrack,
|
||||
type MediaSource,
|
||||
@@ -56,8 +57,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. */
|
||||
publish: PublishRequest[];
|
||||
/** What the local member publishes at the join: requests, or a lobby's media. */
|
||||
publish: PublishRequest[] | LocalMedia;
|
||||
encryptionSystem: EncryptionSystem;
|
||||
/**
|
||||
* Every media key the session knows: the current ones replayed on
|
||||
@@ -113,8 +114,11 @@ export interface DataPacket {
|
||||
}
|
||||
|
||||
export interface MediaBackend {
|
||||
/** The membership transport type this backend serves: "livekit". */
|
||||
readonly transportType: string;
|
||||
/**
|
||||
* The membership transport types this backend serves, ordered decreasingly
|
||||
* by preference.
|
||||
*/
|
||||
readonly transportTypes: readonly string[];
|
||||
|
||||
/**
|
||||
* Makes `transport` usable as the one the local member publishes on. The
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
vi,
|
||||
} from "vitest";
|
||||
import {
|
||||
ConnectionError,
|
||||
type LocalParticipant,
|
||||
type RemoteParticipant,
|
||||
type RemoteTrack,
|
||||
@@ -41,7 +42,7 @@ import {
|
||||
} from "./Connection.ts";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { type ClientGetTokenParts, type ClientOpenIDParts } from "./auth";
|
||||
import { MatrixRTCError } from "../../errors";
|
||||
import { LivekitConnectionError, MatrixRTCError } from "../../errors";
|
||||
import { testJWTToken } from "../../utils/test-fixtures";
|
||||
import { ownMemberMock } from "../../utils/test";
|
||||
import { mockRemoteParticipant } from "./test";
|
||||
@@ -57,6 +58,10 @@ 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",
|
||||
@@ -147,6 +152,13 @@ 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();
|
||||
@@ -375,6 +387,89 @@ 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,6 +14,7 @@ import {
|
||||
ConnectionError,
|
||||
ConnectionErrorReason,
|
||||
type ConnectionState as LivekitConnectionState,
|
||||
getBrowser,
|
||||
type Participant,
|
||||
type RemoteParticipant,
|
||||
type RemoteTrackPublication,
|
||||
@@ -322,7 +323,7 @@ export class Connection {
|
||||
|
||||
try {
|
||||
this.logger.info(`livekitRoom.connect ${url}`);
|
||||
await this.livekitRoom.connect(url, jwt);
|
||||
await this.connectLivekitRoom(url, jwt);
|
||||
this.logger.info(`livekitRoom.connect SUCCESS ${url}`);
|
||||
} catch (e) {
|
||||
this.logger.info(`livekitRoom.connect FAILED ${url}`, e);
|
||||
@@ -382,6 +383,23 @@ 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,
|
||||
publish: requestsOrMedia,
|
||||
encryptionSystem,
|
||||
mediaKeys$,
|
||||
timings,
|
||||
@@ -101,6 +101,9 @@ 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);
|
||||
@@ -137,6 +140,7 @@ export function createLivekitBackend(
|
||||
connectionMap$,
|
||||
localTransport$,
|
||||
publish,
|
||||
media,
|
||||
audioOutputDeviceId$,
|
||||
encryptionSystem,
|
||||
logger,
|
||||
@@ -279,7 +283,7 @@ export function createLivekitBackend(
|
||||
});
|
||||
|
||||
return {
|
||||
transportType: "livekit",
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
prepareLocalTransport,
|
||||
delegateDelayedLeave: delegateLeave,
|
||||
local: localMedia_,
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { EventEmitter } from "events";
|
||||
import {
|
||||
type LocalParticipant,
|
||||
LocalVideoTrack,
|
||||
ParticipantEvent,
|
||||
RemoteAudioTrack,
|
||||
type RemoteParticipant,
|
||||
@@ -17,10 +18,22 @@ import {
|
||||
TrackEvent,
|
||||
type TrackPublication,
|
||||
} from "livekit-client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
onTestFinished,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { type AudioMediaTrack } from "../../media-api";
|
||||
import {
|
||||
type AudioMediaTrack,
|
||||
type LocalVideoMediaTrack,
|
||||
type VideoProcessor,
|
||||
} from "../../media-api";
|
||||
import {
|
||||
createLivekitMediaTrack,
|
||||
createLocalLivekitMediaTrack,
|
||||
@@ -205,8 +218,55 @@ 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");
|
||||
expect(switchActiveDevice).toHaveBeenCalledWith(
|
||||
"audioinput",
|
||||
"mic-2",
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// 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 { convertToLivekitProcessor } from "./videoProcessor";
|
||||
import { toLivekitProcessor, type LivekitProcessor } from "./videoProcessor";
|
||||
|
||||
export const mediaSources: Record<Track.Source, MediaSource> = {
|
||||
[Track.Source.Microphone]: "microphone",
|
||||
@@ -197,9 +197,19 @@ 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,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -228,11 +238,17 @@ export function createLocalLivekitMediaTrack(
|
||||
},
|
||||
setProcessor: async (processor) => {
|
||||
const track = videoTrack();
|
||||
// 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();
|
||||
// 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
|
||||
import {
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalMedia,
|
||||
type LocalVideoMediaTrack,
|
||||
type MediaSource,
|
||||
type PublishRequest,
|
||||
@@ -57,6 +58,8 @@ 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;
|
||||
@@ -79,6 +82,7 @@ export function createLivekitLocalMedia({
|
||||
connectionMap$,
|
||||
localTransport$,
|
||||
publish: initialRequests,
|
||||
media,
|
||||
audioOutputDeviceId$,
|
||||
encryptionSystem,
|
||||
logger: parentLogger,
|
||||
@@ -119,6 +123,7 @@ export function createLivekitLocalMedia({
|
||||
connection.livekitRoom,
|
||||
desired,
|
||||
logger.getChild(`[Publisher ${JSON.stringify(connection.transport)}]`),
|
||||
media,
|
||||
);
|
||||
publisher$.next(publisher);
|
||||
return Promise.resolve(async (): Promise<void> => {
|
||||
|
||||
@@ -11,16 +11,32 @@ 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, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import { type PublishRequest } from "../../media-api";
|
||||
import {
|
||||
type HandedOverTrack,
|
||||
type LocalMedia,
|
||||
type PublishRequest,
|
||||
type VideoProcessor,
|
||||
} from "../../media-api";
|
||||
import { flushPromises } from "../../utils/test";
|
||||
import { type DesiredMedia, Publisher } from "./Publisher";
|
||||
import { type LivekitProcessor } from "./videoProcessor";
|
||||
|
||||
describe("Publisher", () => {
|
||||
describe("setAudioOutputDevice", () => {
|
||||
@@ -104,6 +120,24 @@ 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);
|
||||
@@ -327,6 +361,143 @@ 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,15 +6,23 @@ 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 MediaSource, type PublishRequest } from "../../media-api";
|
||||
import {
|
||||
type HandedOverTrack,
|
||||
type LocalMedia,
|
||||
type MediaSource,
|
||||
type PublishRequest,
|
||||
} from "../../media-api";
|
||||
import { livekitSources, mediaSources } from "./LivekitMediaTrack";
|
||||
import { toLivekitProcessor } from "./videoProcessor";
|
||||
import {
|
||||
audioCaptureOptions,
|
||||
screenShareCaptureOptions,
|
||||
@@ -31,6 +39,9 @@ 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.
|
||||
@@ -46,10 +57,19 @@ 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) => {
|
||||
@@ -79,33 +99,106 @@ 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(({ enabled }) => enabled)
|
||||
.filter(
|
||||
({ request, enabled }) => enabled || this.hasCaptured(request.source),
|
||||
)
|
||||
.map(({ request }) => request);
|
||||
const sources = new Set(requests.map((request) => request.source));
|
||||
const toCapture = new Set(
|
||||
requests
|
||||
.map((request) => request.source)
|
||||
.filter((source) => !this.hasCaptured(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.
|
||||
if (sources.has("microphone") && sources.has("camera"))
|
||||
const captureBoth = toCapture.has("microphone") && toCapture.has("camera");
|
||||
if (captureBoth) {
|
||||
this.takeCaptureDefaults(requests);
|
||||
void this.room.localParticipant.enableCameraAndMicrophone();
|
||||
}
|
||||
for (const request of requests)
|
||||
if (
|
||||
request.source === "screenShare" ||
|
||||
!(sources.has("microphone") && sources.has("camera"))
|
||||
this.hasCaptured(request.source) ||
|
||||
!captureBoth
|
||||
)
|
||||
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;
|
||||
if (request.source === "microphone")
|
||||
const captured = this.takeCaptured(request);
|
||||
if (captured) publication = await this.adopt(captured, request);
|
||||
else if (request.source === "microphone")
|
||||
publication = await participant.setMicrophoneEnabled(
|
||||
true,
|
||||
audioCaptureOptions(request),
|
||||
|
||||
@@ -13,6 +13,7 @@ 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";
|
||||
@@ -59,7 +60,7 @@ export async function findRemotePublishLivekitTransport({
|
||||
if (!isUnstableLivekitTransport(transport) || !("url" in transport))
|
||||
continue;
|
||||
const { url } = transport;
|
||||
const serverName = membership.getTransportServerName(transport);
|
||||
const serverName = serverNameOfTransport(transport, membership.userId);
|
||||
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 { convertToLivekitProcessor } from "./videoProcessor";
|
||||
import { toLivekitProcessor } 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: convertToLivekitProcessor(processor) }),
|
||||
...(processor && { processor: toLivekitProcessor(processor) }),
|
||||
...(capture?.resolution && { resolution: capture.resolution }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,13 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Track, type VideoProcessorOptions } from "livekit-client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
type TrackProcessor,
|
||||
type VideoProcessorOptions,
|
||||
} from "livekit-client";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { type VideoProcessor } from "../../media-api";
|
||||
import { convertToLivekitProcessor } from "./videoProcessor";
|
||||
import { fromLivekitProcessor, toLivekitProcessor } from "./videoProcessor";
|
||||
|
||||
describe("convertToLivekitProcessor", () => {
|
||||
describe("toLivekitProcessor", () => {
|
||||
const captured = { id: "captured" } as MediaStreamTrack;
|
||||
const processed = { id: "processed" } as MediaStreamTrack;
|
||||
const restarted = { id: "restarted" } as MediaStreamTrack;
|
||||
@@ -33,7 +38,7 @@ describe("convertToLivekitProcessor", () => {
|
||||
|
||||
it("holds the processed track where LiveKit reads it", async () => {
|
||||
const processor = fakeProcessor();
|
||||
const adapter = convertToLivekitProcessor(processor);
|
||||
const adapter = toLivekitProcessor(processor);
|
||||
expect(adapter.name).toBe("blur");
|
||||
expect(adapter.processedTrack).toBeUndefined();
|
||||
|
||||
@@ -47,4 +52,202 @@ describe("convertToLivekitProcessor", () => {
|
||||
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,30 +5,84 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Track, type TrackProcessor } from "livekit-client";
|
||||
import { 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 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.
|
||||
* 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.
|
||||
*/
|
||||
export function convertToLivekitProcessor(
|
||||
export function toLivekitProcessor(
|
||||
processor: VideoProcessor,
|
||||
): TrackProcessor<Track.Kind.Video> {
|
||||
const adapter: TrackProcessor<Track.Kind.Video> = {
|
||||
running?: MediaStreamTrack,
|
||||
): LivekitProcessor {
|
||||
let built = false;
|
||||
const adapter: LivekitProcessor = {
|
||||
name: processor.name,
|
||||
init: async (opts) => {
|
||||
adapter.processedTrack = await processor.init(opts.track, opts.element);
|
||||
},
|
||||
restart: async (opts) => {
|
||||
adapter.processedTrack = await processor.restart(
|
||||
opts.track,
|
||||
opts.element,
|
||||
);
|
||||
},
|
||||
destroy: async () => processor.destroy(),
|
||||
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();
|
||||
},
|
||||
restart: async (track, element) => {
|
||||
await processor.restart({ kind: Track.Kind.Video, track, element });
|
||||
return processedTrack();
|
||||
},
|
||||
destroy: async () => processor.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
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 { ProcessorProvider } from "./tracks/TrackProcessorContext";
|
||||
import { BackgroundEffectsProvider } from "./tracks/BackgroundEffectsContext";
|
||||
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}>
|
||||
<ProcessorProvider>
|
||||
<BackgroundEffectsProvider effects={vm.backgroundEffects}>
|
||||
<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>
|
||||
</ProcessorProvider>
|
||||
</BackgroundEffectsProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
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,
|
||||
} 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}) => {
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
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}
|
||||
</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,6 +22,7 @@ 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";
|
||||
@@ -123,8 +124,19 @@ export const Default: Story = {
|
||||
toggleAudio: fn(),
|
||||
toggleVideo: fn(),
|
||||
toggleScreenSharing: fn(),
|
||||
toggleBlur: fn(),
|
||||
videoBlurEnabled: true,
|
||||
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,
|
||||
hangup: fn(),
|
||||
buttonSize: "lg",
|
||||
showFooter: true,
|
||||
|
||||
@@ -7,6 +7,7 @@ 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";
|
||||
@@ -31,6 +32,7 @@ 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";
|
||||
|
||||
export interface AudioOutputSwitcher {
|
||||
targetOutput: string;
|
||||
@@ -56,20 +58,29 @@ export interface FooterActions {
|
||||
toggleAudio: (() => void) | undefined;
|
||||
/** Also controls if the videoMute button is disabled */
|
||||
toggleVideo: (() => void) | undefined;
|
||||
toggleBlur: (() => void) | undefined;
|
||||
/** Undefined where background effects can't be chosen. */
|
||||
selectBackgroundEffect: ((id: string) => 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;
|
||||
videoBlurEnabled: 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;
|
||||
showFooter: boolean;
|
||||
|
||||
/* This is needed for WindowMode = "flat" */
|
||||
@@ -117,12 +128,14 @@ 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$);
|
||||
@@ -151,8 +164,11 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$);
|
||||
const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$);
|
||||
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
|
||||
const toggleBlur = useBehavior(vm.toggleBlur$);
|
||||
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
|
||||
const backgroundEffect = useBehavior(vm.backgroundEffect$);
|
||||
const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
|
||||
const backgroundEffectNotice = useBehavior(vm.backgroundEffectNotice$);
|
||||
const backgroundEffectSettling = useBehavior(vm.backgroundEffectSettling$);
|
||||
const backgroundEffects = useBehavior(vm.backgroundEffects$);
|
||||
const buttonSize = useBehavior(vm.buttonSize$);
|
||||
const showLogo = useBehavior(vm.showLogo$);
|
||||
|
||||
@@ -214,8 +230,17 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
options={videoOptions}
|
||||
selectedOption={selectedVideo}
|
||||
onSelect={selectVideoButtonOption}
|
||||
videoBlurToggleClick={toggleBlur}
|
||||
videoBlurEnabled={videoBlurEnabled}
|
||||
backgroundEffects={backgroundEffects}
|
||||
selectedBackgroundEffect={backgroundEffect}
|
||||
onSelectBackgroundEffect={selectBackgroundEffect}
|
||||
backgroundEffectSettling={backgroundEffectSettling}
|
||||
backgroundEffectNotice={
|
||||
backgroundEffectNotice === "unavailable"
|
||||
? t("background_effects.unavailable")
|
||||
: backgroundEffectNotice === "slow"
|
||||
? t("background_effects.slow")
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -5,17 +5,22 @@ 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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
|
||||
import { constant } from "../state/Behavior";
|
||||
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import type { Alignment, Layout } from "../state/layout-types";
|
||||
import type { Layout } from "../state/layout-types";
|
||||
import type { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
import type { DeviceLabel } from "../state/MediaDevices";
|
||||
import { createCallFooterViewModel } from "./CallFooterViewModel";
|
||||
import {
|
||||
createCallFooterViewModel,
|
||||
createLobbyFooterViewModel,
|
||||
} from "./CallFooterViewModel";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
|
||||
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
|
||||
|
||||
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../Platform", () => ({
|
||||
@@ -24,10 +29,12 @@ vi.mock("../Platform", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Prevent supportsBackgroundProcessors from throwing in jsdom – it is not
|
||||
// exercised by these tests (only used in `videoToggles`, not `videoOptions`).
|
||||
// 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));
|
||||
vi.mock("@livekit/track-processors", () => ({
|
||||
supportsBackgroundProcessors: (): boolean => false,
|
||||
supportsBackgroundProcessors: (): boolean => sdkSupportMock(),
|
||||
supportsModernBackgroundProcessors: (): boolean => modernRouteMock(),
|
||||
}));
|
||||
|
||||
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
|
||||
@@ -65,10 +72,6 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel {
|
||||
const gridLayout: Layout = {
|
||||
type: "grid",
|
||||
grid: [],
|
||||
spotlightAlignment$: new BehaviorSubject<Alignment>({
|
||||
inline: "end",
|
||||
block: "end",
|
||||
}),
|
||||
setVisibleTiles: (_: number) => {},
|
||||
};
|
||||
|
||||
@@ -109,6 +112,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -151,6 +155,7 @@ describe("createCallFooterViewModel", () => {
|
||||
select: vi.fn(),
|
||||
},
|
||||
}),
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -169,6 +174,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(layout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -191,6 +197,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -222,4 +229,127 @@ 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();
|
||||
});
|
||||
|
||||
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);
|
||||
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,
|
||||
})),
|
||||
]);
|
||||
});
|
||||
|
||||
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("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,15 +5,30 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { combineLatest, map, type Observable, switchMap } from "rxjs";
|
||||
import { supportsBackgroundProcessors } from "@livekit/track-processors";
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
type Observable,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { supportsAudioOutputSelection } from "livekit-client";
|
||||
import {
|
||||
supportsBackgroundProcessors,
|
||||
supportsModernBackgroundProcessors,
|
||||
} from "@livekit/track-processors";
|
||||
|
||||
import {
|
||||
parseEffect,
|
||||
serializeEffect,
|
||||
shippedBackgrounds,
|
||||
} from "../tracks/backgroundEffectCatalog";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import {
|
||||
backgroundBlur as backgroundBlurSettings,
|
||||
backgroundEffect as backgroundEffectSetting,
|
||||
debugTileLayout as debugTileLayoutSetting,
|
||||
} from "../settings/settings";
|
||||
import { type Behavior, constant } from "../state/Behavior";
|
||||
@@ -23,6 +38,7 @@ import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { platform } from "../Platform";
|
||||
import { type FooterSnapshot } from "./CallFooter";
|
||||
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
|
||||
/**
|
||||
* Shared helper: maps MuteStates into the audio/video enabled + toggle behaviors
|
||||
@@ -56,13 +72,14 @@ function buildMuteBehaviors(
|
||||
|
||||
/**
|
||||
* Shared helper: maps MediaDevices into the audio/video device-list behaviors
|
||||
* needed by FooterSnapshot (options, selection, callbacks, blur toggle).
|
||||
* needed by FooterSnapshot (options, selection, callbacks, background effect).
|
||||
*/
|
||||
function buildDeviceBehaviors(
|
||||
scope: ObservableScope,
|
||||
mediaDevices: MediaDevices,
|
||||
/** return empty arrays for audioOptions and videoOptions*/
|
||||
disableSwitcher$: Behavior<boolean>,
|
||||
backgroundEffectSettling$: Observable<boolean>,
|
||||
): Pick<
|
||||
ViewModel<FooterSnapshot>,
|
||||
| "audioOptions$"
|
||||
@@ -74,8 +91,11 @@ function buildDeviceBehaviors(
|
||||
| "videoOptions$"
|
||||
| "selectedVideo$"
|
||||
| "selectVideoButtonOption$"
|
||||
| "toggleBlur$"
|
||||
| "videoBlurEnabled$"
|
||||
| "backgroundEffect$"
|
||||
| "selectBackgroundEffect$"
|
||||
| "backgroundEffects$"
|
||||
| "backgroundEffectNotice$"
|
||||
| "backgroundEffectSettling$"
|
||||
> {
|
||||
const options$ = (
|
||||
available$: Behavior<Map<string, MenuOptions["label"]>>,
|
||||
@@ -92,6 +112,11 @@ function buildDeviceBehaviors(
|
||||
),
|
||||
);
|
||||
|
||||
const supported = supportsBackgroundProcessors();
|
||||
const slow = !supportsModernBackgroundProcessors();
|
||||
const offered$ = disableSwitcher$.pipe(
|
||||
map((switcherDisabled) => !switcherDisabled && supported),
|
||||
);
|
||||
return {
|
||||
audioOptions$: scope.behavior(options$(mediaDevices.audioInput.available$)),
|
||||
selectedAudio$: scope.behavior(
|
||||
@@ -116,18 +141,35 @@ function buildDeviceBehaviors(
|
||||
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
|
||||
toggleBlur$: scope.behavior(
|
||||
combineLatest([backgroundBlurSettings.value$, disableSwitcher$]).pipe(
|
||||
map(([current, switcherDisabled]) => {
|
||||
return !switcherDisabled && supportsBackgroundProcessors()
|
||||
? (): void => {
|
||||
backgroundBlurSettings.setValue(!current);
|
||||
}
|
||||
: undefined;
|
||||
}),
|
||||
backgroundEffect$: scope.behavior(
|
||||
backgroundEffectSetting.value$.pipe(
|
||||
map((raw) => (supported ? serializeEffect(parseEffect(raw)) : "none")),
|
||||
),
|
||||
),
|
||||
videoBlurEnabled$: backgroundBlurSettings.value$,
|
||||
selectBackgroundEffect$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
offered
|
||||
? (id: string): void =>
|
||||
backgroundEffectSetting.setValue(
|
||||
serializeEffect(parseEffect(id)),
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundEffects$: constant(backgroundEffectChoices()),
|
||||
backgroundEffectNotice$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
!offered ? "unavailable" : slow ? "slow" : undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundEffectSettling$: scope.behavior(
|
||||
backgroundEffectSettling$.pipe(distinctUntilChanged()),
|
||||
false,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,6 +180,8 @@ 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.
|
||||
@@ -148,6 +192,7 @@ export function createCallFooterViewModel(
|
||||
callModel: CallViewModel,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
backgroundEffectSettling$: Observable<boolean>,
|
||||
reactionIdentifier: string | undefined,
|
||||
options: { showControls: boolean; header: HeaderStyle },
|
||||
): ViewModel<FooterSnapshot> {
|
||||
@@ -162,7 +207,12 @@ export function createCallFooterViewModel(
|
||||
);
|
||||
return {
|
||||
...buildMuteBehaviors(scope, muteStates),
|
||||
...buildDeviceBehaviors(scope, mediaDevices, disableDeviceSwitcher$),
|
||||
...buildDeviceBehaviors(
|
||||
scope,
|
||||
mediaDevices,
|
||||
disableDeviceSwitcher$,
|
||||
backgroundEffectSettling$,
|
||||
),
|
||||
// candidat to move into the FooterViewModel
|
||||
showFooter$: callModel.showFooter$,
|
||||
hideControls$: constant(!showControls),
|
||||
@@ -224,6 +274,8 @@ 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.
|
||||
@@ -232,6 +284,7 @@ export function createLobbyFooterViewModel(
|
||||
scope: ObservableScope,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
backgroundEffectSettling$: Observable<boolean>,
|
||||
openSettings: (() => void) | undefined,
|
||||
hangup: (() => void) | undefined,
|
||||
showLogo: boolean,
|
||||
@@ -275,6 +328,23 @@ export function createLobbyFooterViewModel(
|
||||
selectVideoButtonOption: undefined,
|
||||
}),
|
||||
...buildMuteBehaviors(scope, muteStates),
|
||||
...buildDeviceBehaviors(scope, mediaDevices, constant(false)),
|
||||
...buildDeviceBehaviors(
|
||||
scope,
|
||||
mediaDevices,
|
||||
constant(false),
|
||||
backgroundEffectSettling$,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function backgroundEffectChoices(): 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,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -36,20 +36,17 @@ Please see LICENSE in the repository root for full details.
|
||||
}
|
||||
}
|
||||
|
||||
/* The width design sets: a long device name wraps. */
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
inline-size: 296px;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
/* Its share of 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);
|
||||
/* 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);
|
||||
/* The raised headings stay raised within the list, under the menu's frame. */
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Compound's heading rule heads each section; the design adds space below it. */
|
||||
@@ -62,23 +59,10 @@ Please see LICENSE in the repository root for full details.
|
||||
margin-block-start: var(--cpd-space-7x);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Radix focuses whatever the pointer is over, so the browser's ring would
|
||||
@@ -92,3 +76,20 @@ 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);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ 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";
|
||||
@@ -59,14 +61,19 @@ 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 }> = ({ children }) => {
|
||||
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 [callArea, setCallArea] = useState<HTMLElement | null>(null);
|
||||
return (
|
||||
<div
|
||||
ref={setCallArea}
|
||||
style={{
|
||||
// A call's size: anything smaller makes a short device list scroll.
|
||||
blockSize: 720,
|
||||
blockSize,
|
||||
inlineSize: 1024,
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "center",
|
||||
@@ -97,6 +104,10 @@ 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",
|
||||
@@ -127,8 +138,6 @@ 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 }) => {
|
||||
@@ -167,6 +176,16 @@ 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",
|
||||
@@ -175,9 +194,214 @@ 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(),
|
||||
},
|
||||
};
|
||||
|
||||
/** 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,
|
||||
);
|
||||
|
||||
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");
|
||||
},
|
||||
};
|
||||
|
||||
/** 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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -219,9 +443,8 @@ export const SpeakerAndMicrophoneSections: Story = {
|
||||
await expect(
|
||||
document.body.querySelectorAll("[role='separator']"),
|
||||
).toHaveLength(0);
|
||||
const headings = document.body.querySelectorAll<HTMLElement>(
|
||||
`.${styles.sectionHeading}`,
|
||||
);
|
||||
const headings =
|
||||
document.body.querySelectorAll<HTMLElement>(SECTION_HEADING);
|
||||
await expect(headings).toHaveLength(2);
|
||||
const frame = menu.getBoundingClientRect();
|
||||
for (const heading of headings) {
|
||||
@@ -256,6 +479,37 @@ 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,
|
||||
@@ -402,14 +656,14 @@ export const ManyDevices: Story = {
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
const menu = within(document.body);
|
||||
|
||||
// The sticky wrapper rather than the meter: without a fake microphone (as on
|
||||
// The meter's box rather than its level: 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>(
|
||||
`.${styles.stickyMeter}`,
|
||||
`.${meterStyles.meter}`,
|
||||
);
|
||||
if (element === null) throw new Error("the meter has not rendered yet");
|
||||
return element;
|
||||
@@ -436,10 +690,6 @@ 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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -536,7 +786,7 @@ export const KeyboardReachesEveryDevice: Story = {
|
||||
|
||||
const meter = await waitFor(() => {
|
||||
const element = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.stickyMeter}`,
|
||||
`.${meterStyles.meter}`,
|
||||
);
|
||||
if (element === null) throw new Error("the meter has not rendered yet");
|
||||
return element;
|
||||
@@ -545,9 +795,7 @@ export const KeyboardReachesEveryDevice: Story = {
|
||||
// Drawn over the list: the sticky headings and the meter.
|
||||
const overlays = [
|
||||
meter,
|
||||
...document.body.querySelectorAll<HTMLElement>(
|
||||
`.${styles.sectionHeading}`,
|
||||
),
|
||||
...document.body.querySelectorAll<HTMLElement>(SECTION_HEADING),
|
||||
];
|
||||
const items = within(document.body).getAllByRole("menuitemradio");
|
||||
|
||||
@@ -591,10 +839,7 @@ export const HeadingsStayWhileScrolling: Story = {
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
const group = await menu.findByRole("group", { name: "Microphone" });
|
||||
// By class: the heading is aria-hidden, and its group carries the name.
|
||||
const heading = group.querySelector<HTMLElement>(
|
||||
`.${styles.sectionHeading}`,
|
||||
)!;
|
||||
const heading = group.querySelector<HTMLElement>(":scope > [aria-hidden]")!;
|
||||
|
||||
// Far enough that the heading is only on screen if it is stuck there.
|
||||
list.scrollTop +=
|
||||
@@ -607,13 +852,6 @@ 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,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -628,31 +866,23 @@ function overlapping(element: Element, overlays: Element[]): number {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** 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(),
|
||||
},
|
||||
/** The effect tiles get the keyboard ring the device rows get. */
|
||||
export const FocusRingCoversTheEffectTiles: Story = {
|
||||
args: VideoUnmute.args,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const toggle = await within(document.body).findByRole("menuitemcheckbox", {
|
||||
name: /Blur background/,
|
||||
const tile = await within(document.body).findByRole("menuitemradio", {
|
||||
name: "Blur",
|
||||
});
|
||||
|
||||
// Arrow down past the cameras to the toggle.
|
||||
for (let i = 0; i < 6 && document.activeElement !== toggle; i++)
|
||||
for (let i = 0; i < 6 && document.activeElement !== tile; i++)
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
await expect(document.activeElement).toBe(toggle);
|
||||
// The same ring the device rows get.
|
||||
await expect(outlineWidth(toggle)).toBeGreaterThan(0);
|
||||
await expect(document.activeElement).toBe(tile);
|
||||
await expect(outlineWidth(tile)).toBeGreaterThan(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);
|
||||
await userEvent.hover(tile);
|
||||
await expect(document.activeElement).toBe(tile);
|
||||
await expect(outlineWidth(tile)).toBe(0);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { axe } from "vitest-axe";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
@@ -22,10 +23,18 @@ 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;
|
||||
}
|
||||
@@ -305,30 +314,88 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
expect(mic2ItemAfter.querySelector(".rotate")).toBeNull();
|
||||
});
|
||||
|
||||
test("renders menu with toggle control and calls toggle callback", async () => {
|
||||
test("background effects are one keyboard-operable labelled choice", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
const onVideoBlurToggle = vi.fn();
|
||||
const onSelectBackgroundEffect = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="video"
|
||||
enabled={true}
|
||||
videoBlurToggleClick={onVideoBlurToggle}
|
||||
onSelect={onSelect}
|
||||
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
|
||||
selectedOption="cam1"
|
||||
onSelect={vi.fn()}
|
||||
backgroundEffects={effects}
|
||||
selectedBackgroundEffect="none"
|
||||
onSelectBackgroundEffect={onSelectBackgroundEffect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Camera" }));
|
||||
|
||||
const toggle = screen.getByRole("menuitemcheckbox", {
|
||||
name: "Blur background",
|
||||
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,
|
||||
});
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
await user.click(toggle);
|
||||
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();
|
||||
});
|
||||
|
||||
expect(onVideoBlurToggle).toHaveBeenCalled();
|
||||
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("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();
|
||||
}
|
||||
});
|
||||
|
||||
test("marks the selected menu item as checked", async () => {
|
||||
@@ -552,7 +619,9 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
]}
|
||||
selectedOption="cam1"
|
||||
onSelect={vi.fn()}
|
||||
videoBlurToggleClick={vi.fn()}
|
||||
backgroundEffects={effects}
|
||||
selectedBackgroundEffect="none"
|
||||
onSelectBackgroundEffect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -560,9 +629,10 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
|
||||
screen.getByRole("menuitemradio", { name: "Camera 1", checked: true });
|
||||
screen.getByRole("menuitemradio", { name: "Camera 2", checked: false });
|
||||
expect(
|
||||
screen.getByRole("menuitemcheckbox", { name: "Blur background" }),
|
||||
).toBeInTheDocument();
|
||||
within(screen.getByRole("group", { name: "Background effects" })).getByRole(
|
||||
"menuitemradio",
|
||||
{ name: "Blur" },
|
||||
);
|
||||
});
|
||||
|
||||
test("marks focus as keyboard-driven only when the keyboard moved it", async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useId,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type FC,
|
||||
@@ -17,13 +18,14 @@ import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuTitle,
|
||||
MenuScrollArea,
|
||||
MenuScrollSection,
|
||||
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";
|
||||
@@ -40,6 +42,10 @@ 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;
|
||||
@@ -64,8 +70,15 @@ export interface MediaMuteAndSwitchButtonProps {
|
||||
selectedOutputOption?: string;
|
||||
/** Picks an output device. Undefined disables the speaker section. */
|
||||
onSelectOutput?: (id: string) => void;
|
||||
videoBlurToggleClick?: () => void;
|
||||
videoBlurEnabled?: boolean;
|
||||
/** 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;
|
||||
/**
|
||||
* 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`
|
||||
@@ -73,8 +86,6 @@ 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";
|
||||
|
||||
@@ -84,6 +95,9 @@ 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,
|
||||
@@ -94,8 +108,11 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
outputOptions,
|
||||
selectedOutputOption,
|
||||
onSelectOutput,
|
||||
videoBlurEnabled,
|
||||
videoBlurToggleClick,
|
||||
backgroundEffects,
|
||||
selectedBackgroundEffect,
|
||||
onSelectBackgroundEffect,
|
||||
backgroundEffectNotice,
|
||||
backgroundEffectSettling,
|
||||
onSelect,
|
||||
}) => {
|
||||
// Requested but not yet selected. Keyed by kind too, since Chrome uses
|
||||
@@ -113,6 +130,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
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
|
||||
@@ -157,18 +175,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 MuteButton = iconsAndLabels === "audio" ? MicButton : VideoButton;
|
||||
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 button = (
|
||||
<MuteButton
|
||||
enabled={enabled ?? false}
|
||||
@@ -179,22 +197,12 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
e.stopPropagation();
|
||||
}}
|
||||
disabled={isBusy || onMuteClick === undefined}
|
||||
data-testid={
|
||||
iconsAndLabels === "audio" ? "incall_mute" : "incall_videomute"
|
||||
}
|
||||
data-testid={audioMeter ? "incall_mute" : "incall_videomute"}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggles =
|
||||
iconsAndLabels === "video" && videoBlurToggleClick !== undefined
|
||||
? [
|
||||
{
|
||||
label: t("action.blur_background"),
|
||||
enabled: videoBlurEnabled ?? false,
|
||||
id: BLUR_ID,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const showEffects =
|
||||
iconsAndLabels === "video" && (backgroundEffects?.length ?? 0) > 0;
|
||||
|
||||
let optionsButtonLabel: string;
|
||||
let menuTitle: string;
|
||||
@@ -326,6 +334,8 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
open={menuOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
side="top"
|
||||
collisionBoundary={rootElement}
|
||||
collisionPadding={MENU_MARGIN}
|
||||
trigger={
|
||||
<Button
|
||||
iconOnly
|
||||
@@ -340,101 +350,62 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div
|
||||
<MenuScrollArea
|
||||
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
|
||||
}
|
||||
>
|
||||
{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>
|
||||
{deviceItems(
|
||||
"output",
|
||||
speakerOptions,
|
||||
selectedSpeaker,
|
||||
onSelectOutput,
|
||||
(n) => t("settings.devices.speaker_numbered", { n }),
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<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">
|
||||
{audioMeter && speakerOptions && (
|
||||
<MenuScrollSection title={t("settings.devices.speaker")}>
|
||||
{deviceItems(
|
||||
"input",
|
||||
options,
|
||||
selectedOption,
|
||||
onSelect,
|
||||
numberedLabel,
|
||||
"output",
|
||||
speakerOptions,
|
||||
selectedSpeaker,
|
||||
onSelectOutput,
|
||||
(n) => t("settings.devices.speaker_numbered", { n }),
|
||||
)}
|
||||
{iconsAndLabels === "audio" && (
|
||||
<LiveMicrophoneLevelMeter
|
||||
ref={meter}
|
||||
deviceId={selectedOption}
|
||||
// Only while open, so the microphone isn't held all call.
|
||||
active={menuOpen}
|
||||
className={styles.stickyMeter}
|
||||
/>
|
||||
</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
|
||||
}
|
||||
/>
|
||||
{/* In the list, so the menu grows no taller for it. */}
|
||||
{backgroundEffectNotice !== undefined && (
|
||||
<div id={noticeId} role="none" className={styles.notice}>
|
||||
<InfoIcon width={20} height={20} />
|
||||
<span>{backgroundEffectNotice}</span>
|
||||
</div>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
))}
|
||||
</MenuScrollSection>
|
||||
)}
|
||||
</MenuScrollArea>
|
||||
</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 the row's inset border and half
|
||||
the icon's extra width. Checked in the MeterAlignsWithTheDeviceRows story. */
|
||||
/* Centres the icon on the radio column: less half the icon's extra width.
|
||||
Checked in the MeterAlignsWithTheDeviceRows story. */
|
||||
padding-inline-start: calc(
|
||||
var(--cpd-space-4x) - var(--cpd-border-width-1) -
|
||||
var(--cpd-space-4x) -
|
||||
(var(--meter-icon-size) - var(--device-control-size)) / 2
|
||||
);
|
||||
padding-inline-end: calc(var(--cpd-space-4x) * 2 + var(--cpd-space-2x));
|
||||
|
||||
@@ -5,17 +5,9 @@ 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,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { useCallback, useLayoutEffect, useRef, useState, type FC } 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";
|
||||
@@ -30,8 +22,6 @@ export interface LiveMicrophoneLevelMeterProps {
|
||||
deviceId: string | undefined;
|
||||
/** Whether to hold a capture at all. */
|
||||
active: boolean;
|
||||
className?: string;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,24 +31,18 @@ export interface LiveMicrophoneLevelMeterProps {
|
||||
export const LiveMicrophoneLevelMeter: FC<LiveMicrophoneLevelMeterProps> = ({
|
||||
deviceId,
|
||||
active,
|
||||
className,
|
||||
ref,
|
||||
}) => {
|
||||
const state = useMicrophoneLevel(deviceId, active);
|
||||
return <MicrophoneLevelMeter state={state} className={className} ref={ref} />;
|
||||
return <MicrophoneLevelMeter state={state} />;
|
||||
};
|
||||
|
||||
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
|
||||
@@ -104,7 +88,7 @@ export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
|
||||
}, [state, barCount, t]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={classNames(styles.meter, className)}>
|
||||
<div className={styles.meter}>
|
||||
<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_0_"
|
||||
aria-labelledby="_r_1_"
|
||||
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_5_"
|
||||
id="radix-_r_6_"
|
||||
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,11 +5,24 @@ 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 } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { E2eeType } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import { getKeyForRoom, saveKeyForRoom } from "./sharedKeyManagement";
|
||||
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 }) };
|
||||
});
|
||||
|
||||
const roomId = "!room:example.org";
|
||||
const otherRoomId = "!other:example.org";
|
||||
|
||||
describe("getKeyForRoom", () => {
|
||||
afterEach(() => {
|
||||
@@ -33,4 +46,58 @@ 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,22 +59,27 @@ 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.
|
||||
* 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.
|
||||
*/
|
||||
function keyForRoom(
|
||||
roomId: string,
|
||||
paramsRoomId: string | null,
|
||||
password: string | null,
|
||||
): string | null {
|
||||
if (roomId !== paramsRoomId)
|
||||
const storedKey = localStorage.getItem(
|
||||
getRoomSharedKeyLocalStorageKey(roomId),
|
||||
);
|
||||
if (roomId !== paramsRoomId) {
|
||||
logger.warn(
|
||||
"requested key for a roomId which is not the current call room id (from the URL)",
|
||||
roomId,
|
||||
paramsRoomId,
|
||||
);
|
||||
return (
|
||||
password ?? localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId))
|
||||
);
|
||||
return storedKey;
|
||||
}
|
||||
return password ?? storedKey;
|
||||
}
|
||||
|
||||
export function getKeyForRoom(roomId: string): string | null {
|
||||
@@ -89,16 +94,12 @@ export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
||||
const { roomId: paramsRoomId, password } = useUrlParams();
|
||||
|
||||
const [storedPassword] = useRoomSharedKey(
|
||||
// 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),
|
||||
roomId,
|
||||
keyForRoom(roomId, paramsRoomId, password) ?? undefined,
|
||||
);
|
||||
|
||||
const room = client?.getRoom(roomId);
|
||||
const e2eeSystem = <EncryptionSystem>useMemo(() => {
|
||||
const e2eeSystem = useMemo<EncryptionSystem>(() => {
|
||||
if (!room) return { kind: E2eeType.NONE };
|
||||
if (storedPassword)
|
||||
return {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
+100
-150
@@ -5,28 +5,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { arrangeTiles, type Bounds } from "./CallLayout";
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import { makeGridLayout } from "./GridLayout";
|
||||
import { makeSpotlightLandscapeLayout } from "./SpotlightLandscapeLayout";
|
||||
import { makeSpotlightPortraitLayout } from "./SpotlightPortraitLayout";
|
||||
import { type DragCallback, type LayoutProps } from "./Grid";
|
||||
import { type LayoutProps } from "./Grid";
|
||||
import { constant } from "../state/Behavior";
|
||||
import {
|
||||
type Alignment,
|
||||
type GridLayout,
|
||||
type SpotlightLandscapeLayout,
|
||||
type SpotlightPortraitLayout,
|
||||
} from "../state/layout-types";
|
||||
import { type Alignment } 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
|
||||
@@ -36,152 +33,105 @@ vi.mock("./Grid", async (importOriginal) => ({
|
||||
useVisibleTiles: (): void => {},
|
||||
}));
|
||||
|
||||
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 minBounds$ = constant({ width: 800, height: 800 });
|
||||
|
||||
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 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)),
|
||||
);
|
||||
const tiles = (): GridTileViewModel[] =>
|
||||
Array.from({ length: 3 }, () => new GridTileViewModel(constant(media)));
|
||||
const pip = {
|
||||
alignment$: constant<Alignment>({ inline: "end", block: "end" }),
|
||||
onDrag: () => {},
|
||||
vm: { id: "pip" } as GridTileViewModel,
|
||||
size$: constant("sm" as const),
|
||||
};
|
||||
|
||||
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
|
||||
}) => {
|
||||
drags.set(id, onDrag);
|
||||
return (
|
||||
<div data-testid={`slot-${id}`} {...props}>
|
||||
{model instanceof GridTileViewModel ? model.id : "spotlight"}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}) => (
|
||||
<div data-testid={`slot-${id}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
function slotIds(): string[] {
|
||||
return screen
|
||||
.getAllByTestId(/^slot-/)
|
||||
.map((slot) => slot.textContent ?? "")
|
||||
.filter((id) => id !== "spotlight");
|
||||
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}
|
||||
</>
|
||||
),
|
||||
);
|
||||
expect(container).toMatchSnapshot();
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
+12
-29
@@ -40,6 +40,7 @@ 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
|
||||
|
||||
@@ -83,7 +84,10 @@ interface DragState {
|
||||
cursorY: number;
|
||||
}
|
||||
|
||||
interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
|
||||
export interface SlotProps<Model> extends Omit<
|
||||
ComponentProps<"div">,
|
||||
"onDrag"
|
||||
> {
|
||||
id: string;
|
||||
model: Model;
|
||||
onDrag?: DragCallback;
|
||||
@@ -181,27 +185,6 @@ 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,
|
||||
@@ -464,13 +447,13 @@ export function Grid<
|
||||
)
|
||||
.catch(logger.error);
|
||||
|
||||
if (endOfGesture)
|
||||
callback({
|
||||
x: tileX,
|
||||
y: tileY,
|
||||
xRatio: tileX / (gridBounds.width - tile.width),
|
||||
yRatio: tileY / (gridBounds.height - tile.height),
|
||||
});
|
||||
callback({
|
||||
x: tileX,
|
||||
y: tileY,
|
||||
xRatio: tileX / (gridBounds.width - tile.width),
|
||||
yRatio: tileY / (gridBounds.height - tile.height),
|
||||
endOfGesture,
|
||||
});
|
||||
};
|
||||
|
||||
// Callback for useDrag. We could call useDrag here, but the default
|
||||
|
||||
+30
-25
@@ -8,15 +8,21 @@ 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 DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid";
|
||||
import { type SlotProps, 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;
|
||||
@@ -24,6 +30,27 @@ 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.
|
||||
@@ -37,32 +64,10 @@ 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 && (
|
||||
<Slot
|
||||
className={styles.slot}
|
||||
id="spotlight"
|
||||
model={model.spotlight}
|
||||
onDrag={onDragSpotlight}
|
||||
data-block-alignment={alignment.block}
|
||||
data-inline-alignment={alignment.inline}
|
||||
/>
|
||||
<SpotlightSlot model={model.spotlight} Slot={Slot} />
|
||||
)}
|
||||
</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, useCallback, useMemo } from "react";
|
||||
import { type ReactNode, 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 { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { useUpdateLayout } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
/**
|
||||
@@ -37,21 +37,12 @@ export const makeOneOnOneDesktopLayout: CallLayout<
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const { width, height } = useBehavior(minBounds$);
|
||||
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||
const pipAlignment = useBehavior(model.pip.alignment$);
|
||||
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
|
||||
@@ -62,9 +53,9 @@ export const makeOneOnOneDesktopLayout: CallLayout<
|
||||
>
|
||||
<Slot
|
||||
className={classNames(styles.slot, styles.local)}
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
onDrag={onDragLocalTile}
|
||||
id={model.pip.vm.id}
|
||||
model={model.pip.vm}
|
||||
onDrag={model.pip.onDrag}
|
||||
data-block-alignment={pipAlignment.block}
|
||||
data-inline-alignment={pipAlignment.inline}
|
||||
/>
|
||||
|
||||
@@ -6,14 +6,43 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
import { type ComponentType, type FC, type ReactNode } 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 DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { type SlotProps, 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
|
||||
@@ -44,30 +73,9 @@ 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 && (
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -5,13 +5,39 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
import { type ReactNode, type ComponentType, type FC } from "react";
|
||||
|
||||
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/layout-types.ts";
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { type SlotProps, 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
|
||||
@@ -28,7 +54,6 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<Slot
|
||||
@@ -46,29 +71,9 @@ 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 && (
|
||||
<Slot
|
||||
className={styles.pip}
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
onDrag={onDragPip}
|
||||
data-block-alignment={pipAlignment.block}
|
||||
data-inline-alignment={pipAlignment.inline}
|
||||
/>
|
||||
)}
|
||||
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
<Slot
|
||||
className={styles.slot}
|
||||
id="spotlight"
|
||||
model={model.spotlight}
|
||||
model={model.spotlight.vm}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.grid} />
|
||||
@@ -59,7 +59,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<SpotlightSlot media$={model.spotlight.media$} />
|
||||
<SpotlightSlot media$={model.spotlight.vm.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}
|
||||
model={model.spotlight.vm}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,7 +65,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
||||
width,
|
||||
model.grid.length,
|
||||
);
|
||||
const withIndicators = useBehavior(model.spotlight.media$).length > 1;
|
||||
const withIndicators = useBehavior(model.spotlight.vm.media$).length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// 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,6 +50,7 @@ import {
|
||||
mockMediaDevices,
|
||||
mockRtcMembership,
|
||||
MockRTCSession,
|
||||
testScope,
|
||||
} from "../utils/test";
|
||||
import { CallView } from "./CallView";
|
||||
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
|
||||
@@ -61,7 +62,10 @@ import {
|
||||
} from "../HostBridge";
|
||||
import { type JoinCallData } from "../widget";
|
||||
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
||||
import { ProcessorProvider } from "../tracks/TrackProcessorContext";
|
||||
import {
|
||||
BackgroundEffectsProvider,
|
||||
createAppBackgroundEffects,
|
||||
} from "../tracks/BackgroundEffectsContext";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { constant } from "../state/Behavior";
|
||||
|
||||
@@ -193,7 +197,9 @@ function createCallView(
|
||||
<HostBridgeProvider value={hostBridge}>
|
||||
<TooltipProvider>
|
||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||
<ProcessorProvider>
|
||||
<BackgroundEffectsProvider
|
||||
effects={createAppBackgroundEffects(testScope())}
|
||||
>
|
||||
{options.withErrorBoundary ? (
|
||||
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
|
||||
{callView}
|
||||
@@ -201,7 +207,7 @@ function createCallView(
|
||||
) : (
|
||||
callView
|
||||
)}
|
||||
</ProcessorProvider>
|
||||
</BackgroundEffectsProvider>
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>
|
||||
</HostBridgeProvider>
|
||||
|
||||
@@ -72,6 +72,7 @@ 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
|
||||
@@ -273,6 +274,8 @@ 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> => {
|
||||
@@ -500,6 +503,7 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
<LobbyView
|
||||
client={client}
|
||||
matrixInfo={matrixInfo}
|
||||
media={media}
|
||||
muteStates={muteStates}
|
||||
onEnter={() => setJoined(true)}
|
||||
confineToRoom={confineToRoom}
|
||||
@@ -529,6 +533,7 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
rtcSession={rtcSession as MatrixRTCSession}
|
||||
matrixRoom={room}
|
||||
onLeft={onLeft}
|
||||
media={media}
|
||||
muteStates={muteStates}
|
||||
e2eeSystem={e2eeSystem}
|
||||
//otelGroupCallMembership={otelGroupCallMembership}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type MockRTCSession,
|
||||
mockRTCSlot,
|
||||
mockRTCMember,
|
||||
testScope,
|
||||
} from "../utils/test";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import {
|
||||
@@ -48,7 +49,10 @@ import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
|
||||
import { AppBar } from "../AppBar";
|
||||
import { type MatrixInfo } from "./VideoPreview";
|
||||
import { ProcessorProvider } from "../tracks/TrackProcessorContext";
|
||||
import {
|
||||
BackgroundEffectsProvider,
|
||||
createAppBackgroundEffects,
|
||||
} from "../tracks/BackgroundEffectsContext";
|
||||
import { initializeWidget } from "../widget";
|
||||
import { RootElementProvider } from "../RootElementContext";
|
||||
|
||||
@@ -227,6 +231,18 @@ 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({});
|
||||
@@ -234,7 +250,9 @@ describe("ActiveCall", () => {
|
||||
const { findByTestId } = render(
|
||||
<BrowserRouter>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<BackgroundEffectsProvider
|
||||
effects={createAppBackgroundEffects(testScope())}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<ActiveCall
|
||||
client={matrixRoom.client}
|
||||
@@ -244,10 +262,11 @@ describe("ActiveCall", () => {
|
||||
matrixInfo={matrixInfo}
|
||||
onShareClick={null}
|
||||
e2eeSystem={{ kind: E2eeType.NONE }}
|
||||
media={fakeLocalMedia()}
|
||||
onLeft={(): void => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ProcessorProvider>
|
||||
</BackgroundEffectsProvider>
|
||||
</MediaDevicesContext>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
@@ -282,7 +301,9 @@ describe("ActiveCall", () => {
|
||||
<BrowserRouter>
|
||||
<RootElementProvider value={root}>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<BackgroundEffectsProvider
|
||||
effects={createAppBackgroundEffects(testScope())}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<ActiveCall
|
||||
client={matrixRoom.client}
|
||||
@@ -292,10 +313,11 @@ describe("ActiveCall", () => {
|
||||
matrixInfo={matrixInfo}
|
||||
onShareClick={null}
|
||||
e2eeSystem={{ kind: E2eeType.NONE }}
|
||||
media={fakeLocalMedia()}
|
||||
onLeft={(): void => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ProcessorProvider>
|
||||
</BackgroundEffectsProvider>
|
||||
</MediaDevicesContext>
|
||||
</RootElementProvider>
|
||||
</BrowserRouter>,
|
||||
|
||||
+20
-6
@@ -50,6 +50,7 @@ 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";
|
||||
@@ -109,7 +110,7 @@ import { useBehavior } from "../useBehavior.ts";
|
||||
import { useValueBehavior } from "../useValueBehavior.ts";
|
||||
import { Toast } from "../Toast.tsx";
|
||||
import overlayStyles from "../Overlay.module.css";
|
||||
import { useTrackProcessorState$ } from "../tracks/TrackProcessorContext.tsx";
|
||||
import { useBackgroundEffectsState$ } from "../tracks/BackgroundEffectsContext";
|
||||
import { type Layout } from "../state/layout-types.ts";
|
||||
import { ObservableScope } from "../state/ObservableScope.ts";
|
||||
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
|
||||
@@ -133,6 +134,8 @@ 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",
|
||||
@@ -150,7 +153,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const urlParams = useUrlParams();
|
||||
const hostBridge = useHostBridge();
|
||||
const mediaDevices = useMediaDevices();
|
||||
const trackProcessorState$ = useTrackProcessorState$();
|
||||
const backgroundEffectsState$ = useBackgroundEffectsState$();
|
||||
// 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();
|
||||
@@ -170,7 +173,16 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
);
|
||||
const rtcParticipation = slot.join({
|
||||
...rtcParticipationOptions(urlParams),
|
||||
publish: initialPublishRequests(props.muteStates, mediaDevices, capture),
|
||||
// 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,
|
||||
),
|
||||
});
|
||||
|
||||
const vm = createCallViewModel$(
|
||||
@@ -184,7 +196,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
encryptionSystem: props.e2eeSystem,
|
||||
capture,
|
||||
videoProcessor$: scope.behavior(
|
||||
trackProcessorState$.pipe(map((state) => state.processor)),
|
||||
backgroundEffectsState$.pipe(map((state) => state.processor)),
|
||||
),
|
||||
hostBridge,
|
||||
autoLeaveWhenOthersLeft,
|
||||
@@ -214,8 +226,9 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
urlParams,
|
||||
hostBridge,
|
||||
mediaDevices,
|
||||
trackProcessorState$,
|
||||
backgroundEffectsState$,
|
||||
props.client,
|
||||
props.media,
|
||||
rootElement,
|
||||
]);
|
||||
|
||||
@@ -228,6 +241,7 @@ 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 },
|
||||
);
|
||||
@@ -245,7 +259,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
props.onLeft,
|
||||
urlParams,
|
||||
mediaDevices,
|
||||
trackProcessorState$,
|
||||
backgroundEffectsState$,
|
||||
props.client,
|
||||
vm,
|
||||
]);
|
||||
|
||||
@@ -10,23 +10,41 @@ 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 ProcessorState } from "../tracks/TrackProcessorContext";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import { mockMediaDevices } from "../utils/test";
|
||||
|
||||
vi.mock("@livekit/components-react", () => ({
|
||||
usePreviewTracks: (): unknown[] => [],
|
||||
// One instance each: the lobby keys effects on them
|
||||
const stable = vi.hoisted(() => ({
|
||||
processorState$: undefined as unknown,
|
||||
media: undefined as unknown,
|
||||
}));
|
||||
|
||||
vi.mock("../tracks/TrackProcessorContext", () => ({
|
||||
useTrackProcessor: (): ProcessorState => ({
|
||||
supported: false,
|
||||
processor: undefined,
|
||||
}),
|
||||
useTrackProcessorSync: (): void => {},
|
||||
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("react-use-measure", () => ({
|
||||
|
||||
@@ -13,6 +13,7 @@ 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;
|
||||
@@ -49,6 +50,7 @@ export const KnockLobbyView: FC<Props> = ({
|
||||
}): ReactNode => {
|
||||
const { t } = useTranslation();
|
||||
const muteStates = useMuteStates();
|
||||
const media = useLocalMedia();
|
||||
|
||||
if (muteStates === null) return null;
|
||||
|
||||
@@ -85,6 +87,7 @@ export const KnockLobbyView: FC<Props> = ({
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={hideHeader}
|
||||
participantCount={null}
|
||||
media={media}
|
||||
muteStates={muteStates}
|
||||
onShareClick={null}
|
||||
/>
|
||||
|
||||
+26
-10
@@ -12,6 +12,11 @@ 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,
|
||||
@@ -28,7 +33,7 @@ import {
|
||||
} from "../utils/test";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type ProcessorState } from "../tracks/TrackProcessorContext";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import lobbyStyles from "./LobbyView.module.css";
|
||||
import headerStyles from "../Header.module.css";
|
||||
@@ -37,17 +42,27 @@ import { AppBar } from "../AppBar";
|
||||
// Somewhere to go home to, so that the lobby offers the way back
|
||||
const leaveToHome = vi.fn();
|
||||
|
||||
vi.mock("@livekit/components-react", () => ({
|
||||
usePreviewTracks: (): unknown[] => [],
|
||||
// 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("../tracks/TrackProcessorContext", () => ({
|
||||
useTrackProcessor: (): ProcessorState => ({
|
||||
supported: false,
|
||||
processor: undefined,
|
||||
}),
|
||||
useTrackProcessorSync: (): void => {},
|
||||
}));
|
||||
// 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("react-use-measure", () => ({
|
||||
default: (): [() => void, object] => [(): void => {}, {}],
|
||||
@@ -95,6 +110,7 @@ function renderLobbyView(
|
||||
<LobbyView
|
||||
client={mockClient}
|
||||
matrixInfo={matrixInfo}
|
||||
media={fakeLocalMedia()}
|
||||
muteStates={muteStates}
|
||||
onEnter={() => {}}
|
||||
confineToRoom={false}
|
||||
|
||||
+47
-72
@@ -5,26 +5,18 @@ 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,
|
||||
useMemo,
|
||||
useState,
|
||||
type JSX,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { type FC, useCallback, 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 {
|
||||
type CreateLocalTracksOptions,
|
||||
type LocalVideoTrack,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { convertToLivekitProcessor } from "@element-hq/matrixrtc-sdk";
|
||||
constant,
|
||||
type LocalMedia,
|
||||
type LocalVideoMediaTrack,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { map } from "rxjs";
|
||||
|
||||
import inCallStyles from "./InCallView.module.css";
|
||||
import styles from "./LobbyView.module.css";
|
||||
@@ -39,22 +31,26 @@ import { LeaveToHomeLink } from "../button/LeaveToHomeLink";
|
||||
import { useLeaveToHome } from "../LeaveToHomeContext";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { ObservableScope } from "../state/ObservableScope";
|
||||
import { useInitial } from "../useInitial";
|
||||
import {
|
||||
useTrackProcessor,
|
||||
useTrackProcessorSync,
|
||||
} from "../tracks/TrackProcessorContext";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { useBackgroundEffectsState$ } from "../tracks/BackgroundEffectsContext";
|
||||
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;
|
||||
@@ -68,6 +64,7 @@ interface Props {
|
||||
export const LobbyView: FC<Props> = ({
|
||||
client,
|
||||
matrixInfo,
|
||||
media,
|
||||
muteStates,
|
||||
onEnter,
|
||||
enterLabel,
|
||||
@@ -87,7 +84,6 @@ 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$);
|
||||
@@ -126,55 +122,28 @@ export const LobbyView: FC<Props> = ({
|
||||
|
||||
const devices = useMediaDevices();
|
||||
const videoInputId = useBehavior(devices.videoInput.selected$)?.id;
|
||||
const backgroundEffectsState$ = useBackgroundEffectsState$();
|
||||
|
||||
// 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],
|
||||
);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoTrack && videoInputId === undefined) {
|
||||
@@ -185,8 +154,6 @@ export const LobbyView: FC<Props> = ({
|
||||
}
|
||||
}, [devices, videoInputId, videoTrack]);
|
||||
|
||||
useTrackProcessorSync(videoTrack);
|
||||
|
||||
const [footerVm, setFooterVm] = useState<ViewModel<FooterSnapshot> | null>(
|
||||
null,
|
||||
);
|
||||
@@ -197,6 +164,7 @@ 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.
|
||||
@@ -206,7 +174,14 @@ export const LobbyView: FC<Props> = ({
|
||||
return (): void => {
|
||||
footerScope.end();
|
||||
};
|
||||
}, [devices, hangup, hideHeader, muteStates, openSettings]);
|
||||
}, [
|
||||
devices,
|
||||
backgroundEffectsState$,
|
||||
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
|
||||
|
||||
+11
-15
@@ -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 { facingModeFromLocalTrack, type LocalVideoTrack } from "livekit-client";
|
||||
import { constant, type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -15,6 +15,9 @@ 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;
|
||||
@@ -30,7 +33,7 @@ export type MatrixInfo = {
|
||||
interface Props {
|
||||
matrixInfo: MatrixInfo;
|
||||
videoEnabled: boolean;
|
||||
videoTrack: LocalVideoTrack | null;
|
||||
videoTrack: LocalVideoMediaTrack | null;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -46,14 +49,12 @@ export const VideoPreview: FC<Props> = ({
|
||||
const videoEl = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Effect to connect the videoTrack with the video element.
|
||||
if (videoEl.current) {
|
||||
videoTrack?.attach(videoEl.current);
|
||||
}
|
||||
return (): void => {
|
||||
videoTrack?.detach();
|
||||
};
|
||||
const element = videoEl.current;
|
||||
if (!element || !videoTrack) return;
|
||||
videoTrack.attach(element);
|
||||
return (): void => videoTrack.detach(element);
|
||||
}, [videoTrack]);
|
||||
const facingMode = useBehavior(videoTrack?.facingMode$ ?? noFacingMode);
|
||||
|
||||
const cameraIsStarting = useMemo(
|
||||
() => videoEnabled && !videoTrack,
|
||||
@@ -63,12 +64,7 @@ export const VideoPreview: FC<Props> = ({
|
||||
return (
|
||||
<div className={classNames(styles.preview)} ref={previewRef}>
|
||||
<video
|
||||
className={
|
||||
videoTrack &&
|
||||
facingModeFromLocalTrack(videoTrack).facingMode === "user"
|
||||
? styles.mirror
|
||||
: undefined
|
||||
}
|
||||
className={facingMode === "user" ? styles.mirror : undefined}
|
||||
ref={videoEl}
|
||||
muted
|
||||
playsInline
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
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,13 +21,17 @@ import { useHostBridge } from "../HostBridge";
|
||||
import {
|
||||
useSetting,
|
||||
soundEffectVolume as soundEffectVolumeSetting,
|
||||
backgroundBlur as backgroundBlurSetting,
|
||||
backgroundEffect as backgroundEffectSetting,
|
||||
developerMode,
|
||||
} from "./settings";
|
||||
import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
|
||||
import { Slider } from "../Slider";
|
||||
import { DeviceSelection } from "./DeviceSelection";
|
||||
import { useTrackProcessor } from "../tracks/TrackProcessorContext";
|
||||
import { useBackgroundEffects } from "../tracks/BackgroundEffectsContext";
|
||||
import {
|
||||
parseEffect,
|
||||
serializeEffect,
|
||||
} from "../tracks/backgroundEffectCatalog";
|
||||
import {
|
||||
DeveloperSettingsTab,
|
||||
type DeveloperSettingsSnapshot,
|
||||
@@ -78,9 +82,11 @@ export const SettingsModal: FC<Props> = ({
|
||||
|
||||
// Generate a `Checkbox` input to turn blur on or off.
|
||||
const BlurCheckbox: React.FC = (): ReactNode => {
|
||||
const { supported } = useTrackProcessor();
|
||||
const { supported } = useBackgroundEffects();
|
||||
|
||||
const [blurActive, setBlurActive] = useSetting(backgroundBlurSetting);
|
||||
// The camera menu's setting, of which this control sees only blur.
|
||||
const [effect, setEffect] = useSetting(backgroundEffectSetting);
|
||||
const blurActive = parseEffect(effect).kind === "blur";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -94,8 +100,12 @@ export const SettingsModal: FC<Props> = ({
|
||||
supported ? "" : t("settings.blur_not_supported_by_browser")
|
||||
}
|
||||
type="checkbox"
|
||||
checked={!!blurActive}
|
||||
onChange={(b): void => setBlurActive(b.target.checked)}
|
||||
checked={blurActive}
|
||||
onChange={(b): void =>
|
||||
setEffect(
|
||||
serializeEffect({ kind: b.target.checked ? "blur" : "none" }),
|
||||
)
|
||||
}
|
||||
disabled={!supported}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
@@ -13,6 +13,7 @@ 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(
|
||||
@@ -116,7 +117,14 @@ export const videoInput = new Setting<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const backgroundBlur = new Setting<boolean>("background-blur", false);
|
||||
// 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 showHandRaisedTimer = new Setting<boolean>(
|
||||
"hand-raised-show-timer",
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.
|
||||
@@ -16,6 +17,7 @@ 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.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
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 { 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" }),
|
||||
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 fake: ReturnType<typeof fakePipeline>;
|
||||
|
||||
function build(
|
||||
options: Partial<BackgroundEffectsOptions> = {},
|
||||
): BackgroundEffects {
|
||||
return createBackgroundEffects(testScope(), {
|
||||
supported: true,
|
||||
effect$,
|
||||
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");
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
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,
|
||||
imagePathFor,
|
||||
parseEffect,
|
||||
} from "../tracks/backgroundEffectCatalog";
|
||||
|
||||
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>;
|
||||
/** 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$, 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(),
|
||||
);
|
||||
|
||||
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$]).pipe(
|
||||
map(([{ processor }, effect]) =>
|
||||
processor === undefined ? undefined : switchOptionsFor(effect),
|
||||
),
|
||||
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$ };
|
||||
}
|
||||
|
||||
function switchOptionsFor(
|
||||
effect: BackgroundEffect,
|
||||
): SwitchBackgroundProcessorOptions {
|
||||
switch (effect.kind) {
|
||||
case "blur":
|
||||
return { mode: "background-blur", blurRadius };
|
||||
case "shipped": {
|
||||
const imagePath = imagePathFor(effect.id);
|
||||
return imagePath
|
||||
? { mode: "virtual-background", imagePath }
|
||||
: { mode: "disabled" };
|
||||
}
|
||||
default:
|
||||
return { mode: "disabled" };
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import { type Layout } from "../layout-types.ts";
|
||||
import {
|
||||
mockMatrixRoomMember,
|
||||
mockMediaDevices,
|
||||
mockMuteStates,
|
||||
flushPromises,
|
||||
mockRemoteParticipant,
|
||||
withTestScheduler,
|
||||
mockRtcMembership,
|
||||
@@ -62,7 +64,15 @@ import {
|
||||
import { MatrixRTCMode } from "../../config/ConfigOptions.ts";
|
||||
import { initializeWidget } from "../../widget.ts";
|
||||
import { computeUrlParams } from "../../UrlParams.ts";
|
||||
import { callViewModelOptionsFromParams } from "./CallViewModel.ts";
|
||||
import {
|
||||
callViewModelOptionsFromParams,
|
||||
initialPublishRequests,
|
||||
} from "./CallViewModel.ts";
|
||||
import { type Handler, MuteState } from "../MuteStates.ts";
|
||||
import {
|
||||
type RTCParticipation,
|
||||
type VideoProcessor,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
initializeWidget();
|
||||
|
||||
@@ -155,7 +165,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
case "grid":
|
||||
return combineLatest(
|
||||
[
|
||||
l.spotlight?.media$ ?? constant(undefined),
|
||||
l.spotlight?.vm.media$ ?? constant(undefined),
|
||||
...l.grid.map((vm) => vm.media$),
|
||||
],
|
||||
(spotlight, ...grid) => ({
|
||||
@@ -167,7 +177,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
case "spotlight-landscape":
|
||||
case "spotlight-portrait":
|
||||
return combineLatest(
|
||||
[l.spotlight.media$, ...l.grid.map((vm) => vm.media$)],
|
||||
[l.spotlight.vm.media$, ...l.grid.map((vm) => vm.media$)],
|
||||
(spotlight, ...grid) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight.map((vm) => vm.id),
|
||||
@@ -176,7 +186,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
);
|
||||
case "spotlight-expanded":
|
||||
return combineLatest(
|
||||
[l.spotlight.media$, l.pip?.media$ ?? constant(undefined)],
|
||||
[l.spotlight.media$, l.pip?.vm.media$ ?? constant(undefined)],
|
||||
(spotlight, pip) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight.map((vm) => vm.id),
|
||||
@@ -185,7 +195,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
);
|
||||
case "one-on-one-desktop":
|
||||
return combineLatest(
|
||||
[l.spotlight.media$, l.pip.media$],
|
||||
[l.spotlight.media$, l.pip.vm.media$],
|
||||
(spotlight, pip) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight.id,
|
||||
@@ -196,8 +206,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
return combineLatest(
|
||||
[
|
||||
l.spotlight.media$,
|
||||
l.pip?.media$ ?? constant(undefined),
|
||||
l.pipSize$,
|
||||
l.pip?.vm.media$ ?? constant(undefined),
|
||||
l.pip?.size$ ?? constant(undefined),
|
||||
],
|
||||
(spotlight, pip, pipSize) => ({
|
||||
type: l.type,
|
||||
@@ -506,7 +516,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: "lg",
|
||||
pipSize: undefined,
|
||||
},
|
||||
b: {
|
||||
type: "one-on-one-mobile",
|
||||
@@ -544,7 +554,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: "lg",
|
||||
pipSize: undefined,
|
||||
},
|
||||
});
|
||||
// It wouldn't be clear whether Alice or Bob is the remote video tile,
|
||||
@@ -592,7 +602,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: "sm",
|
||||
pipSize: undefined,
|
||||
},
|
||||
b: {
|
||||
type: "spotlight-landscape",
|
||||
@@ -791,7 +801,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: "lg",
|
||||
pipSize: undefined,
|
||||
},
|
||||
b: {
|
||||
// In a larger window, expect the one-on-one desktop layout
|
||||
@@ -1664,3 +1674,50 @@ 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,7 +14,6 @@ import {
|
||||
type EncryptionSystem,
|
||||
generateItems,
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalMediaTrack,
|
||||
type LocalRTCMember,
|
||||
type LocalVideoMediaTrack,
|
||||
type RTCParticipation,
|
||||
@@ -78,14 +77,19 @@ import {
|
||||
type ReactionInfo,
|
||||
type ReactionOption,
|
||||
} from "../../reactions";
|
||||
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 { 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 { HeaderStyle, type UrlParams } from "../../UrlParams";
|
||||
import { type HostBridge, nullHostBridge } from "../../HostBridge";
|
||||
import {
|
||||
type Alignment,
|
||||
layoutShallowEquals,
|
||||
type Layout,
|
||||
type LayoutMedia,
|
||||
type WindowMode,
|
||||
@@ -126,6 +130,7 @@ 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 {
|
||||
@@ -135,14 +140,16 @@ export interface CaptureSettings {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function publishRequest(
|
||||
source: "microphone" | "camera",
|
||||
mediaDevices: MediaDevices,
|
||||
capture: CaptureSettings,
|
||||
processor?: VideoProcessor,
|
||||
): PublishRequest {
|
||||
return source === "microphone"
|
||||
? {
|
||||
@@ -154,6 +161,7 @@ export function publishRequest(
|
||||
source,
|
||||
deviceId: mediaDevices.videoInput.selected$.value?.id,
|
||||
capture: capture.camera,
|
||||
...(processor && { processor }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,12 +170,13 @@ 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));
|
||||
requests.push(publishRequest("camera", mediaDevices, capture, processor));
|
||||
return requests;
|
||||
}
|
||||
|
||||
@@ -503,72 +512,41 @@ export function createCallViewModel$(
|
||||
"screenShare",
|
||||
) as Behavior<LocalVideoMediaTrack | 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();
|
||||
const supply = (source: "microphone" | "camera"): TrackSupply => ({
|
||||
publish: async () =>
|
||||
rtcParticipation.publish(
|
||||
publishRequest(
|
||||
source,
|
||||
mediaDevices,
|
||||
capture,
|
||||
source === "camera" ? videoProcessor$.value : undefined,
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
// 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");
|
||||
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 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
|
||||
@@ -583,15 +561,7 @@ export function createCallViewModel$(
|
||||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
followProcessor(scope, camera$, videoProcessor$, logger);
|
||||
|
||||
const members$ = scope.behavior<(LocalRTCMember | RemoteRTCMember)[]>(
|
||||
combineLatest(
|
||||
@@ -830,7 +800,7 @@ export function createCallViewModel$(
|
||||
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(shallowArrayEquals),
|
||||
distinctUntilChanged(arrayShallowEquals),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -862,13 +832,18 @@ export function createCallViewModel$(
|
||||
[ringingMedia$, screenShares$, spotlightSpeaker$, localUserMediaForPip$],
|
||||
(ringing, screenShares, speaker, localPip) =>
|
||||
computeSpotlight({ ringing, screenShares, speaker, localPip }),
|
||||
).pipe(distinctUntilChanged(shallowObjectEquals)),
|
||||
).pipe(
|
||||
distinctUntilChanged(
|
||||
(a, b) =>
|
||||
arrayShallowEquals(a.spotlight, b.spotlight) && a.pip === b.pip,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const spotlight$ = scope.behavior<MediaViewModel[]>(
|
||||
spotlightAndPip$.pipe(
|
||||
map(({ spotlight }) => spotlight),
|
||||
distinctUntilChanged<MediaViewModel[]>(shallowArrayEquals),
|
||||
distinctUntilChanged<MediaViewModel[]>(arrayShallowEquals),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1029,7 +1004,7 @@ export function createCallViewModel$(
|
||||
desktop: platform === "desktop",
|
||||
}),
|
||||
),
|
||||
distinctUntilChanged(shallowObjectEquals<LayoutMedia>),
|
||||
distinctUntilChanged(layoutShallowEquals),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1221,32 +1196,28 @@ export function createCallViewModel$(
|
||||
);
|
||||
|
||||
/**
|
||||
* The alignment of the floating spotlight tile, if present.
|
||||
* State of the floating spotlight tile, if present.
|
||||
*/
|
||||
const spotlightAlignment$ = new BehaviorSubject<Alignment>({
|
||||
const floatingSpotlightTile = createFloatingTile(scope, {
|
||||
inline: "end",
|
||||
block: "end",
|
||||
});
|
||||
/**
|
||||
* The size of the small picture-in-picture tile, if present, when in portrait.
|
||||
* State of the small picture-in-picture tile, if present, when in landscape.
|
||||
*/
|
||||
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>({
|
||||
const landscapePipTile = createFloatingTile(scope, {
|
||||
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
|
||||
@@ -1276,7 +1247,7 @@ export function createCallViewModel$(
|
||||
case "spotlight-portrait":
|
||||
[layout, newTiles] = gridLikeLayout(
|
||||
media,
|
||||
spotlightAlignment$,
|
||||
floatingSpotlightTile,
|
||||
visibleTiles,
|
||||
setVisibleTiles,
|
||||
prevTiles,
|
||||
@@ -1286,26 +1257,25 @@ export function createCallViewModel$(
|
||||
case "spotlight-expanded":
|
||||
[layout, newTiles] = spotlightExpandedLayout(
|
||||
media,
|
||||
landscapePipAlignment$,
|
||||
landscapePipTile,
|
||||
prevTiles,
|
||||
);
|
||||
break;
|
||||
case "one-on-one-desktop":
|
||||
[layout, newTiles] = oneOnOneDesktopLayout(
|
||||
media,
|
||||
landscapePipAlignment$,
|
||||
landscapePipTile,
|
||||
prevTiles,
|
||||
);
|
||||
pip = layout.pip;
|
||||
pip = layout.pip.vm;
|
||||
break;
|
||||
case "one-on-one-mobile":
|
||||
[layout, newTiles] = oneOnOneMobileLayout(
|
||||
media,
|
||||
portraitPipSize$,
|
||||
portraitPipAlignment$,
|
||||
portraitPipTile,
|
||||
prevTiles,
|
||||
);
|
||||
pip = layout.pip;
|
||||
pip = layout.pip?.vm;
|
||||
break;
|
||||
case "pip":
|
||||
[layout, newTiles] = pipLayout(media, prevTiles);
|
||||
@@ -1330,7 +1300,7 @@ export function createCallViewModel$(
|
||||
layoutInternals$.pipe(
|
||||
map(({ layout }) => layout),
|
||||
// Drop redundant layout updates before they would hit React.
|
||||
distinctUntilChanged(shallowObjectEquals<Layout>),
|
||||
distinctUntilChanged(layoutShallowEquals),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
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" },
|
||||
});
|
||||
}));
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
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,14 +5,9 @@ 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 Layout,
|
||||
type LayoutMedia,
|
||||
} from "./layout-types.ts";
|
||||
import { type Layout, type LayoutMedia } from "./layout-types.ts";
|
||||
import { type TileStore } from "./TileStore";
|
||||
import { type FloatingTile } from "./FloatingTile.ts";
|
||||
|
||||
export type GridLikeLayoutType =
|
||||
| "grid"
|
||||
@@ -25,7 +20,7 @@ export type GridLikeLayoutType =
|
||||
*/
|
||||
export function gridLikeLayout(
|
||||
media: LayoutMedia & { type: GridLikeLayoutType },
|
||||
spotlightAlignment$: BehaviorSubject<Alignment>,
|
||||
floatingSpotlightTile: FloatingTile,
|
||||
visibleTiles: number,
|
||||
setVisibleTiles: (value: number) => void,
|
||||
prevTiles: TileStore,
|
||||
@@ -42,9 +37,11 @@ export function gridLikeLayout(
|
||||
return [
|
||||
{
|
||||
type: media.type,
|
||||
spotlight: tiles.spotlightTile,
|
||||
spotlight: tiles.spotlightTile && {
|
||||
vm: tiles.spotlightTile,
|
||||
...floatingSpotlightTile,
|
||||
},
|
||||
grid: tiles.gridTiles,
|
||||
spotlightAlignment$,
|
||||
setVisibleTiles,
|
||||
} as Layout & { type: GridLikeLayoutType },
|
||||
tiles,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
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,21 +6,19 @@ 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,
|
||||
pipAlignment$: BehaviorSubject<Alignment>,
|
||||
pipTile: FloatingTile,
|
||||
prevTiles: TileStore,
|
||||
): [OneOnOneDesktopLayout, TileStore] {
|
||||
const update = prevTiles.from(2);
|
||||
@@ -32,8 +30,7 @@ export function oneOnOneDesktopLayout(
|
||||
{
|
||||
type: media.type,
|
||||
spotlight: tiles.gridTilesByMedia.get(media.spotlight)!,
|
||||
pip: tiles.gridTilesByMedia.get(media.pip)!,
|
||||
pipAlignment$,
|
||||
pip: { vm: tiles.gridTilesByMedia.get(media.pip)!, ...pipTile },
|
||||
},
|
||||
tiles,
|
||||
];
|
||||
|
||||
@@ -6,23 +6,20 @@ 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,
|
||||
pipSize$: Behavior<"sm" | "lg">,
|
||||
pipAlignment$: BehaviorSubject<Alignment>,
|
||||
pipTile: FloatingTile & { size$: Behavior<"sm" | "lg"> },
|
||||
prevTiles: TileStore,
|
||||
): [OneOnOneMobileLayout, TileStore] {
|
||||
const update = prevTiles.from(media.pip === undefined ? 0 : 1);
|
||||
@@ -30,13 +27,12 @@ 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: media.pip && tiles.gridTilesByMedia.get(media.pip),
|
||||
pipSize$,
|
||||
pipAlignment$,
|
||||
pip: pipVm && { vm: pipVm, ...pipTile },
|
||||
},
|
||||
tiles,
|
||||
];
|
||||
|
||||
@@ -5,21 +5,19 @@ 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,
|
||||
pipAlignment$: BehaviorSubject<Alignment>,
|
||||
pipTile: FloatingTile,
|
||||
prevTiles: TileStore,
|
||||
): [SpotlightExpandedLayout, TileStore] {
|
||||
const update = prevTiles.from(1);
|
||||
@@ -27,12 +25,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: tiles.gridTiles.at(0),
|
||||
pipAlignment$,
|
||||
pip: pipVm && { vm: pipVm, ...pipTile },
|
||||
},
|
||||
tiles,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { 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));
|
||||
+65
-13
@@ -5,8 +5,6 @@ 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";
|
||||
@@ -16,6 +14,9 @@ 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
|
||||
@@ -86,24 +87,48 @@ 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?: SpotlightTileViewModel;
|
||||
spotlight?: FloatingTile & { vm: SpotlightTileViewModel };
|
||||
grid: GridTileViewModel[];
|
||||
spotlightAlignment$: BehaviorSubject<Alignment>;
|
||||
setVisibleTiles: (value: number) => void;
|
||||
}
|
||||
|
||||
export interface SpotlightLandscapeLayout {
|
||||
type: "spotlight-landscape";
|
||||
spotlight: SpotlightTileViewModel;
|
||||
spotlight: { vm: SpotlightTileViewModel };
|
||||
grid: GridTileViewModel[];
|
||||
setVisibleTiles: (value: number) => void;
|
||||
}
|
||||
|
||||
export interface SpotlightPortraitLayout {
|
||||
type: "spotlight-portrait";
|
||||
spotlight: SpotlightTileViewModel;
|
||||
spotlight: { vm: SpotlightTileViewModel };
|
||||
grid: GridTileViewModel[];
|
||||
setVisibleTiles: (value: number) => void;
|
||||
}
|
||||
@@ -111,23 +136,19 @@ export interface SpotlightPortraitLayout {
|
||||
export interface SpotlightExpandedLayout {
|
||||
type: "spotlight-expanded";
|
||||
spotlight: SpotlightTileViewModel;
|
||||
pip?: GridTileViewModel;
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
pip?: FloatingTile & { vm: GridTileViewModel };
|
||||
}
|
||||
|
||||
export interface OneOnOneDesktopLayout {
|
||||
type: "one-on-one-desktop";
|
||||
spotlight: GridTileViewModel;
|
||||
pip: GridTileViewModel;
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
pip: FloatingTile & { vm: GridTileViewModel };
|
||||
}
|
||||
|
||||
export interface OneOnOneMobileLayout {
|
||||
type: "one-on-one-mobile";
|
||||
spotlight: SpotlightTileViewModel;
|
||||
pip?: GridTileViewModel;
|
||||
pipSize$: Behavior<"sm" | "lg">;
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
pip?: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> };
|
||||
}
|
||||
|
||||
export interface PipLayout {
|
||||
@@ -147,3 +168,34 @@ 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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -235,22 +235,35 @@ test("audio and video follow the tracks' mute state", () => {
|
||||
|
||||
test("user media polls stream stats only while the setting is on", () => {
|
||||
onTestFinished(() => showConnectionStats.setValue(false));
|
||||
|
||||
withTestScheduler(({ cold, expectObservable, schedule }) => {
|
||||
const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats;
|
||||
const vm = mockRemoteMedia(rtcMembership, {}, [
|
||||
mockAudioTrack({
|
||||
stats$: () => cold("-s", { s: stats }),
|
||||
}),
|
||||
mockAudioTrack({ stats$: () => cold("-s", { s: { ...stats } }) }),
|
||||
]);
|
||||
schedule("-a-b", {
|
||||
a() {
|
||||
showConnectionStats.setValue(true);
|
||||
},
|
||||
b() {
|
||||
showConnectionStats.setValue(false);
|
||||
},
|
||||
schedule("-t-f-t", {
|
||||
t: () => showConnectionStats.setValue(true),
|
||||
f: () => showConnectionStats.setValue(false),
|
||||
});
|
||||
expectObservable(vm.audioStreamStats$).toBe("u-su", {
|
||||
|
||||
expectObservable(vm.audioStreamStats$).toBe("u-s---s", {
|
||||
u: undefined,
|
||||
s: stats,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("stream stats can be enabled before creating media", () => {
|
||||
showConnectionStats.setValue(true);
|
||||
onTestFinished(() => showConnectionStats.setValue(false));
|
||||
|
||||
withTestScheduler(({ cold, expectObservable }) => {
|
||||
const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats;
|
||||
const vm = mockRemoteMedia(rtcMembership, {}, [
|
||||
mockAudioTrack({ stats$: () => cold("-s", { s: stats }) }),
|
||||
]);
|
||||
|
||||
expectObservable(vm.audioStreamStats$).toBe("us", {
|
||||
u: undefined,
|
||||
s: stats,
|
||||
});
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
map,
|
||||
NEVER,
|
||||
of,
|
||||
startWith,
|
||||
Subject,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
@@ -105,10 +107,9 @@ export function createBaseUserMedia(
|
||||
showConnectionStats.value$,
|
||||
]).pipe(
|
||||
switchMap(([track, show]) =>
|
||||
track !== undefined && show
|
||||
? track.stats$(statsIntervalMs)
|
||||
: of(undefined),
|
||||
track !== undefined && show ? track.stats$(statsIntervalMs) : NEVER,
|
||||
),
|
||||
startWith<MediaStreamStats>(undefined),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
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 {
|
||||
BackgroundTransformer,
|
||||
VideoTransformer,
|
||||
type VideoTransformerInitOptions,
|
||||
} from "@livekit/track-processors";
|
||||
import { ImageSegmenter } from "@mediapipe/tasks-vision";
|
||||
|
||||
import { BackgroundEffectTransformer } from "./BackgroundEffectTransformer";
|
||||
|
||||
const frame = {} as VideoFrame;
|
||||
const controller = {} as TransformStreamDefaultController<VideoFrame>;
|
||||
|
||||
describe("BackgroundEffectTransformer", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("loads a background chosen before it was built", async () => {
|
||||
vi.spyOn(VideoTransformer.prototype, "init").mockResolvedValue();
|
||||
vi.spyOn(ImageSegmenter, "createFromOptions").mockResolvedValue(
|
||||
{} as ImageSegmenter,
|
||||
);
|
||||
const load = vi
|
||||
.spyOn(BackgroundTransformer.prototype, "loadAndSetBackground")
|
||||
.mockResolvedValue();
|
||||
const transformer = new BackgroundEffectTransformer({
|
||||
backgroundDisabled: true,
|
||||
});
|
||||
// Chosen before the camera is processed, as the first effect always is:
|
||||
// loaded then, there is nothing yet to draw it with.
|
||||
await transformer.update({ imagePath: "/background.jpg" });
|
||||
load.mockClear();
|
||||
|
||||
await transformer.init({} as VideoTransformerInitOptions);
|
||||
expect(load).toHaveBeenCalledWith("/background.jpg");
|
||||
});
|
||||
|
||||
it("reports the first frame carrying an effect, once", async () => {
|
||||
vi.spyOn(BackgroundTransformer.prototype, "transform").mockImplementation(
|
||||
async function (this: BackgroundTransformer) {
|
||||
this.isFirstFrame = false;
|
||||
return Promise.resolve();
|
||||
},
|
||||
);
|
||||
const transformer = new BackgroundEffectTransformer({});
|
||||
transformer.onFirstFrame = vi.fn();
|
||||
|
||||
await transformer.transform(frame, controller);
|
||||
await transformer.transform(frame, controller);
|
||||
expect(transformer.onFirstFrame).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not report a frame passed through untouched", async () => {
|
||||
vi.spyOn(BackgroundTransformer.prototype, "transform").mockResolvedValue();
|
||||
const transformer = new BackgroundEffectTransformer({
|
||||
backgroundDisabled: true,
|
||||
});
|
||||
transformer.onFirstFrame = vi.fn();
|
||||
|
||||
await transformer.transform(frame, controller);
|
||||
expect(transformer.onFirstFrame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes frames on untouched once disabled, even after blurring", async () => {
|
||||
const transformer = new BackgroundEffectTransformer({});
|
||||
await transformer.update({ blurRadius: 15, backgroundDisabled: false });
|
||||
await transformer.update({
|
||||
imagePath: undefined,
|
||||
backgroundDisabled: true,
|
||||
});
|
||||
// With neither, the library passes a frame on without segmenting it.
|
||||
expect(transformer.options.blurRadius).toBeUndefined();
|
||||
expect(transformer.options.imagePath).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user