Create the background effects with the app's view model

- AppViewModel creates them, next to its media devices, as review asked;
  BackgroundEffectsProvider takes them as a prop rather than building
  them in an effect.
- The component build, which has no AppViewModel of its own, now makes one
  in the effect that made its media devices, so both builds get their
  state the same way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-10-08 14:34:45 +02:00
co-authored by Claude Opus 5.5
parent de2b98251c
commit 5ca9cedf30
7 changed files with 62 additions and 54 deletions
+9 -10
View File
@@ -72,7 +72,7 @@ import {
useUrlParams, useUrlParams,
} from "../src/UrlParams"; } from "../src/UrlParams";
import { MediaDevicesContext } from "../src/MediaDevicesContext"; import { MediaDevicesContext } from "../src/MediaDevicesContext";
import { MediaDevices } from "../src/state/MediaDevices"; import { AppViewModel } from "../src/state/AppViewModel";
import { ObservableScope } from "../src/state/ObservableScope"; import { ObservableScope } from "../src/state/ObservableScope";
import { BackgroundEffectsProvider } from "../src/livekit/BackgroundEffectsContext"; import { BackgroundEffectsProvider } from "../src/livekit/BackgroundEffectsContext";
import { Config } from "../src/config/Config"; import { Config } from "../src/config/Config";
@@ -192,14 +192,12 @@ export const ElementCall: FC<ElementCallProps> = ({
// observers running for the rest of the page's life. Null until then, which // observers running for the rest of the page's life. Null until then, which
// is one render. // is one render.
const { controlledAudioDevices, callIntent } = params; const { controlledAudioDevices, callIntent } = params;
const [mediaDevices, setMediaDevices] = useState<MediaDevices | null>(null); const [vm, setVm] = useState<AppViewModel | null>(null);
useEffect(() => { useEffect(() => {
const scope = new ObservableScope(); const scope = new ObservableScope();
setMediaDevices( setVm(new AppViewModel(scope, { controlledAudioDevices, callIntent }));
new MediaDevices(scope, { controlledAudioDevices, callIntent }),
);
return (): void => { return (): void => {
setMediaDevices(null); setVm(null);
scope.end(); scope.end();
}; };
}, [controlledAudioDevices, callIntent]); }, [controlledAudioDevices, callIntent]);
@@ -220,8 +218,7 @@ export const ElementCall: FC<ElementCallProps> = ({
// is told that Element Call has loaded, as the widget tells its client once // is told that Element Call has loaded, as the widget tells its client once
// its own initialisation is over. Once per mount, however often the pieces // its own initialisation is over. Once per mount, however often the pieces
// are later swapped out. // are later swapped out.
const ready = const ready = container !== null && rtcSession !== null && vm !== null;
container !== null && rtcSession !== null && mediaDevices !== null;
const announcedLoaded = useRef(false); const announcedLoaded = useRef(false);
useEffect(() => { useEffect(() => {
if (!ready || announcedLoaded.current) return; if (!ready || announcedLoaded.current) return;
@@ -251,8 +248,10 @@ export const ElementCall: FC<ElementCallProps> = ({
<Decoration> <Decoration>
<TooltipProvider> <TooltipProvider>
<ClientProvider client={client}> <ClientProvider client={client}>
<MediaDevicesContext value={mediaDevices}> <MediaDevicesContext value={vm.mediaDevices}>
<BackgroundEffectsProvider> <BackgroundEffectsProvider
effects={vm.backgroundEffects}
>
<CallView <CallView
client={client} client={client}
rtcSession={rtcSession} rtcSession={rtcSession}
+1 -1
View File
@@ -148,7 +148,7 @@ export const App: FC<Props> = ({ vm, widget }) => {
loaded && clientReady ? ( loaded && clientReady ? (
<ClientProvider client={widgetClient}> <ClientProvider client={widgetClient}>
<MediaDevicesContext value={vm.mediaDevices}> <MediaDevicesContext value={vm.mediaDevices}>
<BackgroundEffectsProvider> <BackgroundEffectsProvider effects={vm.backgroundEffects}>
<Sentry.ErrorBoundary <Sentry.ErrorBoundary
fallback={(error) => <ErrorPage error={error} />} fallback={(error) => <ErrorPage error={error} />}
> >
+9 -5
View File
@@ -21,7 +21,9 @@ import {
syncBackgroundEffects, syncBackgroundEffects,
useBackgroundEffects, useBackgroundEffects,
useSyncBackgroundEffects, useSyncBackgroundEffects,
createAppBackgroundEffects,
} from "./BackgroundEffectsContext"; } from "./BackgroundEffectsContext";
import { type BackgroundEffects } from "../state/BackgroundEffects";
import { backgroundBlur } from "../settings/settings"; import { backgroundBlur } from "../settings/settings";
import { constant } from "../state/Behavior"; import { constant } from "../state/Behavior";
import { flushPromises, testScope } from "../utils/test"; import { flushPromises, testScope } from "../utils/test";
@@ -150,16 +152,17 @@ describe("BackgroundEffectsProvider", () => {
}; };
// One component for every render, so a rerender updates the tree rather than // One component for every render, so a rerender updates the tree rather than
// mounting a second provider with a pipeline of its own. // mounting a second provider with a pipeline of its own.
let effects: BackgroundEffects | undefined;
const Surfaces: FC<{ tracks: (LocalVideoTrack | null)[] }> = ({ tracks }) => const Surfaces: FC<{ tracks: (LocalVideoTrack | null)[] }> = ({ tracks }) =>
createElement( createElement(BackgroundEffectsProvider, {
BackgroundEffectsProvider, // Made on first render, once a test has set up the platform.
null, effects: (effects ??= createAppBackgroundEffects(testScope())),
createElement( children: createElement(
"div", "div",
null, null,
...tracks.map((track, key) => createElement(Surface, { key, track })), ...tracks.map((track, key) => createElement(Surface, { key, track })),
), ),
); });
const surfaces = ( const surfaces = (
...tracks: (LocalVideoTrack | null)[] ...tracks: (LocalVideoTrack | null)[]
): ReturnType<typeof createElement> => createElement(Surfaces, { tracks }); ): ReturnType<typeof createElement> => createElement(Surfaces, { tracks });
@@ -173,6 +176,7 @@ describe("BackgroundEffectsProvider", () => {
beforeEach(() => { beforeEach(() => {
seen = []; seen = [];
effects = undefined;
pipelines.built = 0; pipelines.built = 0;
pipelines.destroyed = 0; pipelines.destroyed = 0;
pipelines.switches = []; pipelines.switches = [];
+15 -26
View File
@@ -10,14 +10,7 @@ import {
type ProcessorWrapper, type ProcessorWrapper,
type BackgroundOptions, type BackgroundOptions,
} from "@livekit/track-processors"; } from "@livekit/track-processors";
import { import { createContext, type FC, type JSX, use, useEffect } from "react";
createContext,
type FC,
type JSX,
use,
useEffect,
useState,
} from "react";
import { type LocalVideoTrack } from "livekit-client"; import { type LocalVideoTrack } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { combineLatest } from "rxjs"; import { combineLatest } from "rxjs";
@@ -27,7 +20,7 @@ import { BackgroundEffectTransformer } from "./BackgroundEffectTransformer";
import { OneStepPipeline } from "./OneStepPipeline"; import { OneStepPipeline } from "./OneStepPipeline";
import { supportsBackgroundProcessors } from "./backgroundProcessing"; import { supportsBackgroundProcessors } from "./backgroundProcessing";
import { type Behavior } from "../state/Behavior"; import { type Behavior } from "../state/Behavior";
import { ObservableScope } from "../state/ObservableScope"; import { type ObservableScope } from "../state/ObservableScope";
import { import {
type BackgroundEffects, type BackgroundEffects,
createBackgroundEffects, createBackgroundEffects,
@@ -118,31 +111,27 @@ export const useSyncBackgroundEffects = (
}, [processor, videoTrack]); }, [processor, videoTrack]);
}; };
interface Props { /** The app's one pipeline, shared by every camera track it opens. */
children: JSX.Element; export function createAppBackgroundEffects(
} scope: ObservableScope,
): BackgroundEffects {
export const BackgroundEffectsProvider: FC<Props> = ({ children }) => { return createBackgroundEffects(scope, {
const [effects, setEffects] = useState<BackgroundEffects | null>(null);
useEffect(() => {
const scope = new ObservableScope();
setEffects(
createBackgroundEffects(scope, {
supported: supportsBackgroundProcessors(), supported: supportsBackgroundProcessors(),
blur$: backgroundBlurSettings.value$, blur$: backgroundBlurSettings.value$,
pipeline: new OneStepPipeline( pipeline: new OneStepPipeline(
new BackgroundEffectTransformer({ backgroundDisabled: true }), new BackgroundEffectTransformer({ backgroundDisabled: true }),
"background-effect", "background-effect",
), ),
}), });
); }
return (): void => scope.end();
}, []);
if (effects === null) return null; interface Props {
return ( effects: BackgroundEffects;
children: JSX.Element;
}
export const BackgroundEffectsProvider: FC<Props> = ({ effects, children }) => (
<BackgroundEffectsContext value={effects}> <BackgroundEffectsContext value={effects}>
{children} {children}
</BackgroundEffectsContext> </BackgroundEffectsContext>
); );
};
+8 -2
View File
@@ -50,6 +50,7 @@ import {
mockMediaDevices, mockMediaDevices,
mockRtcMembership, mockRtcMembership,
MockRTCSession, MockRTCSession,
testScope,
} from "../utils/test"; } from "../utils/test";
import { CallView } from "./CallView"; import { CallView } from "./CallView";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary"; import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
@@ -61,7 +62,10 @@ import {
} from "../HostBridge"; } from "../HostBridge";
import { type JoinCallData } from "../widget"; import { type JoinCallData } from "../widget";
import { MatrixRTCTransportMissingError } from "../utils/errors"; import { MatrixRTCTransportMissingError } from "../utils/errors";
import { BackgroundEffectsProvider } from "../livekit/BackgroundEffectsContext"; import {
BackgroundEffectsProvider,
createAppBackgroundEffects,
} from "../livekit/BackgroundEffectsContext";
import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevicesContext } from "../MediaDevicesContext";
import { constant } from "../state/Behavior"; import { constant } from "../state/Behavior";
@@ -193,7 +197,9 @@ function createCallView(
<HostBridgeProvider value={hostBridge}> <HostBridgeProvider value={hostBridge}>
<TooltipProvider> <TooltipProvider>
<MediaDevicesContext value={mockMediaDevices({})}> <MediaDevicesContext value={mockMediaDevices({})}>
<BackgroundEffectsProvider> <BackgroundEffectsProvider
effects={createAppBackgroundEffects(testScope())}
>
{options.withErrorBoundary ? ( {options.withErrorBoundary ? (
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}> <GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
{callView} {callView}
+11 -3
View File
@@ -31,6 +31,7 @@ import {
mockRemoteParticipant, mockRemoteParticipant,
mockRtcMembership, mockRtcMembership,
type MockRTCSession, type MockRTCSession,
testScope,
} from "../utils/test"; } from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { import {
@@ -49,7 +50,10 @@ import { MediaDevicesContext } from "../MediaDevicesContext";
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices"; import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
import { AppBar } from "../AppBar"; import { AppBar } from "../AppBar";
import { type MatrixInfo } from "./VideoPreview"; import { type MatrixInfo } from "./VideoPreview";
import { BackgroundEffectsProvider } from "../livekit/BackgroundEffectsContext"; import {
BackgroundEffectsProvider,
createAppBackgroundEffects,
} from "../livekit/BackgroundEffectsContext";
import { initializeWidget } from "../widget"; import { initializeWidget } from "../widget";
import { RootElementProvider } from "../RootElementContext"; import { RootElementProvider } from "../RootElementContext";
@@ -242,7 +246,9 @@ describe("ActiveCall", () => {
const { findByTestId } = render( const { findByTestId } = render(
<BrowserRouter> <BrowserRouter>
<MediaDevicesContext value={mediaDevices}> <MediaDevicesContext value={mediaDevices}>
<BackgroundEffectsProvider> <BackgroundEffectsProvider
effects={createAppBackgroundEffects(testScope())}
>
<TooltipProvider> <TooltipProvider>
<RoomContext value={mockLivekitRoom({ localParticipant })}> <RoomContext value={mockLivekitRoom({ localParticipant })}>
<ActiveCall <ActiveCall
@@ -292,7 +298,9 @@ describe("ActiveCall", () => {
<BrowserRouter> <BrowserRouter>
<RootElementProvider value={root}> <RootElementProvider value={root}>
<MediaDevicesContext value={mediaDevices}> <MediaDevicesContext value={mediaDevices}>
<BackgroundEffectsProvider> <BackgroundEffectsProvider
effects={createAppBackgroundEffects(testScope())}
>
<TooltipProvider> <TooltipProvider>
<RoomContext value={mockLivekitRoom({ localParticipant })}> <RoomContext value={mockLivekitRoom({ localParticipant })}>
<ActiveCall <ActiveCall
+2
View File
@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { type AudioOutputOptions, MediaDevices } from "./MediaDevices"; import { type AudioOutputOptions, MediaDevices } from "./MediaDevices";
import { createAppBackgroundEffects } from "../livekit/BackgroundEffectsContext";
import { type ObservableScope } from "./ObservableScope"; import { type ObservableScope } from "./ObservableScope";
/** /**
@@ -16,6 +17,7 @@ export class AppViewModel {
this.scope, this.scope,
this.audioOutputOptions, this.audioOutputOptions,
); );
public readonly backgroundEffects = createAppBackgroundEffects(this.scope);
// TODO: Move more application logic here. The CallViewModel, at the very // TODO: Move more application logic here. The CallViewModel, at the very
// least, ought to be accessible from this object. // least, ought to be accessible from this object.