From e5d5f139234673d867f33855378c2cda67ac4816 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 13 Sep 2026 01:22:20 -0400 Subject: [PATCH] fix(lotus): focus_participant works in grid/1:1, clears on leave, keeps PiP, pins by device - A non-null pin forces layout "spotlight" and remembers the displaced mode; clearing restores it only if the user hasn't switched since; gridLayoutMedia$ surfaces the pinned item for narrow mode (#3). - Pin clears when the user is gone for 5 s or on leave$ (#16). - Screenshare branch keeps pip$ = auto speaker unless it IS the pinned user (#29). - Payload accepts an optional media id (userId:deviceId) and prefers it; userId-only picks the speaking device (#30). 18 unit tests. Fixes #3 Fixes #16 Fixes #29 Fixes #30 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/lotus/lotusFocus.ts | 37 ++- src/lotus/lotusSpotlight.test.ts | 295 +++++++++++++++++++++++ src/lotus/lotusSpotlight.ts | 219 ++++++++++++++--- src/state/CallViewModel/CallViewModel.ts | 54 +++-- 4 files changed, 551 insertions(+), 54 deletions(-) create mode 100644 src/lotus/lotusSpotlight.test.ts diff --git a/src/lotus/lotusFocus.ts b/src/lotus/lotusFocus.ts index 163085fc..43754805 100644 --- a/src/lotus/lotusFocus.ts +++ b/src/lotus/lotusFocus.ts @@ -10,12 +10,33 @@ import { type IWidgetApiRequest } from "matrix-widget-api"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; import { widget } from "../widget"; import { LotusWidgetActions } from "./lotusActions"; +import { type ManualSpotlight } from "./lotusSpotlight"; + +/** + * Parse a `focus_participant` payload into a pin, `null` to clear, or + * `undefined` to leave the current pin alone (#30). Exported for tests. + * + * Mirror deafen's partial-payload semantics: a payload that OMITS `userId` + * must keep the current spotlight, not clear it. Only act when the key is + * actually present — an explicit `null` clears, a string pins that user. An + * optional `id` (EC media id `${userId}:${deviceId}`, as sent to the host in + * `io.lotus.call_state`) selects a specific device of that user. + */ +export function parseFocusPayload( + data: unknown, +): ManualSpotlight | null | undefined { + if (typeof data !== "object" || data === null || !("userId" in data)) + return undefined; + const { userId, id } = data as { userId?: unknown; id?: unknown }; + if (typeof userId !== "string") return null; + return { userId, id: typeof id === "string" ? id : null }; +} /** * Handle the host's `io.lotus.focus_participant` toWidget action (#4): pin a - * participant to the spotlight by Matrix user id, or clear it with - * `{ userId: null }`. This replaces cinny's old DOM `.click()` tile-selector - * hack with a real, layout-aware spotlight override. + * participant to the spotlight by Matrix user id (and optionally media id), or + * clear it with `{ userId: null }`. This replaces cinny's old DOM `.click()` + * tile-selector hack with a real, layout-aware spotlight override. * * No effect unless the host actually sends the action, so registering the * handler whenever we're a widget is safe. Returns a teardown function. @@ -27,14 +48,8 @@ export function startLotusFocus(vm: CallViewModel): () => void { const handler = (ev: CustomEvent): void => { // Always reply so the host transport doesn't time out. w.api.transport.reply(ev.detail, {}); - const data = ev.detail.data as { userId?: unknown } | undefined; - // Mirror deafen's partial-payload semantics: a payload that OMITS `userId` - // must keep the current spotlight, not clear it. Only act when the key is - // actually present — an explicit `null` clears, a string pins that user. - if (data && "userId" in data) { - const userId = typeof data.userId === "string" ? data.userId : null; - vm.setManualSpotlight(userId); - } + const target = parseFocusPayload(ev.detail.data); + if (target !== undefined) vm.setManualSpotlight(target); }; w.lazyActions.on(LotusWidgetActions.FocusParticipant, handler); diff --git a/src/lotus/lotusSpotlight.test.ts b/src/lotus/lotusSpotlight.test.ts new file mode 100644 index 00000000..0f9897aa --- /dev/null +++ b/src/lotus/lotusSpotlight.test.ts @@ -0,0 +1,295 @@ +/* +Copyright 2026 Lotus Guild + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, test, vi } from "vitest"; +import { BehaviorSubject, of, Subject } from "rxjs"; + +import { withTestScheduler } from "../utils/test"; +import { type UserMediaViewModel } from "../state/media/UserMediaViewModel"; +import { type ScreenShareViewModel } from "../state/media/ScreenShareViewModel"; +import { type LayoutMode } from "../state/LayoutSwitchViewModel"; +import { + createManualSpotlight, + type ManualSpotlight, + overrideSpotlight$, + pinnedMedia$, + resolveManualSpotlight, +} from "./lotusSpotlight"; +import { parseFocusPayload } from "./lotusFocus"; + +interface FakeMedia { + id: string; + userId: string; + type: "user"; + local: boolean; + speaking$: BehaviorSubject; +} + +function media( + userId: string, + deviceId: string, + speaking = false, + local = false, +): FakeMedia { + return { + id: `${userId}:${deviceId}`, + userId, + type: "user", + local, + speaking$: new BehaviorSubject(speaking), + }; +} +const asUser = (m: FakeMedia): UserMediaViewModel => + m as unknown as UserMediaViewModel; +const screen = { id: "@a:x:d1:screen", userId: "@a:x", type: "screen share" }; + +const aliceDesk = media("@alice:x", "desk"); +const alicePhone = media("@alice:x", "phone", true); +const bob = media("@bob:x", "d"); +const all = [aliceDesk, alicePhone, bob]; + +describe("resolveManualSpotlight (#30)", () => { + const speaking = (m: FakeMedia): boolean => m.speaking$.value; + + test("null pin resolves to nothing", () => { + expect(resolveManualSpotlight(null, all, speaking)).toBeUndefined(); + }); + + test("prefers the media id when given", () => { + expect( + resolveManualSpotlight( + { userId: "@alice:x", id: "@alice:x:desk" }, + all, + speaking, + ), + ).toBe(aliceDesk); + }); + + test("falls back to the userId when the id is not present", () => { + expect( + resolveManualSpotlight( + { userId: "@alice:x", id: "@alice:x:tablet" }, + all, + speaking, + ), + ).toBe(alicePhone); + }); + + test("userId only: prefers the speaking device, else the first", () => { + expect( + resolveManualSpotlight({ userId: "@alice:x", id: null }, all, speaking), + ).toBe(alicePhone); + expect( + resolveManualSpotlight( + { userId: "@alice:x", id: null }, + all, + () => false, + ), + ).toBe(aliceDesk); + }); + + test("absent user resolves to nothing", () => { + expect( + resolveManualSpotlight({ userId: "@carol:x", id: null }, all, speaking), + ).toBeUndefined(); + }); +}); + +describe("pinnedMedia$", () => { + test("follows the speaking device live for a userId-only pin", () => { + withTestScheduler(({ expectObservable, schedule }) => { + const desk = media("@alice:x", "desk", true); + const phone = media("@alice:x", "phone", false); + const pin$ = new BehaviorSubject({ + userId: "@alice:x", + id: null, + }); + schedule("-a", { + a: () => { + desk.speaking$.next(false); + phone.speaking$.next(true); + }, + }); + expectObservable( + pinnedMedia$(pin$, of([asUser(desk), asUser(phone)])), + ).toBe("dp", { d: desk, p: phone }); + }); + }); +}); + +describe("overrideSpotlight$", () => { + test("no pin: identical to upstream", () => { + withTestScheduler(({ expectObservable }) => { + const result$ = overrideSpotlight$( + of(asUser(bob)), + of(null), + of([]), + of(undefined), + of(all.map(asUser)), + ); + expectObservable(result$.pipe()).toBe("(a|)", { + a: expect.objectContaining({ spotlight: [bob] }), + }); + }); + }); + + test("pin replaces the auto speaker without a screenshare", () => { + withTestScheduler(({ expectObservable }) => { + const result$ = overrideSpotlight$( + of(asUser(bob)), + of({ userId: "@alice:x", id: "@alice:x:desk" }), + of([]), + of(undefined), + of(all.map(asUser)), + ); + expectObservable(result$).toBe("(a|)", { + a: expect.objectContaining({ spotlight: [aliceDesk] }), + }); + }); + }); + + test("#29: PiP keeps the auto speaker when a third party is pinned", () => { + withTestScheduler(({ expectObservable }) => { + let pip$; + overrideSpotlight$( + of(asUser(bob)), + of({ userId: "@alice:x", id: null }), + of([screen as unknown as ScreenShareViewModel]), + of(undefined), + of(all.map(asUser)), + ).subscribe((r) => { + expect(r.spotlight).toEqual([screen, alicePhone]); + pip$ = r.pip$; + }); + expectObservable(pip$!).toBe("(b|)", { b: bob }); + }); + }); + + test("#29: PiP is blanked only when the auto speaker is the pinned one", () => { + withTestScheduler(({ expectObservable }) => { + let pip$; + overrideSpotlight$( + of(asUser(alicePhone)), + of({ userId: "@alice:x", id: null }), + of([screen as unknown as ScreenShareViewModel]), + of(undefined), + of(all.map(asUser)), + ).subscribe((r) => { + pip$ = r.pip$; + }); + expectObservable(pip$!).toBe("(u|)", { u: undefined }); + }); + }); +}); + +describe("createManualSpotlight (#3 / #16)", () => { + function setup(initialLayout: LayoutMode = "grid") { + const pin$ = new BehaviorSubject(null); + const layout$ = new BehaviorSubject(initialLayout); + const setLayout = vi.fn((m: LayoutMode) => layout$.next(m)); + const userMedia$ = new BehaviorSubject<{ userId: string }[]>([ + { userId: "@alice:x" }, + { userId: "@bob:x" }, + ]); + const leave$ = new Subject(); + const ctl = createManualSpotlight( + pin$, + userMedia$, + leave$, + { layout$, setLayout }, + 5000, + ); + const sub = ctl.effects$.subscribe(); + return { pin$, layout$, setLayout, userMedia$, leave$, ctl, sub }; + } + + test("pinning forces spotlight and clearing restores the displaced mode", () => { + const { ctl, layout$, setLayout, pin$ } = setup("grid"); + ctl.setManualSpotlight({ userId: "@alice:x", id: null }); + expect(pin$.value).toEqual({ userId: "@alice:x", id: null }); + expect(setLayout).toHaveBeenLastCalledWith("spotlight"); + // Re-pinning someone else does not touch the layout again. + ctl.setManualSpotlight({ userId: "@bob:x", id: null }); + expect(setLayout).toHaveBeenCalledTimes(1); + ctl.setManualSpotlight(null); + expect(layout$.value).toBe("grid"); + expect(setLayout).toHaveBeenCalledTimes(2); + }); + + test("already in spotlight: nothing is forced or restored", () => { + const { ctl, setLayout } = setup("spotlight"); + ctl.setManualSpotlight({ userId: "@alice:x", id: null }); + ctl.setManualSpotlight(null); + expect(setLayout).not.toHaveBeenCalled(); + }); + + test("does not clobber a layout the user chose while pinned", () => { + const { ctl, layout$, setLayout } = setup("grid"); + ctl.setManualSpotlight({ userId: "@alice:x", id: null }); + layout$.next("grid"); // user switched back manually + ctl.setManualSpotlight(null); + expect(setLayout).toHaveBeenCalledTimes(1); + }); + + test("#16: pin is cleared when the user is gone for 5 s, not on a blip", () => { + vi.useFakeTimers(); + try { + const { ctl, pin$, userMedia$, layout$ } = setup("grid"); + ctl.setManualSpotlight({ userId: "@alice:x", id: null }); + // Transient absence: back within the window. + userMedia$.next([{ userId: "@bob:x" }]); + vi.advanceTimersByTime(2000); + userMedia$.next([{ userId: "@alice:x" }, { userId: "@bob:x" }]); + vi.advanceTimersByTime(6000); + expect(pin$.value).not.toBeNull(); + // Real leave. + userMedia$.next([{ userId: "@bob:x" }]); + vi.advanceTimersByTime(4999); + expect(pin$.value).not.toBeNull(); + vi.advanceTimersByTime(1); + expect(pin$.value).toBeNull(); + expect(layout$.value).toBe("grid"); + } finally { + vi.useRealTimers(); + } + }); + + test("#16: pin is cleared on leave$", () => { + const { ctl, pin$, leave$, layout$ } = setup("grid"); + ctl.setManualSpotlight({ userId: "@alice:x", id: null }); + expect(layout$.value).toBe("spotlight"); + leave$.next(); + expect(pin$.value).toBeNull(); + expect(layout$.value).toBe("grid"); + }); +}); + +describe("parseFocusPayload (#30)", () => { + test("omitted userId leaves the pin alone", () => { + expect(parseFocusPayload(undefined)).toBeUndefined(); + expect(parseFocusPayload({})).toBeUndefined(); + expect(parseFocusPayload({ id: "@a:x:d" })).toBeUndefined(); + }); + test("null / non-string userId clears", () => { + expect(parseFocusPayload({ userId: null })).toBeNull(); + expect(parseFocusPayload({ userId: 42 })).toBeNull(); + }); + test("userId with and without id", () => { + expect(parseFocusPayload({ userId: "@a:x" })).toEqual({ + userId: "@a:x", + id: null, + }); + expect(parseFocusPayload({ userId: "@a:x", id: "@a:x:d" })).toEqual({ + userId: "@a:x", + id: "@a:x:d", + }); + expect(parseFocusPayload({ userId: "@a:x", id: 7 })).toEqual({ + userId: "@a:x", + id: null, + }); + }); +}); diff --git a/src/lotus/lotusSpotlight.ts b/src/lotus/lotusSpotlight.ts index 560d3185..7827df6f 100644 --- a/src/lotus/lotusSpotlight.ts +++ b/src/lotus/lotusSpotlight.ts @@ -5,18 +5,103 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { combineLatest, map, type Observable, of, switchMap } from "rxjs"; +import { + type BehaviorSubject, + combineLatest, + debounceTime, + distinctUntilChanged, + filter, + ignoreElements, + map, + merge, + type Observable, + of, + switchMap, + tap, +} from "rxjs"; import { type UserMediaViewModel } from "../state/media/UserMediaViewModel"; import { type ScreenShareViewModel } from "../state/media/ScreenShareViewModel"; import { type MediaViewModel } from "../state/media/MediaViewModel"; import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel"; +import { type LayoutMode } from "../state/LayoutSwitchViewModel"; +import { type Behavior } from "../state/Behavior"; interface SpotlightAndPip { spotlight: MediaViewModel[]; pip$: Observable; } +/** + * [lotus #4/#30] A host-requested spotlight pin. `id` is the EC media id + * (`${userId}:${deviceId}`, the same value `io.lotus.call_state` sends the + * host) and is preferred when present; `userId` alone resolves to the + * currently-speaking device of that user, else their first device. + */ +export interface ManualSpotlight { + userId: string; + id: string | null; +} + +/** + * How long (ms) a pinned participant may be absent from `userMedia$` before + * the pin is dropped (#16). Long enough to ride out a transient LiveKit + * reconnect, short enough that a real leave restores speaker-follows promptly. + */ +export const MANUAL_SPOTLIGHT_GONE_MS = 5000; + +/** + * Resolve the pin against the current media list (#30). Pure; the observable + * wrapper below feeds it the live `speaking` state. + */ +export function resolveManualSpotlight< + M extends { id: string; userId: string }, +>( + target: ManualSpotlight | null, + mediaItems: M[], + speaking: (m: M) => boolean, +): M | undefined { + if (target === null) return undefined; + if (target.id !== null) { + const byId = mediaItems.find((m) => m.id === target.id); + if (byId) return byId; + // The pinned device is gone but the user may still be here on another + // one — fall through to the userId match rather than dropping the pin. + } + const devices = mediaItems.filter((m) => m.userId === target.userId); + if (devices.length <= 1) return devices[0]; + return devices.find(speaking) ?? devices[0]; +} + +/** + * The pinned media item, or `undefined` when there is no pin or the pinned + * participant is not (currently) in the call. + */ +export function pinnedMedia$( + manualSpotlight$: Observable, + userMedia$: Observable, +): Observable { + return combineLatest([manualSpotlight$, userMedia$]).pipe( + switchMap(([target, mediaItems]) => { + if (target === null) return of(undefined); + const devices = mediaItems.filter((m) => m.userId === target.userId); + // Only subscribe to speaking$ when there is actually a choice to make. + const speaking$: Observable> = + devices.length > 1 && !devices.some((m) => m.id === target.id) + ? combineLatest(devices.map((m) => m.speaking$)).pipe( + map((flags) => new Set(devices.filter((_, i) => flags[i]))), + ) + : of(new Set()); + return speaking$.pipe( + map((speaking) => + resolveManualSpotlight(target, mediaItems, (m) => speaking.has(m)), + ), + ); + }), + distinctUntilChanged(), + ); +} + /** * [lotus #4] Manual spotlight override. * @@ -27,41 +112,35 @@ interface SpotlightAndPip { * `spotlightSpeaker$` auto-selection unchanged and just routes the * screenshare/spotlight computation through this wrapper at one call point. * - * Behaviour is IDENTICAL to upstream whenever `manualSpotlightUserId$` stays + * Behaviour is IDENTICAL to upstream whenever `manualSpotlight$` stays * `null` (the default). When it names a participant that is still present: * - with no screenshare, that participant is spotlighted instead of the * auto-selected active speaker; * - during a screenshare, that participant's camera is surfaced in the * spotlight ALONGSIDE the shared screen (#4 / A5 "focus camera during - * screenshare"), and the redundant PiP is hidden. + * screenshare"); the PiP keeps showing the auto-selected speaker and is + * only blanked when that speaker IS the pinned participant (#29). * * @param autoSpotlightSpeaker$ upstream's speaker-follows auto selection - * @param manualSpotlightUserId$ host-pinned userId, or `null` for auto (default) + * @param manualSpotlight$ host pin, or `null` for auto (default) * @param screenShares$ current screen-share view models * @param localUserMediaForPip$ local media suitable for the PiP - * @param userMedia$ all user media in the call (to resolve the pinned userId) + * @param userMedia$ all user media in the call (to resolve the pin) */ export function overrideSpotlight$( autoSpotlightSpeaker$: Observable, - manualSpotlightUserId$: Observable, + manualSpotlight$: Observable, screenShares$: Observable, localUserMediaForPip$: Observable, userMedia$: Observable, ): Observable { + const pinned$ = pinnedMedia$(manualSpotlight$, userMedia$); + // The effective spotlight speaker: the host-pinned participant when set and // still present, otherwise upstream's auto-selected speaker. - const spotlightSpeaker$ = combineLatest([ - autoSpotlightSpeaker$, - manualSpotlightUserId$, - userMedia$, - ]).pipe( - map(([auto, manualUserId, mediaItems]) => { - if (manualUserId !== null) { - const pinned = mediaItems.find((m) => m.userId === manualUserId); - if (pinned) return pinned; - } - return auto; - }), + const spotlightSpeaker$ = combineLatest( + [autoSpotlightSpeaker$, pinned$], + (auto, pinned) => pinned ?? auto, ); return screenShares$.pipe( @@ -72,16 +151,19 @@ export function overrideSpotlight$( // shared screen (the whole point of "focus camera during screenshare"). // With no manual pin this is unchanged: the screenshare alone is // spotlighted. - return combineLatest([manualSpotlightUserId$, userMedia$]).pipe( - map(([manualUserId, mediaItems]) => { - const pinned = - manualUserId !== null - ? mediaItems.find((m) => m.userId === manualUserId) - : undefined; - return pinned - ? { spotlight: [...screenShares, pinned], pip$: of(undefined) } - : { spotlight: screenShares, pip$: spotlightSpeaker$ }; - }), + return pinned$.pipe( + map((pinned) => + pinned + ? { + spotlight: [...screenShares, pinned], + // [lotus #29] Keep the active-speaker PiP; it is only + // redundant when the speaker is the pinned participant. + pip$: autoSpotlightSpeaker$.pipe( + map((auto) => (auto === pinned ? undefined : auto)), + ), + } + : { spotlight: screenShares, pip$: spotlightSpeaker$ }, + ), ); return spotlightSpeaker$.pipe( @@ -96,3 +178,84 @@ export function overrideSpotlight$( }), ); } + +export interface ManualSpotlightController { + /** Pin `target`, or clear with `null`. */ + setManualSpotlight: (target: ManualSpotlight | null) => void; + /** + * Side effects that must run for the lifetime of the call. Emits nothing; + * the caller subscribes it through its scope. + */ + effects$: Observable; +} + +/** + * [lotus #3/#16] State + side effects behind `CallViewModel.setManualSpotlight`. + * + * - #3: a pin only feeds `spotlight$`, which the default grid layout ignores + * (and 1:1 layouts bypass entirely), so pinning forces the layout switch to + * "spotlight" and remembers the mode it displaced; clearing the pin restores + * that mode if the user has not switched layouts themselves meanwhile. + * - #16: the pin is dropped when the pinned participant has been absent from + * `userMedia$` for {@link MANUAL_SPOTLIGHT_GONE_MS} (a transient reconnect + * is shorter than that), and on `leave$`. + * + * Pure with respect to its inputs so it can be unit-tested with fake + * observables; CallViewModel only wires it up. + * + * @param manualSpotlight$ the pin state, owned by the caller (it has to exist + * before the layout switch does, because `spotlightAndPip$` feeds into it) + */ +export function createManualSpotlight( + manualSpotlight$: BehaviorSubject, + userMedia$: Observable<{ userId: string }[]>, + leave$: Observable, + layout: { + layout$: Behavior; + setLayout: (mode: LayoutMode) => void; + }, + goneMs = MANUAL_SPOTLIGHT_GONE_MS, +): ManualSpotlightController { + // The layout mode we displaced by forcing spotlight, if any. + let displacedLayout: LayoutMode | null = null; + + const setManualSpotlight = (target: ManualSpotlight | null): void => { + const hadPin = manualSpotlight$.value !== null; + manualSpotlight$.next(target); + if (target !== null) { + if (!hadPin && layout.layout$.value !== "spotlight") { + displacedLayout = layout.layout$.value; + layout.setLayout("spotlight"); + } + } else if (displacedLayout !== null) { + // Only restore if the user hasn't picked a layout themselves since. + if (layout.layout$.value === "spotlight") + layout.setLayout(displacedLayout); + displacedLayout = null; + } + }; + + // #16: pinned user has left for good (absent for `goneMs`), or we hung up. + const pinnedGone$ = manualSpotlight$.pipe( + switchMap((target) => + target === null + ? of(false) + : userMedia$.pipe( + map((items) => !items.some((m) => m.userId === target.userId)), + distinctUntilChanged(), + // A transient absence shorter than goneMs collapses back to + // `false` before the timer fires and is ignored. + debounceTime(goneMs), + filter((gone) => gone), + ), + ), + filter((gone) => gone), + ); + const effects$ = merge(pinnedGone$, leave$).pipe( + filter(() => manualSpotlight$.value !== null), + tap(() => setManualSpotlight(null)), + ignoreElements(), + ); + + return { setManualSpotlight, effects$ }; +} diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index f75d44be..b852c627 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -158,7 +158,11 @@ import { type LocalUserMediaViewModel } from "../media/LocalUserMediaViewModel.t import { type RemoteUserMediaViewModel } from "../media/RemoteUserMediaViewModel.ts"; // [lotus #4] Manual spotlight override, extracted so this file stays byte-close // to upstream (see the file for the behaviour contract). -import { overrideSpotlight$ } from "../../lotus/lotusSpotlight"; +import { + createManualSpotlight, + type ManualSpotlight, + overrideSpotlight$, +} from "../../lotus/lotusSpotlight"; import { createRingingMedia, type RingingMediaViewModel, @@ -267,10 +271,11 @@ export interface CallViewModel { */ toggleScreenSharing: (() => void) | null; /** - * [lotus] Pin a participant to the spotlight by Matrix user id (#4 - * focus-participant). Pass null to clear and restore speaker-follows. + * [lotus] Pin a participant to the spotlight (#4 focus-participant) by + * Matrix user id and, optionally, EC media id (#30). Pass null to clear and + * restore speaker-follows. */ - setManualSpotlight: (userId: string | null) => void; + setManualSpotlight: (target: ManualSpotlight | null) => void; /** * Whether we are sharing our screen. */ @@ -955,11 +960,6 @@ export function createCallViewModel$( ), ); - // [lotus #4] Host-pinned spotlight target (io.lotus.focus_participant). null = - // follow the active speaker (upstream default), so this is inert unless the - // host pins someone. Consumed by overrideSpotlight$ in spotlightAndPip$. - const manualSpotlightUserId$ = new BehaviorSubject(null); - const grid$ = scope.behavior( userMedia$.pipe( switchMap((mediaItems) => { @@ -1000,6 +1000,12 @@ export function createCallViewModel$( ), ); + // [lotus #4] Host-pinned spotlight target (io.lotus.focus_participant). null = + // follow the active speaker (upstream default), so this is inert unless the + // host pins someone. Consumed by overrideSpotlight$ in spotlightAndPip$; + // driven by createManualSpotlight below (after the layout switch exists). + const manualSpotlight$ = new BehaviorSubject(null); + const spotlightAndPip$ = scope.behavior<{ spotlight: MediaViewModel[]; pip$: Observable; @@ -1016,7 +1022,7 @@ export function createCallViewModel$( // stays close to upstream and rebases cleanly. return overrideSpotlight$( spotlightSpeaker$, - manualSpotlightUserId$, + manualSpotlight$, screenShares$, localUserMediaForPip$, userMedia$, @@ -1088,12 +1094,31 @@ export function createCallViewModel$( hasRemoteScreenShares$, ); + // [lotus #3/#16] Pinning forces the layout switch to "spotlight" (grid and + // 1:1 layouts would otherwise never render the pin) and the pin is dropped + // when the participant leaves for good or we hang up; see + // src/lotus/lotusSpotlight.ts. + const manualSpotlight = createManualSpotlight( + manualSpotlight$, + userMedia$, + leave$, + layoutSwitchVm, + ); + manualSpotlight.effects$.pipe(scope.bind()).subscribe(); + const gridLayoutMedia$: Observable = combineLatest( - [grid$, spotlight$], - (grid, spotlight) => ({ + [grid$, spotlight$, manualSpotlight$], + (grid, spotlight, manual) => ({ type: "grid", edgeToEdge: false, - spotlight: spotlight.some((vm) => vm.type === "screen share") + // [lotus #3] Also surface the spotlight when it holds a host-pinned + // participant, so the pin is visible in window modes that ignore the + // layout switch (narrow). Unchanged when manual is null. + spotlight: spotlight.some( + (vm) => + vm.type === "screen share" || + (manual !== null && vm.userId === manual.userId), + ) ? spotlight : undefined, grid, @@ -1796,8 +1821,7 @@ export function createCallViewModel$( join: localMembership.requestJoinAndPublish, leave: localMembership.requestDisconnect, toggleScreenSharing: toggleScreenSharing, - setManualSpotlight: (userId: string | null): void => - manualSpotlightUserId$.next(userId), + setManualSpotlight: manualSpotlight.setManualSpotlight, sharingScreen$: sharingScreen$, tapScreen: (): void => screenTap$.next(),