mirror of
https://github.com/vector-im/element-call.git
synced 2026-11-07 00:52:28 +00:00
Compare commits
7
Commits
johannes/intent
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef86fc06e | ||
|
|
cd5c367ce9 | ||
|
|
190902eec0 | ||
|
|
88f748ec21 | ||
|
|
9639effd28 | ||
|
|
f2f67019b4 | ||
|
|
51fac690f4 |
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -70,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();
|
||||
|
||||
@@ -1692,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,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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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