fix(calls): "Focus camera" toggles to "Unfocus"; pin cleared on dispose; drop dead setPipMode

clearFocusParticipant() had no callers, so a spotlight pin was permanent.
CallControl now tracks focusedUserId, the member menu toggles, and
dispose() clears the pin. Removes _pipMode/setPipMode (never read).

Fixes #56
Fixes #59

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-12 20:28:41 -04:00
co-authored by Claude Opus 5
parent 91def3ad34
commit 0f7f0100af
2 changed files with 52 additions and 8 deletions
+18 -5
View File
@@ -33,7 +33,10 @@ export class CallControl extends EventEmitter implements CallControlState {
// re-observe pass so a busy EC re-render doesn't thrash the control observer.
private bodyMutationTimer?: ReturnType<typeof setTimeout>;
private _pipMode = false;
// [Gitea #56] Tracks the participant currently pinned via focusCameraParticipant(),
// so callers (MemberGlance) can render a "Focus camera" / "Unfocus camera" toggle
// instead of a one-way pin with no way back. null == no manual pin (speaker-follows).
private _focusedUserId: string | null = null;
// C-M3: last quality payload requested via setQuality(). Held so we can (re)send
// it once joined (io.lotus.set_quality must not be sent before call-join — a
@@ -422,10 +425,6 @@ export class CallControl extends EventEmitter implements CallControlState {
this.spotlightButton?.click();
}
public setPipMode(pip: boolean) {
this._pipMode = pip;
}
public toggleReactions() {
this.reactionsButton?.click();
}
@@ -446,17 +445,29 @@ export class CallControl extends EventEmitter implements CallControlState {
* participant has their camera off and EC didn't render a video tile for
* them yet).
*/
public get focusedUserId(): string | null {
return this._focusedUserId;
}
public focusCameraParticipant(userId: string): void {
// [lotus #4] Pin the participant via the fork's widget action instead of
// DOM-poking tiles. EC's layout honors it — including surfacing the camera
// alongside a screenshare (A5) — and it's version-stable. The fork always
// acks, so the promise resolves regardless.
this._focusedUserId = userId;
this.call.transport.send('io.lotus.focus_participant', { userId }).catch(() => undefined);
// [Gitea #56] Notify state-update listeners so the menu can flip to "Unfocus camera".
this.emitStateUpdate();
}
/** [lotus #4] Clear any manual spotlight pin and return to speaker-follows. */
public clearFocusParticipant(): void {
// [Gitea #56] No-op (and no redundant widget send) if nothing is pinned —
// dispose() calls this unconditionally on every call teardown.
if (this._focusedUserId === null) return;
this._focusedUserId = null;
this.call.transport.send('io.lotus.focus_participant', { userId: null }).catch(() => undefined);
this.emitStateUpdate();
}
/**
@@ -502,6 +513,8 @@ export class CallControl extends EventEmitter implements CallControlState {
clearTimeout(this.bodyMutationTimer);
this.bodyMutationTimer = undefined;
}
// [Gitea #56] Don't let a manual focus pin outlive the call.
this.clearFocusParticipant();
this.bodyMutationObserver.disconnect();
this.controlMutationObserver.disconnect();
}