From 0ffe2479292dfe7a65b362e080a7a0cd8873fed2 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Thu, 2 Jul 2026 20:13:01 -0400 Subject: [PATCH] =?UTF-8?q?fix(lotus):=20Wave-1=20audit=20fixes=20(EC1?= =?UTF-8?q?=E2=80=93EC6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EC1: lotusQuality — track + clearTimeout the 500ms settle re-apply per room (was leaking a timer that fired on torn-down rooms). - EC2/EC3: lotusQuality + lotusAudioInject drive off vm.allConnections$ instead of the remote-gated livekitRoomItems$ (were no-ops when alone), matching lotusDenoise. - EC4: lotusDecorations resets its roster to {} on teardown so a decoration from a previous call can't render on a shared user in the next one. - EC5: hoisted a stable useSyncExternalStore subscribe fn (was re-subscribing every tile render). - EC6: lotusFocus only sets the spotlight when the userId field is present (a partial payload no longer clears the pin). tsc clean. Needs a republish to ship. Co-Authored-By: Claude Opus 4.8 --- src/lotus/lotusAudioInject.ts | 9 ++++++--- src/lotus/lotusDecorations.ts | 23 ++++++++++++++--------- src/lotus/lotusFocus.ts | 9 +++++++-- src/lotus/lotusQuality.ts | 28 +++++++++++++++++++++++++--- 4 files changed, 52 insertions(+), 17 deletions(-) diff --git a/src/lotus/lotusAudioInject.ts b/src/lotus/lotusAudioInject.ts index b06b9179..cc25a667 100644 --- a/src/lotus/lotusAudioInject.ts +++ b/src/lotus/lotusAudioInject.ts @@ -38,10 +38,13 @@ export function startLotusAudioInject(vm: CallViewModel): () => void { const w = widget; if (!w) return () => undefined; - // Track the set of connected LiveKit rooms to publish into. + // Track the set of connected LiveKit rooms to publish into. Drive off the + // LOCAL participant's connection(s), not `livekitRoomItems$` — that stream + // omits rooms with no remote members, so inject would no-op while you're + // alone. Map the connections to their livekit rooms like `lotusDenoise.ts`. let rooms: LivekitRoom[] = []; - const sub = vm.livekitRoomItems$.subscribe((items) => { - rooms = items.map((i) => i.livekitRoom); + const sub = vm.allConnections$.subscribe((data) => { + rooms = data.getConnections().map((c) => c.livekitRoom); }); // In-flight clips, so we can abort them on teardown (unmount / vm change / diff --git a/src/lotus/lotusDecorations.ts b/src/lotus/lotusDecorations.ts index a08cff75..ed7216e5 100644 --- a/src/lotus/lotusDecorations.ts +++ b/src/lotus/lotusDecorations.ts @@ -26,15 +26,16 @@ function emit(): void { for (const l of listeners) l(); } +// Stable module-scope subscribe reference, so `useSyncExternalStore` doesn't +// re-subscribe (add/remove the listener) on every render of a tile. +function subscribe(cb: () => void): () => void { + listeners.add(cb); + return () => listeners.delete(cb); +} + /** Subscribe a tile avatar to its participant's decoration URL (or undefined). */ export function useLotusDecoration(userId: string): string | undefined { - return useSyncExternalStore( - (cb) => { - listeners.add(cb); - return () => listeners.delete(cb); - }, - () => decorations[userId], - ); + return useSyncExternalStore(subscribe, () => decorations[userId]); } function safeImageUrl(raw: unknown): string | null { @@ -94,8 +95,12 @@ export function startLotusDecorations(): () => void { registrations = 0; unregister?.(); unregister = null; - // Intentionally keep the last decorations map: a transient remount must - // not drop decorations until the host next pushes an update. + // Reset the roster once the last registration goes away, so a decoration + // pushed in call A can't leak onto a shared user in call B before the + // host re-pushes. Notify listeners so any still-mounted tile drops the + // now-stale overlay via the render path. + decorations = {}; + emit(); } }; } diff --git a/src/lotus/lotusFocus.ts b/src/lotus/lotusFocus.ts index 77b8941a..3868497e 100644 --- a/src/lotus/lotusFocus.ts +++ b/src/lotus/lotusFocus.ts @@ -28,8 +28,13 @@ export function startLotusFocus(vm: CallViewModel): () => void { // Always reply so the host transport doesn't time out. void w.api.transport.reply(ev.detail, {}); const data = ev.detail.data as { userId?: unknown } | undefined; - const userId = typeof data?.userId === "string" ? data.userId : null; - vm.setManualSpotlight(userId); + // 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); + } }; w.lazyActions.on(LotusWidgetActions.FocusParticipant, handler); diff --git a/src/lotus/lotusQuality.ts b/src/lotus/lotusQuality.ts index 5fcea4ce..8c182358 100644 --- a/src/lotus/lotusQuality.ts +++ b/src/lotus/lotusQuality.ts @@ -48,6 +48,10 @@ export function startLotusQuality(vm: CallViewModel): () => void { // Per-room LocalTrackPublished listeners, so sticky settings re-apply on // every (re)publish. const roomListeners = new Map void>(); + // Per-room settle re-apply timers, so we can cancel a pending 500ms re-apply + // when a room is removed or on teardown — otherwise it would fire against a + // torn-down room. + const settleTimers = new Map>(); let rooms: LivekitRoom[] = []; const applyToRoom = (room: LivekitRoom): void => { @@ -76,8 +80,12 @@ export function startLotusQuality(vm: CallViewModel): () => void { const applyToAll = (): void => rooms.forEach(applyToRoom); // Keep the LocalTrackPublished listeners in sync with the connected rooms. - const sub = vm.livekitRoomItems$.subscribe((items) => { - const next = items.map((i) => i.livekitRoom); + // Drive off the LOCAL participant's connection(s), not `livekitRoomItems$` — + // that stream omits rooms with no remote members (returns null for isLocal), + // so caps wouldn't apply to the local senders while you're alone. Map the + // connections to their livekit rooms exactly like `lotusDenoise.ts` does. + const sub = vm.allConnections$.subscribe((data) => { + const next = data.getConnections().map((c) => c.livekitRoom); rooms = next; // Remove listeners for rooms that went away. for (const [room, off] of roomListeners) { @@ -97,7 +105,18 @@ export function startLotusQuality(vm: CallViewModel): () => void { // recompute so our cap wins. const reapply = (): void => { applyToRoom(room); - setTimeout(() => applyToRoom(room), 500); + // Store the settle timer per room and cancel any pending one, so it + // can be cleared on removal/teardown and never fires against a + // torn-down room. + const prev = settleTimers.get(room); + if (prev !== undefined) clearTimeout(prev); + settleTimers.set( + room, + setTimeout(() => { + settleTimers.delete(room); + applyToRoom(room); + }, 500), + ); }; const events = [ ParticipantEvent.LocalTrackPublished, @@ -106,6 +125,9 @@ export function startLotusQuality(vm: CallViewModel): () => void { for (const e of events) room.localParticipant.on(e, reapply); roomListeners.set(room, () => { for (const e of events) room.localParticipant.off(e, reapply); + const t = settleTimers.get(room); + if (t !== undefined) clearTimeout(t); + settleTimers.delete(room); }); applyToRoom(room); }