From 9f0883fe442626efd3af2843c1c9bdfd38bf786f Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Fri, 6 Nov 2026 13:30:36 +0100 Subject: [PATCH] Hand the camera track to the footer view model --- src/components/CallFooter.stories.tsx | 1 + src/components/CallFooter.tsx | 3 ++ src/components/CallFooterViewModel.test.ts | 43 ++++++++++++++++++++++ src/components/CallFooterViewModel.tsx | 20 ++++++++++ src/room/LobbyView.tsx | 3 ++ src/state/CallViewModel/CallViewModel.ts | 3 ++ 6 files changed, 73 insertions(+) diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index d7b1dcf90..8d6cc2a77 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -141,6 +141,7 @@ export const Default: Story = { backgroundImageRefusal: undefined, addBackgroundImage: fn(), removeBackgroundEffect: fn(), + cameraTrack: null, hangup: fn(), buttonSize: "lg", showFooter: true, diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index fce982347..f09109357 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -34,6 +34,7 @@ import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel"; import { LayoutSwitch } from "../room/LayoutSwitch"; import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid"; import { type UnusableReason } from "../tracks/backgroundImages"; +import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk"; export interface BackgroundImageRefusal { reason: UnusableReason | "not-kept"; @@ -92,6 +93,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" */ diff --git a/src/components/CallFooterViewModel.test.ts b/src/components/CallFooterViewModel.test.ts index a1b174462..7036db800 100644 --- a/src/components/CallFooterViewModel.test.ts +++ b/src/components/CallFooterViewModel.test.ts @@ -7,12 +7,14 @@ 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 { CallViewModel } from "../state/CallViewModel/CallViewModel"; @@ -78,6 +80,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 => {}, @@ -272,6 +275,7 @@ describe("createCallFooterViewModel", () => { mockMuteStates(), twoMicsAndOneCamMediaDevices, constant(false), + /* camera$ */ constant(null), /* openSettings */ undefined, /* hangup */ undefined, /* showLogo */ false, @@ -362,6 +366,7 @@ describe("createCallFooterViewModel", () => { mockMuteStates(), twoMicsAndOneCamMediaDevices, settling$, + /* camera$ */ constant(null), /* openSettings */ undefined, /* hangup */ undefined, /* showLogo */ false, @@ -427,6 +432,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(track); + const vm = createLobbyFooterViewModel( + testScope(), + mockMuteStates(), + twoMicsAndOneCamMediaDevices, + constant(false), + 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, + constant(false), + /* 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); diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx index 8a22aa52e..07f6dc0b1 100644 --- a/src/components/CallFooterViewModel.tsx +++ b/src/components/CallFooterViewModel.tsx @@ -11,9 +11,11 @@ import { distinctUntilChanged, map, type Observable, + of, switchMap, } from "rxjs"; import { supportsAudioOutputSelection } from "livekit-client"; +import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk"; import { supportsBackgroundProcessors, supportsModernBackgroundProcessors, @@ -88,6 +90,8 @@ function buildDeviceBehaviors( /** return empty arrays for audioOptions and videoOptions*/ disableSwitcher$: Behavior, backgroundEffectSettling$: Observable, + /** The camera as the pipeline has it, for the menu's preview. */ + camera$: Observable, ): Pick< ViewModel, | "audioOptions$" @@ -107,6 +111,7 @@ function buildDeviceBehaviors( | "addBackgroundImage$" | "backgroundImageRefusal$" | "removeBackgroundEffect$" + | "cameraTrack$" > { const options$ = ( available$: Behavior>, @@ -208,6 +213,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( @@ -269,6 +285,7 @@ export function createCallFooterViewModel( mediaDevices, disableDeviceSwitcher$, backgroundEffectSettling$, + callModel.camera$, ), // candidat to move into the FooterViewModel showFooter$: callModel.showFooter$, @@ -333,6 +350,7 @@ export function createCallFooterViewModel( * @param mediaDevices - Available and selected input devices. * @param backgroundEffectSettling$ - Whether the first effect chosen is still * being prepared. + * @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. @@ -342,6 +360,7 @@ export function createLobbyFooterViewModel( muteStates: MuteStates, mediaDevices: MediaDevices, backgroundEffectSettling$: Observable, + camera$: Behavior, openSettings: (() => void) | undefined, hangup: (() => void) | undefined, showLogo: boolean, @@ -390,6 +409,7 @@ export function createLobbyFooterViewModel( mediaDevices, constant(false), backgroundEffectSettling$, + camera$, ), }; } diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index 1a197b8d0..a72c04965 100644 --- a/src/room/LobbyView.tsx +++ b/src/room/LobbyView.tsx @@ -158,6 +158,7 @@ export const LobbyView: FC = ({ null, ); useEffect(() => { + if (mediaVm === null) return; const footerScope = new ObservableScope(); setFooterVm( createLobbyFooterViewModel( @@ -165,6 +166,7 @@ export const LobbyView: FC = ({ muteStates, devices, backgroundEffectsState$.pipe(map(({ settling }) => settling ?? false)), + mediaVm.videoTrack$, openSettings, hangup, // Logo and header are connected: only show the logo in SPA with header. @@ -179,6 +181,7 @@ export const LobbyView: FC = ({ backgroundEffectsState$, hangup, hideHeader, + mediaVm, muteStates, openSettings, ]); diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 9b1e853a3..ba83fad68 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -363,6 +363,8 @@ export interface CallViewModel { keyRotationSuppressed$: Behavior; /** The local member, once our membership has been seen in the room. */ localMember$: Behavior; + /** Our own camera track, while one is published. */ + camera$: Behavior; remoteMembers$: Behavior; /** The transports the client holds a connection to, for the developer panel. */ connectedTransports$: Behavior; @@ -1508,6 +1510,7 @@ export function createCallViewModel$( leave, toggleScreenSharing: toggleScreenSharing, sharingScreen$: sharingScreen$, + camera$, tapScreen: (): void => screenTap$.next(), tapControls: (): void => controlsTap$.next(),