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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-13 01:22:20 -04:00
co-authored by Claude Opus 5
parent e504a31efd
commit e5d5f13923
4 changed files with 551 additions and 54 deletions
+26 -11
View File
@@ -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<IWidgetApiRequest>): 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);
+295
View File
@@ -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<boolean>;
}
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<ManualSpotlight | null>({
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<ManualSpotlight | null>(null);
const layout$ = new BehaviorSubject<LayoutMode>(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<void>();
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,
});
});
});
+191 -28
View File
@@ -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<UserMediaViewModel | undefined>;
}
/**
* [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<ManualSpotlight | null>,
userMedia$: Observable<UserMediaViewModel[]>,
): Observable<UserMediaViewModel | undefined> {
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<Set<UserMediaViewModel>> =
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<UserMediaViewModel>());
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<UserMediaViewModel | undefined>,
manualSpotlightUserId$: Observable<string | null>,
manualSpotlight$: Observable<ManualSpotlight | null>,
screenShares$: Observable<ScreenShareViewModel[]>,
localUserMediaForPip$: Observable<LocalUserMediaViewModel | undefined>,
userMedia$: Observable<UserMediaViewModel[]>,
): Observable<SpotlightAndPip> {
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<never>;
}
/**
* [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<ManualSpotlight | null>,
userMedia$: Observable<{ userId: string }[]>,
leave$: Observable<unknown>,
layout: {
layout$: Behavior<LayoutMode>;
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$ };
}
+39 -15
View File
@@ -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<string | null>(null);
const grid$ = scope.behavior<UserMediaViewModel[]>(
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<ManualSpotlight | null>(null);
const spotlightAndPip$ = scope.behavior<{
spotlight: MediaViewModel[];
pip$: Observable<UserMediaViewModel | undefined>;
@@ -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<GridLayoutMedia> = 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(),