mirror of
https://github.com/vector-im/element-call.git
synced 2026-11-07 00:52:28 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef86fc06e | ||
|
|
cd5c367ce9 | ||
|
|
190902eec0 | ||
|
|
88f748ec21 | ||
|
|
9639effd28 | ||
|
|
f2f67019b4 | ||
|
|
51fac690f4 |
+1
-1
@@ -107,7 +107,7 @@
|
||||
"livekit-client": "^2.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#4cb91f3155c31f982cbb7375ca34a14fbecc7848",
|
||||
"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",
|
||||
|
||||
@@ -106,6 +106,66 @@ async function drawsPerSecond(page: Page): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("the camera menu's preview", () => {
|
||||
test("shows the camera with the effect in force", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Preview user", "Preview in the menu");
|
||||
const camera = await cameraColour(page.locator("video").first());
|
||||
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const preview = page.getByRole("menu").locator("video");
|
||||
await expect(preview).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), camera), {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toBeLessThan(30);
|
||||
|
||||
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
});
|
||||
|
||||
test("shows the call's own camera once joined", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(
|
||||
page,
|
||||
"Call preview user",
|
||||
"Preview in the call's menu",
|
||||
true,
|
||||
);
|
||||
await expect(page.getByTestId("incall_videomute")).toBeEnabled({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const camera = await cameraColour(page.locator("video").first());
|
||||
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const preview = page.getByRole("menu").locator("video");
|
||||
await expect(preview).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), camera), {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toBeLessThan(30);
|
||||
|
||||
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("background effects section", () => {
|
||||
test("section stays within the call area at every size", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
@@ -151,6 +211,46 @@ test.describe("background effects section", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("a pipeline that fails to build", () => {
|
||||
test("offers no effects for the session, and keeps the choice", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Failing user", "Pipeline fails");
|
||||
const preview = page.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
await page.route("**/*.tflite*", async (route) => route.abort());
|
||||
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
await expect(
|
||||
page.getByText("Background effects are not supported on this platform."),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(tile).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
// The camera and microphone carry on without it.
|
||||
expect(distance(await cameraColour(preview), camera)).toBeLessThan(30);
|
||||
await expect(page.getByTestId("incall_mute")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
|
||||
await page.unroute("**/*.tflite*");
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
await expect(tile).toHaveAttribute("aria-checked", "true");
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("the chosen effect", () => {
|
||||
test("chosen effect survives leaving and rejoining", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
Generated
+9
-9
@@ -191,8 +191,8 @@ importers:
|
||||
specifier: ^1.9.1
|
||||
version: 1.9.2
|
||||
matrix-js-sdk:
|
||||
specifier: github:matrix-org/matrix-js-sdk#4cb91f3155c31f982cbb7375ca34a14fbecc7848
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4cb91f3155c31f982cbb7375ca34a14fbecc7848
|
||||
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
|
||||
@@ -4734,8 +4734,8 @@ 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/4cb91f3155c31f982cbb7375ca34a14fbecc7848:
|
||||
resolution: {gitHosted: true, integrity: sha512-3OCLpmzR0zO9TsTdUcxhN8dhy8ZpoZqGzbAQFESaDU60nJXrJRUKiKJAB61KzimVdy2xNscLl4M4NGdqX3aNuw==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4cb91f3155c31f982cbb7375ca34a14fbecc7848}
|
||||
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'}
|
||||
|
||||
@@ -4946,8 +4946,8 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-retry@9.0.0:
|
||||
resolution: {integrity: sha512-HuEats8gYn/UUYa5uyveyQGv75mQmwdFtuMgnZlS12dj/7bdnJSO6keaTjgcZUhvkANTj5EO3lACUmV3xmgj0Q==}
|
||||
p-retry@8.0.1:
|
||||
resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
p-try@2.2.0:
|
||||
@@ -10409,7 +10409,7 @@ snapshots:
|
||||
|
||||
matrix-events-sdk@0.0.1: {}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4cb91f3155c31f982cbb7375ca34a14fbecc7848:
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.8
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
|
||||
@@ -10419,7 +10419,7 @@ snapshots:
|
||||
loglevel: 1.9.2
|
||||
matrix-events-sdk: 0.0.1
|
||||
matrix-widget-api: 1.20.0
|
||||
p-retry: 9.0.0
|
||||
p-retry: 8.0.1
|
||||
sdp-transform: 3.0.0
|
||||
unhomoglyph: 1.0.6
|
||||
|
||||
@@ -10741,7 +10741,7 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
p-retry@9.0.0:
|
||||
p-retry@8.0.1:
|
||||
dependencies:
|
||||
is-network-error: 1.3.2
|
||||
|
||||
|
||||
@@ -184,7 +184,6 @@ export function createRTCParticipation(
|
||||
encryptMedia: encryptionSystem.kind !== E2eeType.NONE,
|
||||
matrixRTCMode,
|
||||
sendNotificationType: options.sendNotificationType,
|
||||
callIntent: options.callIntent,
|
||||
applicationData: options.applicationData,
|
||||
timings,
|
||||
delayedLeave,
|
||||
|
||||
+4
-8
@@ -12,7 +12,6 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
type Transport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -104,14 +103,11 @@ export interface RTCParticipationOptions {
|
||||
* to a notification (ringing, timeouts, declines) is the application's job.
|
||||
*/
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
/**
|
||||
* The kind of call the local member intends to take part in: `"m.audio"`
|
||||
* or `"m.video"`. Carried by the membership as a field of its own.
|
||||
*/
|
||||
callIntent?: RTCCallIntent;
|
||||
/**
|
||||
* Whatever the application wants to say about itself in the membership,
|
||||
* under namespaced keys. Opaque to the SDK.
|
||||
* under namespaced keys. Opaque to the SDK, apart from `m.call.intent`
|
||||
* (`"audio"` or `"video"`), which the membership format carries as a
|
||||
* field of its own.
|
||||
*/
|
||||
applicationData?: Record<string, unknown>;
|
||||
/** Timings the host has configured; the defaults otherwise. */
|
||||
@@ -291,7 +287,7 @@ export interface RTCMember {
|
||||
memberId: string;
|
||||
/** Which transport this member is on; undefined when the membership has none. */
|
||||
transport$: Behavior<TransportMetadata | undefined>;
|
||||
/** What the member's application says about itself, e.g. `intent`. */
|
||||
/** What the member's application says about itself, e.g. `m.call.intent`. */
|
||||
applicationData$: Behavior<Record<string, unknown>>;
|
||||
|
||||
displayName$: Behavior<string>;
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("joinJsSdkSession", () => {
|
||||
expect(s.join).toHaveBeenCalledWith(ownMemberMock, [transportWithAlias], {
|
||||
notificationType: "ring",
|
||||
callIntent: undefined,
|
||||
applicationData: undefined,
|
||||
applicationData: {},
|
||||
manageMediaKeys: true,
|
||||
delayedLeaveEventRestartMs: 300_000,
|
||||
delayedLeaveEventDelayMs: 3_600_000,
|
||||
@@ -95,14 +95,13 @@ describe("joinJsSdkSession", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the call intent and application data through and sends sticky events in Matrix 2.0 mode", () => {
|
||||
it("lifts the call intent out of the application data and sends sticky events in Matrix 2.0 mode", () => {
|
||||
const s = session();
|
||||
|
||||
joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, {
|
||||
encryptMedia: true,
|
||||
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
|
||||
callIntent: "m.audio",
|
||||
applicationData: { custom: 1 },
|
||||
applicationData: { "m.call.intent": "audio", custom: 1 },
|
||||
timings: defaultSessionTimings,
|
||||
delayedLeave: defaultSessionTimings.delayedLeave,
|
||||
});
|
||||
@@ -111,7 +110,7 @@ describe("joinJsSdkSession", () => {
|
||||
ownMemberMock,
|
||||
[transportWithAlias],
|
||||
expect.objectContaining({
|
||||
callIntent: "m.audio",
|
||||
callIntent: "audio",
|
||||
applicationData: { custom: 1 },
|
||||
unstableSendStickyEvents: true,
|
||||
}),
|
||||
@@ -125,8 +124,7 @@ describe("joinJsSdkSession", () => {
|
||||
joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, {
|
||||
encryptMedia: true,
|
||||
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
|
||||
callIntent: "m.audio",
|
||||
applicationData: { custom: 1 },
|
||||
applicationData: { "m.call.intent": "audio", custom: 1 },
|
||||
timings: defaultSessionTimings,
|
||||
delayedLeave: defaultSessionTimings.delayedLeave,
|
||||
});
|
||||
@@ -135,7 +133,7 @@ describe("joinJsSdkSession", () => {
|
||||
ownMemberMock,
|
||||
[transport],
|
||||
expect.objectContaining({
|
||||
callIntent: "m.audio",
|
||||
callIntent: "audio",
|
||||
applicationData: { custom: 1 },
|
||||
unstableSendStickyEvents: true,
|
||||
}),
|
||||
|
||||
@@ -23,7 +23,6 @@ interface Options {
|
||||
encryptMedia: boolean;
|
||||
matrixRTCMode: MatrixRTCMode;
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
callIntent?: RTCCallIntent;
|
||||
applicationData?: Record<string, unknown>;
|
||||
timings: SessionTimings;
|
||||
/** The delayed leave timings to use: delegated to the SFU or not. */
|
||||
@@ -44,12 +43,15 @@ export function joinJsSdkSession(
|
||||
encryptMedia,
|
||||
matrixRTCMode,
|
||||
sendNotificationType,
|
||||
callIntent,
|
||||
applicationData,
|
||||
timings,
|
||||
delayedLeave,
|
||||
}: Options,
|
||||
): void {
|
||||
// The membership format carries the intent as a field of its own
|
||||
const { "m.call.intent": intent, ...rest } = applicationData ?? {};
|
||||
const callIntent =
|
||||
typeof intent === "string" ? (intent as RTCCallIntent) : undefined;
|
||||
// Give up on the network as soon as either the sync has been down for the
|
||||
// grace period or the delayed leave has probably been sent, whichever is
|
||||
// sooner
|
||||
@@ -76,7 +78,7 @@ export function joinJsSdkSession(
|
||||
{
|
||||
notificationType: sendNotificationType,
|
||||
callIntent,
|
||||
applicationData,
|
||||
applicationData: rest,
|
||||
manageMediaKeys: encryptMedia,
|
||||
delayedLeaveEventRestartMs: delayedLeave.restart_ms,
|
||||
delayedLeaveEventDelayMs: delayedLeave.delay_ms,
|
||||
|
||||
+8
-10
@@ -18,8 +18,6 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -181,40 +179,40 @@ export function configurationForIntent(intent: UserIntent): UrlConfiguration {
|
||||
switch (intent) {
|
||||
case UserIntent.StartNewCall:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = RTC_CALL_INTENT_VIDEO;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCall:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = RTC_CALL_INTENT_VIDEO;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.StartNewCallVoice:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallVoice:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.StartNewCallDMVoice:
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.StartNewCallDM:
|
||||
preset.skipLobby = true;
|
||||
preset.sendNotificationType = "ring";
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.waitForCallPickup = true;
|
||||
preset.callIntent = preset.callIntent ?? RTC_CALL_INTENT_VIDEO;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallDMVoice:
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = true;
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.callIntent = preset.callIntent ?? RTC_CALL_INTENT_VIDEO;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
// Non widget usecase defaults
|
||||
default:
|
||||
|
||||
+6
-10
@@ -21,10 +21,6 @@ import {
|
||||
UserIntent,
|
||||
} from "../src/UrlParams";
|
||||
import { mockConfig } from "./utils/test";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
const ROOM_NAME = "roomNameHere";
|
||||
const ROOM_ID = "!d45f138fsd";
|
||||
@@ -279,7 +275,7 @@ describe("UrlParams", () => {
|
||||
).toMatchObject({
|
||||
...startNewCallDefaults("desktop"),
|
||||
skipLobby: false,
|
||||
callIntent: RTC_CALL_INTENT_VIDEO,
|
||||
callIntent: "video",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -326,7 +322,7 @@ describe("UrlParams", () => {
|
||||
).toMatchObject({
|
||||
...startNewCallDefaults("desktop"),
|
||||
skipLobby: false,
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
callIntent: "audio",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -337,7 +333,7 @@ describe("UrlParams", () => {
|
||||
),
|
||||
).toMatchObject({
|
||||
...joinExistingCallDefaults("desktop"),
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
callIntent: "audio",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -352,7 +348,7 @@ describe("UrlParams", () => {
|
||||
sendNotificationType: "ring",
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: true,
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
callIntent: "audio",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -367,7 +363,7 @@ describe("UrlParams", () => {
|
||||
skipLobby: true,
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: false,
|
||||
callIntent: RTC_CALL_INTENT_VIDEO,
|
||||
callIntent: "video",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -381,7 +377,7 @@ describe("UrlParams", () => {
|
||||
skipLobby: true,
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: false,
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
callIntent: "audio",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,6 +141,7 @@ export const Default: Story = {
|
||||
backgroundImageRefusal: undefined,
|
||||
addBackgroundImage: fn(),
|
||||
removeBackgroundEffect: fn(),
|
||||
cameraTrack: null,
|
||||
hangup: fn(),
|
||||
buttonSize: "lg",
|
||||
showFooter: true,
|
||||
|
||||
@@ -33,7 +33,9 @@ import { useBehavior } from "../useBehavior";
|
||||
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
|
||||
import { LayoutSwitch } from "../room/LayoutSwitch";
|
||||
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
import { SelfPreview } from "./SelfPreview";
|
||||
import { type UnusableReason } from "../tracks/backgroundImages";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
export interface BackgroundImageRefusal {
|
||||
reason: UnusableReason | "not-kept";
|
||||
@@ -92,6 +94,8 @@ export interface FooterState {
|
||||
backgroundEffectSettling: boolean;
|
||||
/** Why the last file offered couldn't be kept, if it couldn't. */
|
||||
backgroundImageRefusal: BackgroundImageRefusal | undefined;
|
||||
/** The camera to preview, with the effect in force; null while it is off. */
|
||||
cameraTrack: LocalVideoMediaTrack | null;
|
||||
showFooter: boolean;
|
||||
|
||||
/* This is needed for WindowMode = "flat" */
|
||||
@@ -181,6 +185,7 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
const backgroundEffectSettling = useBehavior(vm.backgroundEffectSettling$);
|
||||
const addBackgroundImage = useBehavior(vm.addBackgroundImage$);
|
||||
const removeBackgroundEffect = useBehavior(vm.removeBackgroundEffect$);
|
||||
const cameraTrack = useBehavior(vm.cameraTrack$);
|
||||
const refusal = useBehavior(vm.backgroundImageRefusal$);
|
||||
const refusalMessage = useMemo(() => {
|
||||
switch (refusal?.reason) {
|
||||
@@ -264,6 +269,7 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
backgroundEffectSettling={backgroundEffectSettling}
|
||||
onAddBackgroundImage={addBackgroundImage}
|
||||
onRemoveBackgroundEffect={removeBackgroundEffect}
|
||||
selfPreview={<SelfPreview track={cameraTrack} />}
|
||||
backgroundImageRefusal={refusalMessage}
|
||||
backgroundEffectNotice={
|
||||
backgroundEffectNotice === "unavailable"
|
||||
|
||||
@@ -7,14 +7,17 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import {
|
||||
flushPromises,
|
||||
testScope,
|
||||
mockMuteStates,
|
||||
mockMediaDevices,
|
||||
mockVideoTrack,
|
||||
} from "../utils/test";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type Behavior, constant } from "../state/Behavior";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import type { Layout } from "../state/layout-types";
|
||||
import type { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
@@ -78,6 +81,7 @@ vi.mock("livekit-client", () => ({
|
||||
function buildMinimalCallViewModel(layout: Layout): CallViewModel {
|
||||
return {
|
||||
layout$: constant(layout),
|
||||
camera$: constant(undefined),
|
||||
edgeToEdge$: constant(false),
|
||||
showHeader$: constant(false),
|
||||
hangup: (): void => {},
|
||||
@@ -109,6 +113,16 @@ const pipLayout: Layout = {
|
||||
spotlight: {} as SpotlightTileViewModel,
|
||||
};
|
||||
|
||||
const noPipeline = constant<BackgroundEffectsState>({
|
||||
supported: false,
|
||||
processor: undefined,
|
||||
});
|
||||
|
||||
/** The provider's verdict, which the SDK's check stands for here. */
|
||||
function pipelineAsTheSdkSays(): Behavior<BackgroundEffectsState> {
|
||||
return constant({ supported: sdkSupportMock(), processor: undefined });
|
||||
}
|
||||
|
||||
const twoMicsAndOneCamMediaDevices = mockMediaDevices({
|
||||
audioInput: {
|
||||
available$: constant(
|
||||
@@ -140,7 +154,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -183,7 +197,7 @@ describe("createCallFooterViewModel", () => {
|
||||
select: vi.fn(),
|
||||
},
|
||||
}),
|
||||
constant(false),
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -202,7 +216,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(layout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -225,7 +239,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -271,7 +285,8 @@ describe("createCallFooterViewModel", () => {
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
pipelineAsTheSdkSays(),
|
||||
/* camera$ */ constant(null),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
@@ -356,21 +371,52 @@ describe("createCallFooterViewModel", () => {
|
||||
|
||||
it("passes on the wait for the first effect", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
const settling$ = new BehaviorSubject(true);
|
||||
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
wait: "settling",
|
||||
});
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
settling$,
|
||||
pipeline$,
|
||||
/* camera$ */ constant(null),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
expect(vm.backgroundEffectSettling$.value).toBe(true);
|
||||
settling$.next(false);
|
||||
pipeline$.next({ ...pipeline$.value, wait: undefined });
|
||||
expect(vm.backgroundEffectSettling$.value).toBe(false);
|
||||
});
|
||||
|
||||
it("offers nothing once the pipeline fails to build, keeping the choice", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
backgroundEffectSetting.setValue("blur");
|
||||
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
});
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
pipeline$,
|
||||
/* camera$ */ constant(null),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
expect(vm.backgroundEffect$.value).toBe("blur");
|
||||
|
||||
pipeline$.next({ supported: false, processor: undefined });
|
||||
expect(vm.selectBackgroundEffect$.value).toBeUndefined();
|
||||
expect(vm.addBackgroundImage$.value).toBeUndefined();
|
||||
expect(vm.backgroundEffect$.value).toBe("none");
|
||||
expect(backgroundEffectSetting.getValue()).toBe("blur");
|
||||
});
|
||||
|
||||
it("adding is unavailable at the limit", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
const added$ = addedBackgrounds.added$ as BehaviorSubject<
|
||||
@@ -427,6 +473,44 @@ describe("createCallFooterViewModel", () => {
|
||||
expect(store.remove).toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("previews the lobby's camera while there is one", () => {
|
||||
const track = mockVideoTrack({});
|
||||
const camera$ = new BehaviorSubject<LocalVideoMediaTrack | null>(track);
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
camera$,
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
// The lobby releases the camera when it is turned off
|
||||
camera$.next(null);
|
||||
expect(vm.cameraTrack$.value).toBeNull();
|
||||
});
|
||||
|
||||
it("previews the call's camera only while it is not muted", () => {
|
||||
const muted$ = new BehaviorSubject(false);
|
||||
const track = mockVideoTrack({ muted$ });
|
||||
const vm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
{ ...buildMinimalCallViewModel(gridLayout), camera$: constant(track) },
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
muted$.next(true);
|
||||
expect(vm.cameraTrack$.value).toBeNull();
|
||||
muted$.next(false);
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
});
|
||||
|
||||
it("availability is the same before and during a call", () => {
|
||||
for (const supported of [true, false]) {
|
||||
sdkSupportMock.mockReturnValue(supported);
|
||||
@@ -436,7 +520,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
pipelineAsTheSdkSays(),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
|
||||
@@ -11,13 +11,12 @@ import {
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { supportsAudioOutputSelection } from "livekit-client";
|
||||
import {
|
||||
supportsBackgroundProcessors,
|
||||
supportsModernBackgroundProcessors,
|
||||
} from "@livekit/track-processors";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
import { supportsModernBackgroundProcessors } from "@livekit/track-processors";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
@@ -33,6 +32,7 @@ import {
|
||||
} from "../tracks/backgroundImages";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import {
|
||||
@@ -87,7 +87,9 @@ function buildDeviceBehaviors(
|
||||
mediaDevices: MediaDevices,
|
||||
/** return empty arrays for audioOptions and videoOptions*/
|
||||
disableSwitcher$: Behavior<boolean>,
|
||||
backgroundEffectSettling$: Observable<boolean>,
|
||||
backgroundEffectsState$: Observable<BackgroundEffectsState>,
|
||||
/** The camera as the pipeline has it, for the menu's preview. */
|
||||
camera$: Observable<LocalVideoMediaTrack | null | undefined>,
|
||||
): Pick<
|
||||
ViewModel<FooterSnapshot>,
|
||||
| "audioOptions$"
|
||||
@@ -107,6 +109,7 @@ function buildDeviceBehaviors(
|
||||
| "addBackgroundImage$"
|
||||
| "backgroundImageRefusal$"
|
||||
| "removeBackgroundEffect$"
|
||||
| "cameraTrack$"
|
||||
> {
|
||||
const options$ = (
|
||||
available$: Behavior<Map<string, MenuOptions["label"]>>,
|
||||
@@ -123,10 +126,14 @@ function buildDeviceBehaviors(
|
||||
),
|
||||
);
|
||||
|
||||
const supported = supportsBackgroundProcessors();
|
||||
// Also false once the pipeline has failed to build this session.
|
||||
const supported$ = backgroundEffectsState$.pipe(
|
||||
map(({ supported }) => supported === true),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const slow = !supportsModernBackgroundProcessors();
|
||||
const offered$ = disableSwitcher$.pipe(
|
||||
map((switcherDisabled) => !switcherDisabled && supported),
|
||||
const offered$ = combineLatest([disableSwitcher$, supported$]).pipe(
|
||||
map(([switcherDisabled, supported]) => !switcherDisabled && supported),
|
||||
);
|
||||
// A new object for each refusal, so the same one twice is shown twice.
|
||||
const refusal$ = new BehaviorSubject<BackgroundImageRefusal | undefined>(
|
||||
@@ -157,8 +164,10 @@ function buildDeviceBehaviors(
|
||||
),
|
||||
selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
|
||||
backgroundEffect$: scope.behavior(
|
||||
backgroundEffectSetting.value$.pipe(
|
||||
map((raw) => (supported ? serializeEffect(parseEffect(raw)) : "none")),
|
||||
combineLatest([backgroundEffectSetting.value$, supported$]).pipe(
|
||||
map(([raw, supported]) =>
|
||||
supported ? serializeEffect(parseEffect(raw)) : "none",
|
||||
),
|
||||
),
|
||||
),
|
||||
selectBackgroundEffect$: scope.behavior(
|
||||
@@ -186,7 +195,10 @@ function buildDeviceBehaviors(
|
||||
),
|
||||
),
|
||||
backgroundEffectSettling$: scope.behavior(
|
||||
backgroundEffectSettling$.pipe(distinctUntilChanged()),
|
||||
backgroundEffectsState$.pipe(
|
||||
map(({ wait }) => wait !== undefined),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
false,
|
||||
),
|
||||
// Kept and offered, not put on: that waits for the user to choose it.
|
||||
@@ -208,6 +220,17 @@ function buildDeviceBehaviors(
|
||||
),
|
||||
),
|
||||
backgroundImageRefusal$: refusal$,
|
||||
// None while muted: the call mutes its track rather than releasing it.
|
||||
cameraTrack$: scope.behavior(
|
||||
camera$.pipe(
|
||||
switchMap((camera) =>
|
||||
camera
|
||||
? camera.muted$.pipe(map((muted) => (muted ? null : camera)))
|
||||
: of(null),
|
||||
),
|
||||
),
|
||||
null,
|
||||
),
|
||||
// Never the one in force: taking it away would leave nothing chosen.
|
||||
removeBackgroundEffect$: scope.behavior(
|
||||
offered$.pipe(
|
||||
@@ -237,8 +260,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 backgroundEffectsState$ - The background pipeline's state: whether
|
||||
* effects run here, and the wait for the first.
|
||||
* @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.
|
||||
@@ -249,7 +272,7 @@ export function createCallFooterViewModel(
|
||||
callModel: CallViewModel,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
backgroundEffectSettling$: Observable<boolean>,
|
||||
backgroundEffectsState$: Observable<BackgroundEffectsState>,
|
||||
reactionIdentifier: string | undefined,
|
||||
options: { showControls: boolean; header: HeaderStyle },
|
||||
): ViewModel<FooterSnapshot> {
|
||||
@@ -268,7 +291,8 @@ export function createCallFooterViewModel(
|
||||
scope,
|
||||
mediaDevices,
|
||||
disableDeviceSwitcher$,
|
||||
backgroundEffectSettling$,
|
||||
backgroundEffectsState$,
|
||||
callModel.camera$,
|
||||
),
|
||||
// candidat to move into the FooterViewModel
|
||||
showFooter$: callModel.showFooter$,
|
||||
@@ -331,8 +355,9 @@ 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 backgroundEffectsState$ - The background pipeline's state: whether
|
||||
* effects run here, and the wait for the first.
|
||||
* @param camera$ - The lobby's camera, for the camera menu's preview.
|
||||
* @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.
|
||||
@@ -341,7 +366,8 @@ export function createLobbyFooterViewModel(
|
||||
scope: ObservableScope,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
backgroundEffectSettling$: Observable<boolean>,
|
||||
backgroundEffectsState$: Observable<BackgroundEffectsState>,
|
||||
camera$: Behavior<LocalVideoMediaTrack | null>,
|
||||
openSettings: (() => void) | undefined,
|
||||
hangup: (() => void) | undefined,
|
||||
showLogo: boolean,
|
||||
@@ -389,7 +415,8 @@ export function createLobbyFooterViewModel(
|
||||
scope,
|
||||
mediaDevices,
|
||||
constant(false),
|
||||
backgroundEffectSettling$,
|
||||
backgroundEffectsState$,
|
||||
camera$,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,3 +97,35 @@ Please see LICENSE in the repository root for full details.
|
||||
.refusal {
|
||||
margin: var(--cpd-space-2x) var(--cpd-space-4x) 0;
|
||||
}
|
||||
|
||||
.selfPreview {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin-block-end: var(--cpd-space-2x);
|
||||
overflow: clip;
|
||||
border-start-start-radius: var(--cpd-space-3x);
|
||||
border-start-end-radius: var(--cpd-space-3x);
|
||||
background: var(--cpd-color-bg-subtle-secondary);
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
}
|
||||
|
||||
.selfPreview > video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
object-fit: cover;
|
||||
/* Rounded itself too: a video can be drawn on a layer of its own, which the
|
||||
frame's rounded clip doesn't always reach. */
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* Up to the menu's frame, over the padding at its top. */
|
||||
.selfPreviewPinned,
|
||||
.deviceListLeadsWithPreview {
|
||||
margin-block-start: calc(-1 * var(--cpd-space-5x));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||
import styles from "./MediaMuteAndSwitchButton.module.css";
|
||||
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
import { SelfPreview } from "./SelfPreview";
|
||||
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
|
||||
import meterStyles from "./MicrophoneLevelMeter.module.css";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
@@ -60,6 +61,50 @@ const WithAMicrophone: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
/** A camera for the preview: a figure against a room, drawn to a stream. */
|
||||
const FakeCamera: FC = () => {
|
||||
const [stream, setStream] = useState<MediaStream>();
|
||||
useEffect(() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 640;
|
||||
canvas.height = 360;
|
||||
const context = canvas.getContext("2d")!;
|
||||
const draw = (): void => {
|
||||
context.fillStyle = "#6b7f8e";
|
||||
context.fillRect(0, 0, 640, 360);
|
||||
context.fillStyle = "#c9a27e";
|
||||
context.beginPath();
|
||||
context.arc(320, 150, 62, 0, 2 * Math.PI);
|
||||
context.fill();
|
||||
context.fillStyle = "#34495e";
|
||||
context.beginPath();
|
||||
context.ellipse(320, 380, 150, 140, 0, Math.PI, 2 * Math.PI);
|
||||
context.fill();
|
||||
};
|
||||
draw();
|
||||
// Redrawn, so the stream keeps delivering frames.
|
||||
const timer = setInterval(draw, 200);
|
||||
const captured = canvas.captureStream(10);
|
||||
setStream(captured);
|
||||
return (): void => {
|
||||
clearInterval(timer);
|
||||
captured.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<video
|
||||
data-testid="preview-camera"
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
ref={(video): void => {
|
||||
if (video && stream && video.srcObject !== stream)
|
||||
video.srcObject = stream;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** Supplies a call-sized root. Without one the list is bounded by the whole Storybook frame. */
|
||||
const WithACallArea: FC<{ children: ReactNode; blockSize?: number }> = ({
|
||||
children,
|
||||
@@ -171,8 +216,8 @@ export const VideoMute: Story = {
|
||||
{ label: { type: "name", name: "Camera 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Camera 2" }, id: "2" },
|
||||
],
|
||||
|
||||
selectedOption: "1",
|
||||
selfPreview: <SelfPreview track={null} />,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -199,6 +244,8 @@ export const VideoUnmute: Story = {
|
||||
selectedBackgroundEffect: "none",
|
||||
onSelectBackgroundEffect: fn(),
|
||||
onAddBackgroundImage: fn(),
|
||||
// As the footer always gives the camera menu one.
|
||||
selfPreview: <FakeCamera />,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -344,6 +391,9 @@ export const BackgroundEffectsSettling: Story = {
|
||||
checked: false,
|
||||
}))
|
||||
await expect(other).toHaveAttribute("aria-busy", "false");
|
||||
await expect(menu.queryByTestId("preview-camera")).toBeNull();
|
||||
const frames = document.body.querySelectorAll(`.${styles.selfPreview} svg`);
|
||||
await expect(frames).toHaveLength(1);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -501,6 +551,113 @@ export const RemovingWithLiveSelection: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Where the call leaves room, the camera holds the top while the options scroll. */
|
||||
export const BackgroundEffectsWithPreview: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
// Enough that the list fills its share, so the preview's cost counts.
|
||||
options: Array.from({ length: 8 }, (_, i) => ({
|
||||
label: { type: "name" as const, name: `Camera ${i + 1}` },
|
||||
id: `${i + 1}`,
|
||||
})),
|
||||
},
|
||||
decorators: [
|
||||
(Story): JSX.Element => (
|
||||
<WithACallArea blockSize={640}>
|
||||
<Story />
|
||||
</WithACallArea>
|
||||
),
|
||||
],
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const picture = await within(document.body).findByTestId("preview-camera");
|
||||
const preview = picture.parentElement!;
|
||||
const list = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
const frame = document.body
|
||||
.querySelector("[role='menu']")!
|
||||
.getBoundingClientRect();
|
||||
|
||||
const box = preview.getBoundingClientRect();
|
||||
await expect(list.contains(preview)).toBe(false);
|
||||
await expect(box.top - frame.top).toBeLessThanOrEqual(2);
|
||||
await expect(frame.width - box.width).toBeLessThanOrEqual(2);
|
||||
await expect(box.width / box.height).toBeCloseTo(16 / 9, 1);
|
||||
await expect(box.bottom).toBeLessThanOrEqual(
|
||||
list.getBoundingClientRect().top,
|
||||
);
|
||||
|
||||
const listShare = Math.round(640 * 0.6);
|
||||
// The list runs on through the menu's padding at its foot, which isn't
|
||||
// part of its share.
|
||||
const listEnd =
|
||||
list.getBoundingClientRect().bottom -
|
||||
Number.parseFloat(getComputedStyle(list).paddingBlockEnd);
|
||||
await expect(listEnd - box.top).toBeLessThanOrEqual(listShare + 1);
|
||||
|
||||
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
|
||||
list.scrollTop = list.scrollHeight;
|
||||
await expect(preview.getBoundingClientRect().top).toBe(box.top);
|
||||
},
|
||||
};
|
||||
|
||||
/** With the camera off the preview says so, and effects can still be chosen. */
|
||||
export const BackgroundEffectsPreviewWithTheCameraOff: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
enabled: false,
|
||||
selfPreview: <SelfPreview track={null} />,
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const menu = within(document.body);
|
||||
await menu.findByRole("group", { name: "Background effects" });
|
||||
const preview = document.body.querySelector(`.${styles.selfPreview}`)!;
|
||||
await expect(preview.querySelector("video")).toBeNull();
|
||||
await expect(preview.querySelector("svg")).not.toBeNull();
|
||||
|
||||
await userEvent.click(menu.getByRole("menuitemradio", { name: "Blur" }));
|
||||
await expect(args.onSelectBackgroundEffect).toHaveBeenCalledWith("blur");
|
||||
},
|
||||
};
|
||||
|
||||
/** In a call too short to hold it, the camera leads the options and scrolls away. */
|
||||
export const BackgroundEffectsPreviewScrollsWithTheList: Story = {
|
||||
args: VideoUnmute.args,
|
||||
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 picture = await within(document.body).findByTestId("preview-camera");
|
||||
const preview = picture.parentElement!;
|
||||
const list = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
await expect(list.contains(preview)).toBe(true);
|
||||
const cameras = within(document.body).getByRole("group", {
|
||||
name: "Camera",
|
||||
});
|
||||
await expect(
|
||||
preview.compareDocumentPosition(cameras) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
|
||||
list.scrollTop = list.scrollHeight;
|
||||
await expect(preview.getBoundingClientRect().bottom).toBeLessThan(
|
||||
list.getBoundingClientRect().top,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** In a short call the effects scroll into view with the list. */
|
||||
export const BackgroundEffectsScrollWhenTheyDoNotFit: Story = {
|
||||
args: {
|
||||
|
||||
@@ -14,10 +14,12 @@ import {
|
||||
type FC,
|
||||
useEffect,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
InlineSpinner,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuScrollArea,
|
||||
@@ -87,6 +89,8 @@ export interface MediaMuteAndSwitchButtonProps {
|
||||
onRemoveBackgroundEffect?: (id: string) => void;
|
||||
/** Why the last file offered couldn't be used; a new object each time. */
|
||||
backgroundImageRefusal?: { text: string };
|
||||
/** The user's own camera, shown at the top of the camera menu. */
|
||||
selfPreview?: ReactNode;
|
||||
/**
|
||||
* 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`
|
||||
@@ -103,9 +107,16 @@ 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;
|
||||
|
||||
/** The menu's width as its stylesheet sets it, to place the preview before
|
||||
the menu is drawn. */
|
||||
const MENU_WIDTH = 296;
|
||||
|
||||
/** Space kept between the menu and the call area's sides. */
|
||||
const MENU_MARGIN = 16;
|
||||
|
||||
/** Below the preview, as its stylesheet draws it. */
|
||||
const PREVIEW_GAP = 12;
|
||||
|
||||
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
enabled,
|
||||
busy,
|
||||
@@ -124,6 +135,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
onAddBackgroundImage,
|
||||
onRemoveBackgroundEffect,
|
||||
backgroundImageRefusal,
|
||||
selfPreview,
|
||||
onSelect,
|
||||
}) => {
|
||||
// Requested but not yet selected. Keyed by kind too, since Chrome uses
|
||||
@@ -202,21 +214,53 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
|
||||
// Measured on the call area: CSS can't size the portalled menu against it.
|
||||
const rootElement = useRootElement();
|
||||
const [listMaxHeight, setListMaxHeight] = useState<number>();
|
||||
const [listShare, setListShare] = useState<number>();
|
||||
const [previewBlock, setPreviewBlock] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
// Followed, since a host can resize the call while the menu is open.
|
||||
const subscription = observeElementSize$(rootElement)
|
||||
.pipe(
|
||||
map(({ height }) =>
|
||||
Math.max(MIN_LIST_HEIGHT, Math.round(height * LIST_SHARE_OF_CALL)),
|
||||
map(({ width, height }) => ({
|
||||
height: Math.round(height * LIST_SHARE_OF_CALL),
|
||||
width: Math.min(MENU_WIDTH, Math.round(width - 2 * MENU_MARGIN)),
|
||||
})),
|
||||
distinctUntilChanged(
|
||||
(a, b) => a.height === b.height && a.width === b.width,
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
.subscribe(setListMaxHeight);
|
||||
.subscribe(({ height, width }) => {
|
||||
setListShare(height);
|
||||
setPreviewBlock(Math.round((width * 9) / 16) + PREVIEW_GAP);
|
||||
});
|
||||
return (): void => subscription.unsubscribe();
|
||||
}, [menuOpen, rootElement]);
|
||||
|
||||
// Paid for out of the list's share, so a preview makes the menu no taller.
|
||||
const hasPreview =
|
||||
iconsAndLabels === "video" &&
|
||||
selfPreview !== undefined &&
|
||||
listShare !== undefined;
|
||||
const previewPinned =
|
||||
hasPreview && listShare - previewBlock >= MIN_LIST_HEIGHT;
|
||||
const listMaxHeight =
|
||||
listShare === undefined
|
||||
? undefined
|
||||
: Math.max(
|
||||
MIN_LIST_HEIGHT,
|
||||
listShare - (previewPinned ? previewBlock : 0),
|
||||
);
|
||||
const preview = hasPreview && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={classNames(styles.selfPreview, {
|
||||
[styles.selfPreviewPinned]: previewPinned,
|
||||
})}
|
||||
>
|
||||
{backgroundEffectSettling ? <InlineSpinner size={32} /> : selfPreview}
|
||||
</div>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
|
||||
}, [menuOpen, devices]);
|
||||
@@ -407,9 +451,12 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
/>
|
||||
}
|
||||
>
|
||||
{previewPinned && preview}
|
||||
<MenuScrollArea
|
||||
ref={trackFocusSource}
|
||||
className={styles.deviceList}
|
||||
className={classNames(styles.deviceList, {
|
||||
[styles.deviceListLeadsWithPreview]: hasPreview && !previewPinned,
|
||||
})}
|
||||
data-testid="device-list"
|
||||
style={
|
||||
{
|
||||
@@ -418,6 +465,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{hasPreview && !previewPinned && preview}
|
||||
{audioMeter && speakerOptions && (
|
||||
<MenuScrollSection title={t("settings.devices.speaker")}>
|
||||
{deviceItems(
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.mirrored {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
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 { act, render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { SelfPreview } from "./SelfPreview";
|
||||
import styles from "./SelfPreview.module.css";
|
||||
import { mockVideoTrack } from "../utils/test";
|
||||
|
||||
describe("SelfPreview", () => {
|
||||
it("detaches only its own element from a camera shown twice", () => {
|
||||
const track = mockVideoTrack({});
|
||||
const first = render(<SelfPreview track={track} />);
|
||||
const second = render(<SelfPreview track={track} />);
|
||||
const [a, b] = [first, second].map((r) =>
|
||||
r.container.querySelector("video"),
|
||||
);
|
||||
expect(track.attach).toHaveBeenCalledWith(a);
|
||||
expect(track.attach).toHaveBeenCalledWith(b);
|
||||
|
||||
first.unmount();
|
||||
expect(track.detach).toHaveBeenCalledExactlyOnceWith(a);
|
||||
});
|
||||
|
||||
it("mirrors the camera while it faces the user, and only then", () => {
|
||||
const facingMode$ = new BehaviorSubject<"user" | "environment">("user");
|
||||
const { container } = render(
|
||||
<SelfPreview track={mockVideoTrack({ facingMode$ })} />,
|
||||
);
|
||||
const video = (): HTMLVideoElement => container.querySelector("video")!;
|
||||
expect(video().classList.contains(styles.mirrored)).toBe(true);
|
||||
act(() => facingMode$.next("environment"));
|
||||
expect(video().classList.contains(styles.mirrored)).toBe(false);
|
||||
});
|
||||
|
||||
it("says the camera is off while there is none", () => {
|
||||
const { container } = render(<SelfPreview track={null} />);
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
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, useEffect, useRef } from "react";
|
||||
import { constant, type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
import { VideoCallOffIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import styles from "./SelfPreview.module.css";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
const noFacingMode = constant(undefined);
|
||||
|
||||
interface Props {
|
||||
/** The camera as the pipeline has it, or none while the camera is off. */
|
||||
track: LocalVideoMediaTrack | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's own camera with the effect in force: the pipeline's own track,
|
||||
* shown a second time rather than captured again.
|
||||
*/
|
||||
export const SelfPreview: FC<Props> = ({ track }) => {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
useEffect(() => {
|
||||
const element = videoRef.current;
|
||||
if (!track || !element) return;
|
||||
track.attach(element);
|
||||
// This element only: the pre-join preview may be showing the same camera
|
||||
return (): void => {
|
||||
track.detach(element);
|
||||
};
|
||||
}, [track]);
|
||||
const facingMode = useBehavior(track?.facingMode$ ?? noFacingMode);
|
||||
|
||||
if (!track) return <VideoCallOffIcon width={32} height={32} />;
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
playsInline
|
||||
className={facingMode === "user" ? styles.mirrored : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -75,7 +75,6 @@ import {
|
||||
type RTCSlotOptions,
|
||||
constant,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { map } from "rxjs";
|
||||
import {
|
||||
advancedCamera,
|
||||
advancedScreenShare,
|
||||
@@ -181,7 +180,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
props.muteStates,
|
||||
mediaDevices,
|
||||
capture,
|
||||
backgroundEffectsState$.value.processor,
|
||||
backgroundEffectsState$.value,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -195,9 +194,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem: props.e2eeSystem,
|
||||
capture,
|
||||
videoProcessor$: scope.behavior(
|
||||
backgroundEffectsState$.pipe(map((state) => state.processor)),
|
||||
),
|
||||
backgroundEffectsState$,
|
||||
hostBridge,
|
||||
autoLeaveWhenOthersLeft,
|
||||
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
||||
@@ -241,7 +238,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
vm,
|
||||
props.muteStates,
|
||||
mediaDevices,
|
||||
backgroundEffectsState$.pipe(map(({ settling }) => settling ?? false)),
|
||||
backgroundEffectsState$,
|
||||
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
|
||||
{ showControls: urlParams.showControls, header: urlParams.header },
|
||||
);
|
||||
@@ -825,7 +822,9 @@ export function rtcParticipationOptions(
|
||||
const session = config.matrix_rtc_session;
|
||||
return {
|
||||
sendNotificationType: urlParams.sendNotificationType,
|
||||
callIntent: urlParams.callIntent,
|
||||
applicationData: urlParams.callIntent
|
||||
? { "m.call.intent": urlParams.callIntent }
|
||||
: undefined,
|
||||
timings: {
|
||||
syncDisconnectGracePeriodMs: config.sync_disconnect_grace_period_ms,
|
||||
networkErrorRetryMs: session.network_error_retry_ms,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
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";
|
||||
@@ -134,9 +133,7 @@ export const LobbyView: FC<Props> = ({
|
||||
muteStates,
|
||||
devices,
|
||||
captureSettings(),
|
||||
mediaScope.behavior(
|
||||
backgroundEffectsState$.pipe(map((state) => state.processor)),
|
||||
),
|
||||
backgroundEffectsState$,
|
||||
),
|
||||
);
|
||||
return (): void => {
|
||||
@@ -158,13 +155,15 @@ export const LobbyView: FC<Props> = ({
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (mediaVm === null) return;
|
||||
const footerScope = new ObservableScope();
|
||||
setFooterVm(
|
||||
createLobbyFooterViewModel(
|
||||
footerScope,
|
||||
muteStates,
|
||||
devices,
|
||||
backgroundEffectsState$.pipe(map(({ settling }) => settling ?? false)),
|
||||
backgroundEffectsState$,
|
||||
mediaVm.videoTrack$,
|
||||
openSettings,
|
||||
hangup,
|
||||
// Logo and header are connected: only show the logo in SPA with header.
|
||||
@@ -179,6 +178,7 @@ export const LobbyView: FC<Props> = ({
|
||||
backgroundEffectsState$,
|
||||
hangup,
|
||||
hideHeader,
|
||||
mediaVm,
|
||||
muteStates,
|
||||
openSettings,
|
||||
]);
|
||||
|
||||
@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
|
||||
import { expect, type MockedFunction, test, vi } from "vitest";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { RTC_CALL_INTENT_AUDIO } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { createRingingMedia } from "../state/media/RingingMediaViewModel";
|
||||
@@ -40,7 +39,7 @@ test("ringtone plays on loop while ringing", () => {
|
||||
userId: alice.userId,
|
||||
displayName$: constant("Alice"),
|
||||
mxcAvatarUrl$: constant(undefined),
|
||||
intent: RTC_CALL_INTENT_AUDIO,
|
||||
intent: "audio",
|
||||
pickupState$,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,11 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { it, vi, expect, beforeEach, afterEach, describe } from "vitest";
|
||||
import { firstValueFrom, of, Subject, take, toArray } from "rxjs";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
type RTCCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.ts";
|
||||
import type { Controls, OutputDevice } from "../controls";
|
||||
@@ -121,7 +117,7 @@ describe("Default selection", () => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(BASE_DEVICE_LIST),
|
||||
testScope,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
"video",
|
||||
mockControls,
|
||||
);
|
||||
|
||||
@@ -146,7 +142,7 @@ describe("Default selection", () => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(BASE_DEVICE_LIST),
|
||||
testScope,
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
"audio",
|
||||
mockControls,
|
||||
);
|
||||
|
||||
@@ -167,7 +163,7 @@ describe("Default selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
[RTC_CALL_INTENT_AUDIO, RTC_CALL_INTENT_VIDEO].forEach((callIntent) => {
|
||||
["audio", "video"].forEach((callIntent) => {
|
||||
it(`Default to BT headset for ${callIntent} calls if present`, async () => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(BT_HEADSET_BASE_DEVICE_LIST),
|
||||
@@ -194,7 +190,7 @@ describe("Default selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
[RTC_CALL_INTENT_AUDIO, RTC_CALL_INTENT_VIDEO].forEach((callIntent) => {
|
||||
["audio", "video"].forEach((callIntent) => {
|
||||
it(`Default to wired headset for ${callIntent} calls if present`, async () => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(WIRED_HEADSET_BASE_DEVICE_LIST),
|
||||
@@ -304,7 +300,7 @@ describe("Test select a device", () => {
|
||||
b: BT_HEADSET_BASE_DEVICE_LIST,
|
||||
}),
|
||||
testScope,
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
"audio",
|
||||
mockControls,
|
||||
);
|
||||
|
||||
@@ -344,7 +340,7 @@ describe("Test select a device", () => {
|
||||
c: BASE_DEVICE_LIST,
|
||||
}),
|
||||
testScope,
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
"audio",
|
||||
mockControls,
|
||||
);
|
||||
|
||||
@@ -397,7 +393,7 @@ describe("Available device changes", () => {
|
||||
});
|
||||
|
||||
it("When a BT headset is added, control should switch to use it", () => {
|
||||
createAudioControlledOutput(RTC_CALL_INTENT_VIDEO);
|
||||
createAudioControlledOutput("video");
|
||||
|
||||
// Emit the base device list, the speaker should be selected
|
||||
availableSource$.next(BASE_DEVICE_LIST);
|
||||
@@ -423,9 +419,7 @@ describe("Available device changes", () => {
|
||||
|
||||
// Android does not set `isExternalHeadset` to true for wired headphones, so we can't test this case.'
|
||||
it.skip("When a wired headset is added, control should switch to use it", async () => {
|
||||
const controlledAudioOutput = createAudioControlledOutput(
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
);
|
||||
const controlledAudioOutput = createAudioControlledOutput("video");
|
||||
|
||||
// Emit the base device list, the speaker should be selected
|
||||
availableSource$.next(BASE_DEVICE_LIST);
|
||||
@@ -452,7 +446,7 @@ describe("Available device changes", () => {
|
||||
});
|
||||
|
||||
it("When the active bt headset is removed on audio call, control should switch to earpiece", () => {
|
||||
createAudioControlledOutput(RTC_CALL_INTENT_AUDIO);
|
||||
createAudioControlledOutput("audio");
|
||||
|
||||
// Emit the BT headset device list, the BT headset should be selected
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
@@ -478,7 +472,7 @@ describe("Available device changes", () => {
|
||||
});
|
||||
|
||||
it("When the active bt headset is removed on video call, control should switch to speaker", () => {
|
||||
createAudioControlledOutput(RTC_CALL_INTENT_VIDEO);
|
||||
createAudioControlledOutput("video");
|
||||
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
|
||||
@@ -503,7 +497,7 @@ describe("Available device changes", () => {
|
||||
});
|
||||
|
||||
it("Do not repeatidly set the same device", () => {
|
||||
createAudioControlledOutput(RTC_CALL_INTENT_VIDEO);
|
||||
createAudioControlledOutput("video");
|
||||
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
|
||||
@@ -22,10 +22,7 @@ import {
|
||||
type SelectedAudioOutputDevice,
|
||||
} from "./MediaDevices.ts";
|
||||
import type { ObservableScope } from "./ObservableScope.ts";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
type RTCCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type Controls, type OutputDevice } from "../controls.ts";
|
||||
import { type Behavior } from "./Behavior.ts";
|
||||
|
||||
@@ -105,7 +102,7 @@ export class AndroidControlledAudioOutput implements MediaDevice<
|
||||
* @constructor
|
||||
* @param controlledDevices$ - The list of available output devices coming from the hosting application, ordered by preference order (most preferred first).
|
||||
* @param scope - The ObservableScope to create the Behaviors in.
|
||||
* @param initialIntent - The initial call intent (e.g. "m.audio" or "m.video") that can be used to determine the default audio routing (e.g. default to earpiece for audio calls and speaker for video calls).
|
||||
* @param initialIntent - The initial call intent (e.g. "audio" or "video") that can be used to determine the default audio routing (e.g. default to earpiece for audio calls and speaker for video calls).
|
||||
* @param controls - The controls provided by the hosting application to control the audio routing and notify of user actions.
|
||||
*/
|
||||
public constructor(
|
||||
@@ -319,7 +316,7 @@ export class AndroidControlledAudioOutput implements MediaDevice<
|
||||
this.logger.debug(
|
||||
`Android routing logic intent: ${this.initialIntent} finding best default...`,
|
||||
);
|
||||
if (this.initialIntent === RTC_CALL_INTENT_AUDIO) {
|
||||
if (this.initialIntent === "audio") {
|
||||
const systemProposed = available[0];
|
||||
// If no headset is connected, android will route to the speaker by default,
|
||||
// but for a voice call we want to route to the earpiece instead,
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 { BehaviorSubject, distinctUntilChanged, map, of } from "rxjs";
|
||||
import { type BackgroundProcessorWrapper } from "@livekit/track-processors";
|
||||
|
||||
import {
|
||||
@@ -47,23 +47,28 @@ function fakePipeline(): {
|
||||
};
|
||||
}
|
||||
|
||||
/** One letter per state: idle, waiting for a frame, attached. */
|
||||
/** One letter per state: idle, preparing, waiting for a frame, attached. */
|
||||
function letter(state: BackgroundEffectsState): string {
|
||||
if (state.supported === false) return "x";
|
||||
if (state.wait === "preparing") return "p";
|
||||
if (state.processor === undefined) return "i";
|
||||
return state.settling ? "w" : "a";
|
||||
return state.wait === "settling" ? "w" : "a";
|
||||
}
|
||||
|
||||
describe("the pipeline's state", () => {
|
||||
function testState({
|
||||
effect,
|
||||
answer = "-y",
|
||||
firstFrame = "",
|
||||
expected,
|
||||
}: {
|
||||
effect: string;
|
||||
/** When the trial build answers, from being asked. */
|
||||
answer?: string;
|
||||
firstFrame?: string;
|
||||
expected: string;
|
||||
}): void {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, cold, schedule, expectObservable }) => {
|
||||
const transformer = {
|
||||
onFirstFrame: undefined as (() => void) | undefined,
|
||||
};
|
||||
@@ -72,6 +77,7 @@ describe("the pipeline's state", () => {
|
||||
effect$: behavior(effect, { n: "none", b: "blur" }),
|
||||
setEffect: vi.fn(),
|
||||
added$: new BehaviorSubject<AddedBackground[] | undefined>([]),
|
||||
canSegment: () => cold(answer, { y: true, n: false }),
|
||||
pipeline: fakePipeline().pipeline,
|
||||
transformer,
|
||||
});
|
||||
@@ -84,15 +90,21 @@ describe("the pipeline's state", () => {
|
||||
|
||||
it("defaults to no effect", () => testState({ effect: "n", expected: "i" }));
|
||||
|
||||
it("attaches only once it is known the pipeline builds", () =>
|
||||
testState({ effect: "nb", answer: "--y", expected: "ip-w" }));
|
||||
|
||||
it("offers none where the pipeline fails to build", () =>
|
||||
testState({ effect: "nb", answer: "-n", expected: "ipx" }));
|
||||
|
||||
it("waits from the first effect until a frame carries it", () =>
|
||||
testState({
|
||||
effect: " nb-n-b",
|
||||
firstFrame: " --f",
|
||||
expected: " iwa",
|
||||
effect: " nb---n-b",
|
||||
firstFrame: "---f",
|
||||
expected: " ipwa",
|
||||
}));
|
||||
|
||||
it("attaches on first use and stays attached", () =>
|
||||
testState({ effect: "nb-n", firstFrame: "--f", expected: "iwa" }));
|
||||
testState({ effect: "nb--n", firstFrame: "---f", expected: "ipwa" }));
|
||||
});
|
||||
|
||||
describe("background effects", () => {
|
||||
@@ -109,6 +121,7 @@ describe("background effects", () => {
|
||||
effect$,
|
||||
setEffect,
|
||||
added$,
|
||||
canSegment: () => of(true),
|
||||
pipeline: fake.pipeline,
|
||||
transformer: { onFirstFrame: undefined },
|
||||
...options,
|
||||
@@ -118,8 +131,6 @@ describe("background effects", () => {
|
||||
effect$.next(raw);
|
||||
await flushPromises();
|
||||
};
|
||||
const blur = async (on: boolean): Promise<void> =>
|
||||
choose(on ? "blur" : "none");
|
||||
|
||||
beforeEach(() => {
|
||||
effect$ = new BehaviorSubject("none");
|
||||
@@ -128,6 +139,17 @@ describe("background effects", () => {
|
||||
fake = fakePipeline();
|
||||
});
|
||||
|
||||
it("offers none for the session where the pipeline fails to build", async () => {
|
||||
const effects = build({ canSegment: () => of(false) });
|
||||
await choose("blur");
|
||||
|
||||
expect(effects.state$.value.supported).toBe(false);
|
||||
expect(effects.state$.value.processor).toBeUndefined();
|
||||
expect(effects.state$.value.wait).toBeUndefined();
|
||||
// A browser update may fix it, so the next session asks again.
|
||||
expect(effect$.value).toBe("blur");
|
||||
});
|
||||
|
||||
it("puts a shipped background on as that picture", async () => {
|
||||
build();
|
||||
await choose(`image:${shippedBackgrounds[0].id}`);
|
||||
@@ -141,10 +163,10 @@ describe("background effects", () => {
|
||||
|
||||
it("switches in place rather than reattaching", async () => {
|
||||
const effects = build();
|
||||
await blur(true);
|
||||
await choose("blur");
|
||||
const pipeline = effects.state$.value.processor;
|
||||
await blur(false);
|
||||
await blur(true);
|
||||
await choose("none");
|
||||
await choose("blur");
|
||||
|
||||
expect(effects.state$.value.processor).toBe(pipeline);
|
||||
expect(fake.switches).toEqual([
|
||||
@@ -157,18 +179,18 @@ describe("background effects", () => {
|
||||
it("keeps switching after a switch fails", async () => {
|
||||
vi.mocked(fake.pipeline.switchTo).mockRejectedValueOnce(new Error("lost"));
|
||||
build();
|
||||
await blur(true);
|
||||
await blur(false);
|
||||
await choose("blur");
|
||||
await choose("none");
|
||||
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);
|
||||
await choose("blur");
|
||||
await choose("none");
|
||||
await choose("blur");
|
||||
await choose("none");
|
||||
expect(fake.switches).toHaveLength(1);
|
||||
|
||||
finished();
|
||||
|
||||
@@ -8,9 +8,15 @@ Please see LICENSE in the repository root for full details.
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
first,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
type ObservableInput,
|
||||
of,
|
||||
scan,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import {
|
||||
type BackgroundProcessorWrapper,
|
||||
@@ -38,8 +44,12 @@ 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;
|
||||
/**
|
||||
* The effect is not ready:
|
||||
* preparing, asking whether the pipeline can be built.
|
||||
* settling, once it is attached until a frame carrying the effect
|
||||
*/
|
||||
wait?: "preparing" | "settling";
|
||||
}
|
||||
|
||||
export interface BackgroundEffectsOptions {
|
||||
@@ -51,6 +61,8 @@ export interface BackgroundEffectsOptions {
|
||||
setEffect: (id: EffectId) => void;
|
||||
/** The backgrounds the device keeps, undefined until it has said. */
|
||||
added$: Behavior<AddedBackground[] | undefined>;
|
||||
/** Builds a segmenter on trial, and answers whether it could. */
|
||||
canSegment: () => ObservableInput<boolean>;
|
||||
/** The background processor pipeline to be switched between effects. */
|
||||
pipeline: BackgroundProcessorWrapper;
|
||||
/** Tells, once, that a frame carrying an effect has been drawn. */
|
||||
@@ -70,6 +82,7 @@ export function createBackgroundEffects(
|
||||
effect$,
|
||||
setEffect,
|
||||
added$,
|
||||
canSegment,
|
||||
pipeline,
|
||||
transformer,
|
||||
}: BackgroundEffectsOptions,
|
||||
@@ -77,9 +90,11 @@ export function createBackgroundEffects(
|
||||
// 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 wanted$ = scope.behavior(
|
||||
choice$.pipe(
|
||||
map((effect) => effect.kind !== "none"),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
);
|
||||
|
||||
combineLatest([choice$, added$])
|
||||
@@ -95,6 +110,18 @@ export function createBackgroundEffects(
|
||||
setEffect("none");
|
||||
});
|
||||
|
||||
// Asked once an effect is first wanted. A browser update may fix what
|
||||
// fails, so the choice is kept for the next session rather than cleared.
|
||||
const buildable$ = scope.behavior<boolean | undefined>(
|
||||
supported
|
||||
? wanted$.pipe(
|
||||
first(Boolean),
|
||||
switchMap(() => from(canSegment())),
|
||||
startWith(undefined),
|
||||
)
|
||||
: of(undefined),
|
||||
);
|
||||
|
||||
const drewAFrame$ = scope.behavior(
|
||||
new Observable<boolean>((subscriber) => {
|
||||
subscriber.next(false);
|
||||
@@ -105,18 +132,25 @@ export function createBackgroundEffects(
|
||||
}),
|
||||
);
|
||||
|
||||
// In one step, so that nothing sees the pipeline neither preparing nor
|
||||
// attached.
|
||||
const state$ = scope.behavior(
|
||||
combineLatest([wanted$, drewAFrame$]).pipe(
|
||||
scan<[boolean, boolean], BackgroundEffectsState>(
|
||||
(previous, [wanted, drewAFrame]) => {
|
||||
combineLatest([wanted$, buildable$, drewAFrame$]).pipe(
|
||||
scan<[boolean, boolean | undefined, boolean], BackgroundEffectsState>(
|
||||
(previous, [wanted, buildable, 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);
|
||||
previous.processor !== undefined || (wanted && buildable === true);
|
||||
const preparing = supported && wanted && buildable === undefined;
|
||||
return {
|
||||
supported,
|
||||
supported: supported && buildable !== false,
|
||||
processor: enable ? processor : undefined,
|
||||
settling: enable && !drewAFrame,
|
||||
wait: preparing
|
||||
? "preparing"
|
||||
: enable && !drewAFrame
|
||||
? "settling"
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
{ supported, processor: undefined },
|
||||
|
||||
@@ -12,11 +12,7 @@ import {
|
||||
MatrixEvent,
|
||||
type Room,
|
||||
} from "matrix-js-sdk";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
type RTCCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { map, mergeMap, NEVER, type Observable, startWith } from "rxjs";
|
||||
|
||||
import { withTestScheduler } from "../../utils/test";
|
||||
@@ -37,12 +33,10 @@ function mockRingEvent(
|
||||
eventId: string,
|
||||
lifetimeMs: number | undefined,
|
||||
sender = local.userId,
|
||||
legacyIntent?: string,
|
||||
): CallNotificationWrapper {
|
||||
return {
|
||||
event_id: eventId,
|
||||
...(lifetimeMs === undefined ? {} : { lifetime: lifetimeMs }),
|
||||
...(legacyIntent === undefined ? {} : { "m.call.intent": legacyIntent }),
|
||||
notification_type: "ring",
|
||||
sender,
|
||||
} as unknown as CallNotificationWrapper;
|
||||
@@ -134,25 +128,7 @@ test("ring attempt times out after nobody joins", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("ring attempt maps the legacy intent on the notification to its stable value", () => {
|
||||
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
...defaultProps,
|
||||
memberUserIds$: constant<string[]>([]),
|
||||
sentCallNotification$: hot("-a", {
|
||||
a: mockRingEvent("$notif1", 30, local.userId, "video"),
|
||||
}),
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: RTC_CALL_INTENT_VIDEO, recipient: alice.userId },
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
@@ -172,7 +148,7 @@ test("ring attempt is accepted once recipient joins", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
A: { outcome: "accept" },
|
||||
});
|
||||
});
|
||||
@@ -190,7 +166,7 @@ test("ring attempt is immediately accepted if recipient is already joined", () =
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-(aA)", {
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
A: { outcome: "accept" },
|
||||
});
|
||||
});
|
||||
@@ -225,7 +201,7 @@ test("ring attempt can be declined", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
A: { outcome: "decline" },
|
||||
});
|
||||
});
|
||||
@@ -260,7 +236,7 @@ test("ring attempt times out if recipient declines too late", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
@@ -295,7 +271,7 @@ test("decline event relating to wrong event is ignored (times out)", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
@@ -330,7 +306,7 @@ test("decline event from wrong sender is ignored (times out)", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,9 +9,7 @@ import {
|
||||
type IRTCNotificationContent,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
type RTCCallIntent,
|
||||
toStableCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
filter,
|
||||
@@ -157,10 +155,7 @@ export function createCallNotificationLifecycle$({
|
||||
);
|
||||
|
||||
return of({
|
||||
// The notification still carries the legacy intent values
|
||||
intent:
|
||||
toStableCallIntent(notificationEvent["m.call.intent"]) ??
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
intent: notificationEvent["m.call.intent"] ?? "audio",
|
||||
recipient,
|
||||
outcome$: race(timeout$, accept$, decline$).pipe(
|
||||
take(1),
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
type CallMembership,
|
||||
type IRTCNotificationContent,
|
||||
MatrixRTCSessionEvent,
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
@@ -71,9 +70,11 @@ import {
|
||||
} from "./CallViewModel.ts";
|
||||
import { type Handler, MuteState } from "../MuteStates.ts";
|
||||
import {
|
||||
type LocalVideoMediaTrack,
|
||||
type RTCParticipation,
|
||||
type VideoProcessor,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { type BackgroundEffectsState } from "../BackgroundEffects";
|
||||
|
||||
initializeWidget();
|
||||
|
||||
@@ -1558,7 +1559,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
b: expect.objectContaining({
|
||||
type: "ringing",
|
||||
userId: alice.userId,
|
||||
intent: RTC_CALL_INTENT_AUDIO,
|
||||
intent: "audio",
|
||||
}),
|
||||
});
|
||||
// Layout should show placeholder media for the participant we're
|
||||
@@ -1604,7 +1605,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
b: expect.objectContaining({
|
||||
type: "ringing",
|
||||
userId: alice.userId,
|
||||
intent: RTC_CALL_INTENT_AUDIO,
|
||||
intent: "audio",
|
||||
}),
|
||||
});
|
||||
// Layout should show placeholder media for the participant we're
|
||||
@@ -1693,28 +1694,113 @@ describe("a camera with a video processor", () => {
|
||||
muteStates,
|
||||
mockMediaDevices({}),
|
||||
{},
|
||||
processor,
|
||||
{ supported: true, processor },
|
||||
).find((request) => request.source === "camera"),
|
||||
).toMatchObject({ processor });
|
||||
});
|
||||
|
||||
it("leaves the camera out of the join while the processor is prepared", () => {
|
||||
const muteStates = mockMuteStates();
|
||||
vi.spyOn(muteStates.video.enabled$, "value", "get").mockReturnValue(true);
|
||||
const sources = (wait?: "preparing"): string[] =>
|
||||
initialPublishRequests(
|
||||
muteStates,
|
||||
mockMediaDevices({}),
|
||||
{},
|
||||
{
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
wait,
|
||||
},
|
||||
).map((request) => request.source);
|
||||
expect(sources("preparing")).not.toContain("camera");
|
||||
expect(sources()).toContain("camera");
|
||||
});
|
||||
|
||||
it("waits to make a camera track until it is known the processor builds", async () => {
|
||||
const setHandler = vi.spyOn(MuteState.prototype, "setHandler");
|
||||
onTestFinished(() => setHandler.mockRestore());
|
||||
const effects$ = new BehaviorSubject<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
wait: "preparing",
|
||||
});
|
||||
let participation: RTCParticipation | undefined;
|
||||
withCallViewModel(
|
||||
{},
|
||||
(_vm, _session, { rtcParticipation }) => {
|
||||
participation = rtcParticipation;
|
||||
},
|
||||
{ backgroundEffectsState$: effects$ },
|
||||
);
|
||||
const camera = participation!.localMember$.value?.tracks$.value?.find(
|
||||
(track) => track.source === "camera",
|
||||
) as LocalVideoMediaTrack;
|
||||
const turnedOn = cameraHandler(setHandler)(true);
|
||||
await flushPromises();
|
||||
expect(camera.setEnabled).not.toHaveBeenCalledWith(true);
|
||||
|
||||
effects$.next({ supported: true, processor });
|
||||
await turnedOn;
|
||||
expect(camera.setEnabled).toHaveBeenCalledWith(true);
|
||||
expect(participation!.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("turns the camera on only once its processor is on the track", async () => {
|
||||
const setHandler = vi.spyOn(MuteState.prototype, "setHandler");
|
||||
onTestFinished(() => setHandler.mockRestore());
|
||||
const effects$ = new BehaviorSubject<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
wait: "preparing",
|
||||
});
|
||||
let participation: RTCParticipation | undefined;
|
||||
withCallViewModel(
|
||||
{},
|
||||
(_vm, _session, { rtcParticipation }) => {
|
||||
participation = rtcParticipation;
|
||||
},
|
||||
{ backgroundEffectsState$: effects$ },
|
||||
);
|
||||
const camera = participation!.localMember$.value?.tracks$.value?.find(
|
||||
(track) => track.source === "camera",
|
||||
) as LocalVideoMediaTrack;
|
||||
// Attaching takes its time, as the pipeline is built on the track
|
||||
let attached!: () => void;
|
||||
vi.mocked(camera.setProcessor).mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
attached = resolve;
|
||||
}),
|
||||
);
|
||||
const turnedOn = cameraHandler(setHandler)(true);
|
||||
effects$.next({ supported: true, processor });
|
||||
await flushPromises();
|
||||
expect(camera.setProcessor).toHaveBeenCalledWith(processor);
|
||||
expect(camera.setEnabled).not.toHaveBeenCalledWith(true);
|
||||
|
||||
attached();
|
||||
await turnedOn;
|
||||
expect(camera.setEnabled).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
// 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,
|
||||
);
|
||||
const effects$ = new BehaviorSubject<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
});
|
||||
let participation: RTCParticipation | undefined;
|
||||
withCallViewModel(
|
||||
{},
|
||||
(_vm, _session, { rtcParticipation }) => {
|
||||
participation = rtcParticipation;
|
||||
},
|
||||
{ videoProcessor$ },
|
||||
{ backgroundEffectsState$: effects$ },
|
||||
);
|
||||
videoProcessor$.next(processor);
|
||||
effects$.next({ supported: true, processor });
|
||||
await cameraHandler(setHandler)(true);
|
||||
await flushPromises();
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
fromEvent,
|
||||
map,
|
||||
merge,
|
||||
@@ -77,6 +78,7 @@ import {
|
||||
type ReactionInfo,
|
||||
type ReactionOption,
|
||||
} from "../../reactions";
|
||||
import { type BackgroundEffectsState } from "../BackgroundEffects";
|
||||
import { shallowEquals as arrayShallowEquals } from "../../utils/array";
|
||||
import { type MediaDevices } from "../MediaDevices";
|
||||
import { type MuteStates } from "../MuteStates";
|
||||
@@ -170,13 +172,16 @@ export function initialPublishRequests(
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
capture: CaptureSettings,
|
||||
processor?: VideoProcessor,
|
||||
effects?: BackgroundEffectsState,
|
||||
): PublishRequest[] {
|
||||
const requests: PublishRequest[] = [];
|
||||
if (muteStates.audio.enabled$.value)
|
||||
requests.push(publishRequest("microphone", mediaDevices, capture));
|
||||
if (muteStates.video.enabled$.value)
|
||||
requests.push(publishRequest("camera", mediaDevices, capture, processor));
|
||||
// Left out while the processor is prepared; the call publishes it after
|
||||
if (muteStates.video.enabled$.value && effects?.wait !== "preparing")
|
||||
requests.push(
|
||||
publishRequest("camera", mediaDevices, capture, effects?.processor),
|
||||
);
|
||||
return requests;
|
||||
}
|
||||
|
||||
@@ -187,8 +192,8 @@ export interface CallViewModelOptions {
|
||||
* defaults otherwise.
|
||||
*/
|
||||
capture?: CaptureSettings;
|
||||
/** Background blur and the like, applied to the camera as it changes. */
|
||||
videoProcessor$?: Behavior<VideoProcessor | undefined>;
|
||||
/** The background effects state (preparing, supported...) including the processor itself. */
|
||||
backgroundEffectsState$?: Behavior<BackgroundEffectsState>;
|
||||
/**
|
||||
* The application hosting Element Call, which can ask it to hang up and wants
|
||||
* to know when the user joins or leaves. Defaults to no host.
|
||||
@@ -363,6 +368,8 @@ export interface CallViewModel {
|
||||
keyRotationSuppressed$: Behavior<boolean>;
|
||||
/** The local member, once our membership has been seen in the room. */
|
||||
localMember$: Behavior<LocalRTCMember | null>;
|
||||
/** Our own camera track, while one is published. */
|
||||
camera$: Behavior<LocalVideoMediaTrack | undefined>;
|
||||
remoteMembers$: Behavior<RemoteRTCMember[]>;
|
||||
/** The transports the client holds a connection to, for the developer panel. */
|
||||
connectedTransports$: Behavior<TransportMetadata[]>;
|
||||
@@ -488,7 +495,21 @@ export function createCallViewModel$(
|
||||
sentCallNotification$ = of(null),
|
||||
} = options;
|
||||
|
||||
const { capture = {}, videoProcessor$ = constant(undefined) } = options;
|
||||
const {
|
||||
capture = {},
|
||||
backgroundEffectsState$ = constant<BackgroundEffectsState>({
|
||||
supported: false,
|
||||
processor: undefined,
|
||||
}),
|
||||
} = options;
|
||||
const videoProcessor$ = scope.behavior(
|
||||
backgroundEffectsState$.pipe(map(({ processor }) => processor)),
|
||||
);
|
||||
// Resolves once it is known whether the processor builds
|
||||
const processorKnown = async (): Promise<unknown> =>
|
||||
firstValueFrom(
|
||||
backgroundEffectsState$.pipe(filter(({ wait }) => wait !== "preparing")),
|
||||
);
|
||||
const localTracks$ = scope.behavior<
|
||||
(LocalAudioMediaTrack | LocalVideoMediaTrack)[]
|
||||
>(
|
||||
@@ -512,6 +533,19 @@ export function createCallViewModel$(
|
||||
"screenShare",
|
||||
) as Behavior<LocalVideoMediaTrack | undefined>;
|
||||
|
||||
const processorAttached = followProcessor(
|
||||
scope,
|
||||
camera$,
|
||||
videoProcessor$,
|
||||
logger,
|
||||
);
|
||||
|
||||
// A camera turned on waits for its processor to be known, then to be on
|
||||
// the track: LiveKit restarts the track with whatever processor it has then.
|
||||
const cameraReady = async (): Promise<void> => {
|
||||
await processorKnown();
|
||||
await processorAttached();
|
||||
};
|
||||
const supply = (source: "microphone" | "camera"): TrackSupply => ({
|
||||
publish: async () =>
|
||||
rtcParticipation.publish(
|
||||
@@ -522,6 +556,7 @@ export function createCallViewModel$(
|
||||
source === "camera" ? videoProcessor$.value : undefined,
|
||||
),
|
||||
),
|
||||
ready: source === "camera" ? cameraReady : undefined,
|
||||
});
|
||||
followMuteState(
|
||||
scope,
|
||||
@@ -548,6 +583,29 @@ export function createCallViewModel$(
|
||||
);
|
||||
followDevice(scope, camera$, mediaDevices.videoInput, "camera", logger);
|
||||
|
||||
// A camera wanted at the join was left out of it while the processor was
|
||||
// prepared (see initialPublishRequests), and is published once that is known
|
||||
if (
|
||||
backgroundEffectsState$.value.wait === "preparing" &&
|
||||
muteStates.video.enabled$.value
|
||||
)
|
||||
processorKnown()
|
||||
.then(async () => {
|
||||
if (camera$.value !== undefined || !muteStates.video.enabled$.value)
|
||||
return;
|
||||
await rtcParticipation.publish(
|
||||
publishRequest(
|
||||
"camera",
|
||||
mediaDevices,
|
||||
capture,
|
||||
videoProcessor$.value,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("Failed to publish the camera", e);
|
||||
});
|
||||
|
||||
// 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
|
||||
// client is left alone
|
||||
@@ -561,8 +619,6 @@ export function createCallViewModel$(
|
||||
});
|
||||
});
|
||||
|
||||
followProcessor(scope, camera$, videoProcessor$, logger);
|
||||
|
||||
const members$ = scope.behavior<(LocalRTCMember | RemoteRTCMember)[]>(
|
||||
combineLatest(
|
||||
[rtcParticipation.localMember$, rtcParticipation.remoteMembers$],
|
||||
@@ -1508,6 +1564,7 @@ export function createCallViewModel$(
|
||||
leave,
|
||||
toggleScreenSharing: toggleScreenSharing,
|
||||
sharingScreen$: sharingScreen$,
|
||||
camera$,
|
||||
|
||||
tapScreen: (): void => screenTap$.next(),
|
||||
tapControls: (): void => controlsTap$.next(),
|
||||
|
||||
@@ -21,10 +21,6 @@ import {
|
||||
EARPIECE_CONFIG_ID,
|
||||
IOSControlledAudioOutput,
|
||||
} from "./IOSControlledAudioOutput";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
// `vi.mock` calls are hoisted above all imports, so the static imports below
|
||||
// already see these mocks. Force the iOS platform so that the virtual earpiece
|
||||
@@ -85,7 +81,7 @@ describe("Default selection", () => {
|
||||
const output = new IOSControlledAudioOutput(
|
||||
constant(false),
|
||||
testScope,
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
"audio",
|
||||
);
|
||||
const selected = latestSelection(output);
|
||||
|
||||
@@ -104,7 +100,7 @@ describe("Default selection", () => {
|
||||
const output = new IOSControlledAudioOutput(
|
||||
constant(false),
|
||||
testScope,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
"video",
|
||||
);
|
||||
const selected = latestSelection(output);
|
||||
|
||||
@@ -117,7 +113,7 @@ describe("Default selection", () => {
|
||||
const output = new IOSControlledAudioOutput(
|
||||
constant(false),
|
||||
testScope,
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
"audio",
|
||||
);
|
||||
const selected = latestSelection(output);
|
||||
|
||||
@@ -134,7 +130,7 @@ describe("Explicit selection", () => {
|
||||
const output = new IOSControlledAudioOutput(
|
||||
constant(false),
|
||||
testScope,
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
"audio",
|
||||
);
|
||||
const selected = latestSelection(output);
|
||||
|
||||
@@ -154,7 +150,7 @@ describe("Explicit selection", () => {
|
||||
const output = new IOSControlledAudioOutput(
|
||||
constant(false),
|
||||
testScope,
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
"audio",
|
||||
);
|
||||
const selected = latestSelection(output);
|
||||
|
||||
|
||||
@@ -8,10 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { combineLatest, merge, startWith, Subject, tap } from "rxjs";
|
||||
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
type RTCCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
availableOutputDevices$ as controlledAvailableOutputDevices$,
|
||||
outputDevice$ as controlledOutputSelection$,
|
||||
@@ -114,7 +111,7 @@ export class IOSControlledAudioOutput implements MediaDevice<
|
||||
* Chooses the default output device when no explicit selection (from the user
|
||||
* or the hosting application) has been made yet.
|
||||
*
|
||||
* For voice calls (`initialIntent === "m.audio"`) we want to start on the
|
||||
* For voice calls (`initialIntent === "audio"`) we want to start on the
|
||||
* earpiece rather than the speaker, like a regular phone call. We only
|
||||
* override when the device that would otherwise be the default is the
|
||||
* speaker: if the host already routed to a headset (e.g. Bluetooth) — which
|
||||
@@ -125,7 +122,7 @@ export class IOSControlledAudioOutput implements MediaDevice<
|
||||
available: Map<string, AudioOutputDeviceLabel>,
|
||||
): string | undefined {
|
||||
const firstId = available.keys().next().value;
|
||||
if (this.initialIntent === RTC_CALL_INTENT_AUDIO) {
|
||||
if (this.initialIntent === "audio") {
|
||||
const firstLabel =
|
||||
firstId !== undefined ? available.get(firstId) : undefined;
|
||||
if (firstLabel?.type === "speaker") {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { type MediaDevices } from "./MediaDevices";
|
||||
import { MuteStates } from "./MuteStates";
|
||||
import { nullHostBridge } from "../HostBridge";
|
||||
import { flushPromises, mockMediaDevices, testScope } from "../utils/test";
|
||||
import { type BackgroundEffectsState } from "./BackgroundEffects";
|
||||
|
||||
describe("LobbyMediaViewModel", () => {
|
||||
it("opens what starts enabled in one capture and follows the devices", async () => {
|
||||
@@ -135,7 +136,7 @@ function setup(
|
||||
muteStates,
|
||||
mediaDevices,
|
||||
{},
|
||||
constant(processor),
|
||||
constant<BackgroundEffectsState>({ supported: true, processor }),
|
||||
);
|
||||
return { media, vm, muteStates, mediaDevices };
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
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";
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
import { type MediaDevices } from "./MediaDevices";
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { followDevice, followProcessor } from "./localMediaControls";
|
||||
import { type BackgroundEffectsState } from "./BackgroundEffects";
|
||||
|
||||
export interface LobbyMediaViewModel {
|
||||
/** The camera, once open, for the preview. */
|
||||
@@ -42,9 +42,13 @@ export function createLobbyMediaViewModel(
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
capture: CaptureSettings,
|
||||
videoProcessor$: Behavior<VideoProcessor | undefined>,
|
||||
/** No camera is opened while its pipeline is prepared: it would show the room without the processor. */
|
||||
backgroundEffectsState$: Behavior<BackgroundEffectsState>,
|
||||
): LobbyMediaViewModel {
|
||||
const logger = rootLogger.getChild("[LobbyMedia]");
|
||||
const videoProcessor$ = scope.behavior(
|
||||
backgroundEffectsState$.pipe(map(({ processor }) => processor)),
|
||||
);
|
||||
const microphone$ = trackBySource$(
|
||||
scope,
|
||||
media.tracks$,
|
||||
@@ -64,13 +68,19 @@ export function createLobbyMediaViewModel(
|
||||
muteStates.video.enabled$,
|
||||
microphone$,
|
||||
camera$,
|
||||
backgroundEffectsState$,
|
||||
])
|
||||
.pipe(debounceTime(0), scope.bind())
|
||||
.subscribe(([audio, video, microphone, camera]) => {
|
||||
.subscribe(([audio, video, microphone, camera, { wait }]) => {
|
||||
const open: PublishRequest[] = [];
|
||||
if (audio && microphone === undefined && !opening.has("microphone"))
|
||||
open.push(publishRequest("microphone", mediaDevices, capture));
|
||||
if (video && camera === undefined && !opening.has("camera"))
|
||||
if (
|
||||
video &&
|
||||
camera === undefined &&
|
||||
!opening.has("camera") &&
|
||||
wait !== "preparing"
|
||||
)
|
||||
open.push(
|
||||
publishRequest(
|
||||
"camera",
|
||||
|
||||
@@ -35,10 +35,6 @@ import { AudioOutput, MediaDevices } from "./MediaDevices";
|
||||
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput";
|
||||
import { IOSControlledAudioOutput } from "./IOSControlledAudioOutput";
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
// Which audio output implementation is used is decided by what the app hosting
|
||||
// Element Call told it, rather than being discovered from the environment.
|
||||
@@ -56,7 +52,7 @@ describe("MediaDevices audio output", () => {
|
||||
|
||||
const devices = new MediaDevices(new ObservableScope(), {
|
||||
controlledAudioDevices: true,
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
callIntent: "audio",
|
||||
});
|
||||
|
||||
expect(devices.audioOutput).toBeInstanceOf(AndroidControlledAudioOutput);
|
||||
@@ -67,7 +63,7 @@ describe("MediaDevices audio output", () => {
|
||||
|
||||
const devices = new MediaDevices(new ObservableScope(), {
|
||||
controlledAudioDevices: true,
|
||||
callIntent: RTC_CALL_INTENT_VIDEO,
|
||||
callIntent: "video",
|
||||
});
|
||||
|
||||
expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput);
|
||||
|
||||
@@ -6,11 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, expect } from "vitest";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
type RTCCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { calculateInitialMuteState } from "./initialMuteState";
|
||||
|
||||
@@ -18,10 +14,10 @@ test.each<{
|
||||
callIntent: RTCCallIntent;
|
||||
allowJoinUnmutedViaIntent: boolean;
|
||||
}>([
|
||||
{ callIntent: RTC_CALL_INTENT_AUDIO, allowJoinUnmutedViaIntent: false },
|
||||
{ callIntent: RTC_CALL_INTENT_AUDIO, allowJoinUnmutedViaIntent: true },
|
||||
{ callIntent: RTC_CALL_INTENT_VIDEO, allowJoinUnmutedViaIntent: false },
|
||||
{ callIntent: RTC_CALL_INTENT_VIDEO, allowJoinUnmutedViaIntent: true },
|
||||
{ callIntent: "audio", allowJoinUnmutedViaIntent: false },
|
||||
{ callIntent: "audio", allowJoinUnmutedViaIntent: true },
|
||||
{ callIntent: "video", allowJoinUnmutedViaIntent: false },
|
||||
{ callIntent: "video", allowJoinUnmutedViaIntent: true },
|
||||
{ callIntent: "unknown", allowJoinUnmutedViaIntent: false },
|
||||
{ callIntent: "unknown", allowJoinUnmutedViaIntent: true },
|
||||
])(
|
||||
@@ -33,15 +29,15 @@ test.each<{
|
||||
allowJoinUnmutedViaIntent,
|
||||
);
|
||||
expect(audioEnabled).toBe(true);
|
||||
expect(videoEnabled).toBe(callIntent !== RTC_CALL_INTENT_AUDIO);
|
||||
expect(videoEnabled).toBe(callIntent !== "audio");
|
||||
},
|
||||
);
|
||||
|
||||
test.each<{
|
||||
callIntent: RTCCallIntent;
|
||||
}>([
|
||||
{ callIntent: RTC_CALL_INTENT_AUDIO },
|
||||
{ callIntent: RTC_CALL_INTENT_VIDEO },
|
||||
{ callIntent: "audio" },
|
||||
{ callIntent: "video" },
|
||||
{ callIntent: "unknown" },
|
||||
])(
|
||||
"Should always mute on start if skipping lobby and the host does not vouch for the intent (callIntent: $callIntent)",
|
||||
@@ -59,8 +55,8 @@ test.each<{
|
||||
test.each<{
|
||||
callIntent: RTCCallIntent;
|
||||
}>([
|
||||
{ callIntent: RTC_CALL_INTENT_AUDIO },
|
||||
{ callIntent: RTC_CALL_INTENT_VIDEO },
|
||||
{ callIntent: "audio" },
|
||||
{ callIntent: "video" },
|
||||
{ callIntent: "unknown" },
|
||||
])(
|
||||
"Can start unmuted if skipping lobby and the host vouches for the intent (callIntent: $callIntent)",
|
||||
@@ -71,6 +67,6 @@ test.each<{
|
||||
true,
|
||||
);
|
||||
expect(audioEnabled).toBe(true);
|
||||
expect(videoEnabled).toBe(callIntent !== RTC_CALL_INTENT_AUDIO);
|
||||
expect(videoEnabled).toBe(callIntent !== "audio");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,10 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
type RTCCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
/**
|
||||
* Calculates the initial mute state for media devices based on configuration.
|
||||
@@ -43,10 +40,10 @@ export function calculateInitialMuteState(
|
||||
|
||||
// A host that vouches for the intent is a trusted environment, so it allows
|
||||
// unmuted by default. Same for when showing a lobby, as users can adjust
|
||||
// their settings there. Additionally, if the call intent is "m.audio", we
|
||||
// their settings there. Additionally, if the call intent is "audio", we
|
||||
// disable video by default.
|
||||
return {
|
||||
audioEnabled: true,
|
||||
videoEnabled: callIntent != RTC_CALL_INTENT_AUDIO,
|
||||
videoEnabled: callIntent != "audio",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { type Handler } from "./MuteStates";
|
||||
/** How a mute switch gets a track where there is none. */
|
||||
export interface TrackSupply {
|
||||
publish: () => Promise<unknown>;
|
||||
/** Awaited before a switch turned on acts: a camera waits for its processor to be known. */
|
||||
ready?: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +47,7 @@ export function followMuteState(
|
||||
let desired = muteState.enabled$.value;
|
||||
muteState.setHandler(async (enabled) => {
|
||||
desired = enabled;
|
||||
if (enabled) await supply.ready?.();
|
||||
const track = track$.value;
|
||||
if (track !== undefined) {
|
||||
desired = await track.setEnabled(enabled);
|
||||
@@ -94,18 +97,23 @@ export function followDevice(
|
||||
/**
|
||||
* 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.
|
||||
* Answers with a wait for the latest attachment to have gone through: a
|
||||
* camera turned on before then would restart without its processor.
|
||||
*/
|
||||
export function followProcessor(
|
||||
scope: ObservableScope,
|
||||
camera$: Behavior<LocalVideoMediaTrack | undefined>,
|
||||
videoProcessor$: Behavior<VideoProcessor | undefined>,
|
||||
logger: Logger,
|
||||
): void {
|
||||
): () => Promise<void> {
|
||||
let attached = Promise.resolve();
|
||||
combineLatest([camera$, videoProcessor$])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([camera, processor]) => {
|
||||
camera?.setProcessor(processor).catch((e) => {
|
||||
logger.error("Failed to set the video processor", e);
|
||||
});
|
||||
attached =
|
||||
camera?.setProcessor(processor).catch((e) => {
|
||||
logger.error("Failed to set the video processor", e);
|
||||
}) ?? Promise.resolve();
|
||||
});
|
||||
return async () => attached;
|
||||
}
|
||||
|
||||
@@ -8,10 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { test, expect } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { GridTile } from "./GridTile";
|
||||
@@ -125,7 +122,7 @@ test("GridTile displays ringing media", async () => {
|
||||
const vm = createRingingMedia({
|
||||
pickupState$,
|
||||
id: "test",
|
||||
intent: RTC_CALL_INTENT_AUDIO,
|
||||
intent: "audio",
|
||||
userId: "@alice:example.org",
|
||||
displayName$: constant("Alice"),
|
||||
mxcAvatarUrl$: constant(undefined),
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
EndCallIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RTC_CALL_INTENT_VIDEO } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
@@ -26,7 +25,7 @@ export const RingingStatus: FC<Props> = ({ vm }) => {
|
||||
const pickupState = useBehavior(vm.pickupState$);
|
||||
const Icon =
|
||||
pickupState === "ringing"
|
||||
? vm.intent === RTC_CALL_INTENT_VIDEO
|
||||
? vm.intent === "video"
|
||||
? VideoCallSolidIcon
|
||||
: VoiceCallSolidIcon
|
||||
: EndCallIcon;
|
||||
|
||||
@@ -11,7 +11,6 @@ import { axe } from "vitest-axe";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { RTC_CALL_INTENT_AUDIO } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { SpotlightTile } from "./SpotlightTile";
|
||||
import {
|
||||
@@ -177,7 +176,7 @@ test("SpotlightTile displays ringing media", async () => {
|
||||
const vm = createRingingMedia({
|
||||
pickupState$,
|
||||
id: "test",
|
||||
intent: RTC_CALL_INTENT_AUDIO,
|
||||
intent: "audio",
|
||||
userId: "@alice:example.org",
|
||||
displayName$: constant("Alice"),
|
||||
mxcAvatarUrl$: constant(undefined),
|
||||
|
||||
@@ -13,13 +13,19 @@ import {
|
||||
} from "@livekit/track-processors";
|
||||
import { ImageSegmenter } from "@mediapipe/tasks-vision";
|
||||
|
||||
import { BackgroundEffectTransformer } from "./BackgroundEffectTransformer";
|
||||
import {
|
||||
BackgroundEffectTransformer,
|
||||
canSegment,
|
||||
} from "./BackgroundEffectTransformer";
|
||||
|
||||
const frame = {} as VideoFrame;
|
||||
const controller = {} as TransformStreamDefaultController<VideoFrame>;
|
||||
|
||||
describe("BackgroundEffectTransformer", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("loads a background chosen before it was built", async () => {
|
||||
vi.spyOn(VideoTransformer.prototype, "init").mockResolvedValue();
|
||||
@@ -78,4 +84,33 @@ describe("BackgroundEffectTransformer", () => {
|
||||
expect(transformer.options.blurRadius).toBeUndefined();
|
||||
expect(transformer.options.imagePath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("segments on the CPU where the GPU refuses", async () => {
|
||||
vi.spyOn(VideoTransformer.prototype, "init").mockResolvedValue();
|
||||
const build = vi
|
||||
.spyOn(ImageSegmenter, "createFromOptions")
|
||||
.mockRejectedValueOnce(new Error("blocklisted"))
|
||||
.mockResolvedValue({} as ImageSegmenter);
|
||||
|
||||
await new BackgroundEffectTransformer({}).init(
|
||||
{} as VideoTransformerInitOptions,
|
||||
);
|
||||
expect(build.mock.calls.map(([, o]) => o.baseOptions?.delegate)).toEqual([
|
||||
"GPU",
|
||||
"CPU",
|
||||
]);
|
||||
});
|
||||
|
||||
it("says whether a segmenter can be built at all", async () => {
|
||||
vi.stubGlobal("OffscreenCanvas", class {});
|
||||
const close = vi.fn();
|
||||
const build = vi
|
||||
.spyOn(ImageSegmenter, "createFromOptions")
|
||||
.mockResolvedValue({ close } as unknown as ImageSegmenter);
|
||||
expect(await canSegment()).toBe(true);
|
||||
expect(close).toHaveBeenCalled();
|
||||
|
||||
build.mockRejectedValue(new Error("no model"));
|
||||
expect(await canSegment()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
VideoTransformer,
|
||||
type VideoTransformerInitOptions,
|
||||
type BackgroundOptions,
|
||||
type SegmenterOptions,
|
||||
} from "@livekit/track-processors";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { ImageSegmenter } from "@mediapipe/tasks-vision";
|
||||
@@ -92,17 +93,10 @@ export class BackgroundEffectTransformer extends BackgroundTransformer {
|
||||
inputElement: inputVideo,
|
||||
});
|
||||
|
||||
this.imageSegmenter = await ImageSegmenter.createFromOptions(wasmFileset, {
|
||||
baseOptions: {
|
||||
modelAssetPath,
|
||||
delegate: "GPU",
|
||||
...this.options.segmenterOptions,
|
||||
},
|
||||
canvas: this.canvas,
|
||||
runningMode: "VIDEO",
|
||||
outputCategoryMask: true,
|
||||
outputConfidenceMasks: false,
|
||||
});
|
||||
this.imageSegmenter = await createSegmenter(
|
||||
this.canvas,
|
||||
this.options.segmenterOptions,
|
||||
);
|
||||
|
||||
// BackgroundTransformer's own init applies these, and this one replaces it.
|
||||
if (this.options.imagePath) {
|
||||
@@ -116,3 +110,38 @@ export class BackgroundEffectTransformer extends BackgroundTransformer {
|
||||
this.gl?.setBackgroundDisabled(this.options.backgroundDisabled ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a segmenter can be built here, asked once before any camera depends
|
||||
* on one: a pipeline that fails to build takes the camera down with it.
|
||||
*/
|
||||
export async function canSegment(): Promise<boolean> {
|
||||
try {
|
||||
(await createSegmenter(new OffscreenCanvas(1, 1))).close();
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.warn("Background effects cannot run here", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** On the GPU, or on the CPU where the GPU refuses, as a blocklisted one does. */
|
||||
async function createSegmenter(
|
||||
canvas: HTMLCanvasElement | OffscreenCanvas | undefined,
|
||||
segmenterOptions?: SegmenterOptions,
|
||||
): Promise<ImageSegmenter> {
|
||||
const build = async (delegate: "GPU" | "CPU"): Promise<ImageSegmenter> =>
|
||||
ImageSegmenter.createFromOptions(wasmFileset, {
|
||||
baseOptions: { modelAssetPath, delegate, ...segmenterOptions },
|
||||
canvas,
|
||||
runningMode: "VIDEO",
|
||||
outputCategoryMask: true,
|
||||
outputConfidenceMasks: false,
|
||||
});
|
||||
try {
|
||||
return await build("GPU");
|
||||
} catch (e) {
|
||||
logger.warn("Segmenting on the CPU, as the GPU refused", e);
|
||||
return build("CPU");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@ vi.mock("./backgroundImages", async () => {
|
||||
};
|
||||
});
|
||||
vi.mock("./BackgroundEffectTransformer", () => ({
|
||||
BackgroundEffectTransformer: vi.fn(),
|
||||
BackgroundEffectTransformer: vi.fn(function () {}),
|
||||
canSegment: async (): Promise<boolean> => Promise.resolve(true),
|
||||
}));
|
||||
|
||||
describe("BackgroundEffectsProvider", () => {
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
import { createContext, type FC, type JSX, use } from "react";
|
||||
|
||||
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
|
||||
import { BackgroundEffectTransformer } from "./BackgroundEffectTransformer";
|
||||
import {
|
||||
BackgroundEffectTransformer,
|
||||
canSegment,
|
||||
} from "./BackgroundEffectTransformer";
|
||||
import { addedBackgrounds } from "./backgroundImages";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
import { type ObservableScope } from "../state/ObservableScope";
|
||||
@@ -56,6 +59,7 @@ export function createAppBackgroundEffects(
|
||||
effect$: backgroundEffectSetting.value$,
|
||||
setEffect: backgroundEffectSetting.setValue,
|
||||
added$: addedBackgrounds.added$,
|
||||
canSegment,
|
||||
pipeline: new BackgroundProcessorWrapper(transformer, "background-effect"),
|
||||
transformer,
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ export function getBasicCallViewModelEnvironment(
|
||||
vm,
|
||||
muteStates,
|
||||
mediaDevices,
|
||||
constant(false),
|
||||
constant({ supported: false, processor: undefined }),
|
||||
"reactionId",
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user