diff --git a/src/components/CallFooterViewModel.test.ts b/src/components/CallFooterViewModel.test.ts index df23cba22..7495b5997 100644 --- a/src/components/CallFooterViewModel.test.ts +++ b/src/components/CallFooterViewModel.test.ts @@ -14,7 +14,10 @@ import type { CallViewModel } from "../state/CallViewModel/CallViewModel"; import type { Alignment, Layout } from "../state/layout-types"; import type { SpotlightTileViewModel } from "../state/TileViewModel"; import type { DeviceLabel } from "../state/MediaDevices"; -import { createCallFooterViewModel } from "./CallFooterViewModel"; +import { + createCallFooterViewModel, + createLobbyFooterViewModel, +} from "./CallFooterViewModel"; import { HeaderStyle } from "../UrlParams"; const platformMock = vi.hoisted(() => vi.fn(() => "desktop")); @@ -24,10 +27,12 @@ vi.mock("../Platform", () => ({ }, })); -// Prevent supportsBackgroundProcessors from throwing in jsdom – it is not -// exercised by these tests (only used in `videoToggles`, not `videoOptions`). +// The SDK's own check needs WebGL and canvas APIs jsdom does not have. The +// tests below drive it directly, because what they are about is the answer the +// app gives on top of it. +const sdkSupportMock = vi.hoisted(() => vi.fn(() => true)); vi.mock("@livekit/track-processors", () => ({ - supportsBackgroundProcessors: (): boolean => false, + supportsBackgroundProcessors: (): boolean => sdkSupportMock(), })); const outputSelectionMock = vi.hoisted(() => vi.fn(() => true)); @@ -228,4 +233,70 @@ describe("createCallFooterViewModel", () => { ]); }); }); + describe("background effects", () => { + // The lobby, deliberately: in a call the whole switcher is already + // withheld on a phone, so a check there passes whatever the verdict says. + // The lobby keeps its switcher, which is where a phone browser was offered + // every effect, given none of them, and told nothing. + function lobbyFor( + platform: string, + ): ReturnType { + platformMock.mockReturnValue(platform); + return createLobbyFooterViewModel( + testScope(), + mockMuteStates(), + twoMicsAndOneCamMediaDevices, + /* openSettings */ undefined, + /* hangup */ undefined, + /* showLogo */ false, + ); + } + + it("offers nothing the pipeline would refuse to honour", () => { + sdkSupportMock.mockReturnValue(true); + const vm = lobbyFor("ios"); + + expect(vm.selectBackgroundEffect$.value).toBeUndefined(); + expect(vm.toggleBlur$.value).toBeUndefined(); + }); + + it("offers them where the pipeline will honour them", () => { + sdkSupportMock.mockReturnValue(true); + const vm = lobbyFor("desktop"); + + expect(vm.selectBackgroundEffect$.value).toBeDefined(); + expect(vm.toggleBlur$.value).toBeDefined(); + }); + + it("offers nothing where the browser itself cannot run them", () => { + sdkSupportMock.mockReturnValue(false); + const vm = lobbyFor("desktop"); + + expect(vm.selectBackgroundEffect$.value).toBeUndefined(); + expect(vm.toggleBlur$.value).toBeUndefined(); + }); + + it("availability is the same before and during a call", () => { + for (const supported of [true, false]) { + sdkSupportMock.mockReturnValue(supported); + platformMock.mockReturnValue("desktop"); + const inCall = createCallFooterViewModel( + testScope(), + buildMinimalCallViewModel(gridLayout), + mockMuteStates(), + twoMicsAndOneCamMediaDevices, + /* reactionIdentifier */ undefined, + { showControls: true, header: HeaderStyle.Standard }, + ); + const lobby = lobbyFor("desktop"); + + const offeredInCall = + inCall.selectBackgroundEffect$.value !== undefined; + const offeredInLobby = + lobby.selectBackgroundEffect$.value !== undefined; + expect(offeredInLobby).toBe(offeredInCall); + expect(offeredInLobby).toBe(supported); + } + }); + }); }); diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx index 467a6cb0c..a7d9c6ed3 100644 --- a/src/components/CallFooterViewModel.tsx +++ b/src/components/CallFooterViewModel.tsx @@ -6,9 +6,10 @@ Please see LICENSE in the repository root for full details. */ import { combineLatest, map, type Observable, switchMap } from "rxjs"; -import { supportsBackgroundProcessors } from "@livekit/track-processors"; import { supportsAudioOutputSelection } from "livekit-client"; +import { supportsBackgroundProcessors } from "../livekit/backgroundProcessing"; + import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; import { type MenuOptions } from "./MediaMuteAndSwitchButton"; import { type MediaDevices } from "../state/MediaDevices"; diff --git a/src/livekit/TrackProcessorContext.tsx b/src/livekit/TrackProcessorContext.tsx index 8d6ca9d1e..92a9ea2b1 100644 --- a/src/livekit/TrackProcessorContext.tsx +++ b/src/livekit/TrackProcessorContext.tsx @@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details. import { BackgroundProcessorWrapper, type ProcessorWrapper, - supportsBackgroundProcessors as supportsBackgroundProcessorsLivekitSdk, type BackgroundOptions, type SwitchBackgroundProcessorOptions, } from "@livekit/track-processors"; @@ -40,9 +39,9 @@ import { type BackgroundEffect, } from "./backgroundEffects"; import { BackgroundImageStore } from "./backgroundImages"; +import { supportsBackgroundProcessors } from "./backgroundProcessing"; import { type Behavior } from "../state/Behavior"; import { type ObservableScope } from "../state/ObservableScope"; -import { platform } from "../Platform"; //TODO-MULTI-SFU: This is not yet fully there. // it is a combination of exposing observable and react hooks. @@ -175,10 +174,6 @@ interface Props { children: JSX.Element; } -function supportsBackgroundProcessors(): boolean { - return supportsBackgroundProcessorsLivekitSdk() && platform === "desktop"; -} - /** Translates a chosen effect into the pipeline's own vocabulary. */ function switchOptionsFor( effect: BackgroundEffect, diff --git a/src/livekit/backgroundProcessing.ts b/src/livekit/backgroundProcessing.ts new file mode 100644 index 000000000..460f81a0c --- /dev/null +++ b/src/livekit/backgroundProcessing.ts @@ -0,0 +1,27 @@ +/* +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 { supportsBackgroundProcessors as supportsBackgroundProcessorsLivekitSdk } from "@livekit/track-processors"; + +import { platform } from "../Platform"; + +/** + * Whether this device can run background effects at all. + * + * The one answer, for everything that asks. The controls and the pipeline used + * to work it out separately, and differently: the pipeline required a desktop + * while the controls asked only whether the browser had the APIs. A phone + * browser has them, so it was offered a choice the pipeline then refused to + * honour, and the video simply never changed — the worst of both, since it + * neither worked nor said why. + * + * Anything that offers a background effect must ask this, so that what is + * offered and what can be delivered cannot drift apart again. + */ +export function supportsBackgroundProcessors(): boolean { + return supportsBackgroundProcessorsLivekitSdk() && platform === "desktop"; +}