fix(call): Wave-1 audit fixes (calls host side)

- C-H1: forceState only on FIRST join; on EC reconnect re-arm the fork handlers
  (resendForkState — deafen+quality only) instead of clobbering live mic/video/
  deafen back to the join-time snapshot.
- C-H2: AFK auto-mute reads the fork's io.lotus.call_state VAD of the LOCAL
  published track instead of getUserMedia on the browser DEFAULT mic (which could
  measure silence while the user spoke on another device → auto-mute an active
  speaker). Fails safe (never mutes) when call_state is null OR empty.
- C-H3: control observer re-binds after EC re-renders (body subtree:true + 100ms
  debounce) with an early-return so unchanged state doesn't re-render.
- C-M3 setQuality join-gated; C-M4 hangup 4s fallback dispose (idempotent);
  C-M5 PTT no longer silently un-deafens; C-M6 screenshare-audio mute resets on
  stop; C-L4 deafen key works in the iframe; C-L6 setState-after-unmount guards.

Reviewed (C-H2 [] fail-safe + C-H3 re-render guard applied). tsc/eslint/prettier
clean, build OK, 677 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 20:20:07 -04:00
parent 0bbdd7ce94
commit ee6bdd8241
7 changed files with 214 additions and 66 deletions
+79 -5
View File
@@ -29,8 +29,22 @@ export class CallControl extends EventEmitter implements CallControlState {
private controlMutationObserver: MutationObserver;
// C-H3: coalesces bursts of body-subtree mutations into a single debounced
// re-observe pass so a busy EC re-render doesn't thrash the control observer.
private bodyMutationTimer?: ReturnType<typeof setTimeout>;
private _pipMode = false;
// 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
// pre-join send pends to a 10s widget timeout, mirroring the deafen gate).
private lastQuality: LotusQualityPayload | null = null;
// C-M5: set true by CallControls while a push-to-talk key is held. A PTT hold
// unmutes the mic transiently, and onMediaState() must NOT treat that as a
// user-initiated unmute that auto-undeafens the user.
public pttActive = false;
// P6-2: mirrors CallEmbed.joined. Set true from forceState(), which CallEmbed
// invokes only from onCallJoined(). Gates io.lotus.set_deafen so we never send
// before the fork's widget handler mounts (pre-join sends pend to a 10s
@@ -153,19 +167,43 @@ export class CallControl extends EventEmitter implements CallControlState {
// this.joined was still false, so it was gated — this is the first send.)
this.joined = true;
this.sendDeafenState();
this.sendQuality();
}
/**
* C-H1 / C-M3: re-push the sticky fork-side state (deafen + quality) after an
* EC reconnect. Unlike forceState() this does NOT touch mic/video, so a
* reconnect can't clobber the user's live media state — it only re-arms the
* fork handlers that remount on reconnect.
*/
public resendForkState(): void {
this.sendDeafenState();
this.sendQuality();
}
public startObserving() {
if (!this.document) return;
// C-H3: watch the whole body subtree (not just direct children) so we
// re-bind the control observer when EC re-renders its controls deeper in the
// tree. Debounced via onBodyMutation() to avoid thrashing on busy renders.
this.bodyMutationObserver.observe(this.document.body, {
childList: true,
subtree: false, // only direct children of body
subtree: true,
});
this.onBodyMutation();
this.applyBodyMutation();
}
private onBodyMutation() {
// C-H3: coalesce a burst of subtree mutations into one debounced pass.
if (this.bodyMutationTimer !== undefined) return;
this.bodyMutationTimer = setTimeout(() => {
this.bodyMutationTimer = undefined;
this.applyBodyMutation();
}, 100);
}
private applyBodyMutation() {
if (!this.document) return;
this.document.body.style.setProperty('background', 'none', 'important');
@@ -266,22 +304,43 @@ export class CallControl extends EventEmitter implements CallControlState {
this.state = state;
this.emitStateUpdate();
if (this.microphone && !this.sound) {
// C-M5: auto-undeafen when the mic turns on, but NOT for a transient
// push-to-talk unmute — a PTT tap while deafened must not silently
// un-deafen the user.
if (this.microphone && !this.sound && !this.pttActive) {
this.toggleSound();
}
}
private onControlMutation() {
const wasScreensharing = this.screenshare;
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
const spotlight: boolean = this.spotlightButton?.checked ?? false;
// C-M6: when a screenshare stops, clear the screenshare-audio mute so a
// later screenshare doesn't start pre-muted.
const screenshareAudioMuted =
wasScreensharing && !screenshare ? false : this.screenshareAudioMuted;
// C-H3: the body observer now watches subtree:true, so this fires on any DOM
// churn in EC's controls. Only re-emit (→ re-render every consumer) when one
// of the values this method derives actually changed — microphone/video/sound
// are copied unchanged from the current state here.
if (
this.state.screenshare === screenshare &&
this.state.spotlight === spotlight &&
this.state.screenshareAudioMuted === screenshareAudioMuted
) {
return;
}
this.state = new CallControlState(
this.microphone,
this.video,
this.sound,
screenshare,
spotlight,
this.screenshareAudioMuted,
screenshareAudioMuted,
);
this.emitStateUpdate();
}
@@ -423,10 +482,25 @@ export class CallControl extends EventEmitter implements CallControlState {
* clamped fork-side, so out-of-range input can't brick the encoder.
*/
public setQuality(settings: LotusQualityPayload): void {
this.call.transport.send('io.lotus.set_quality', settings).catch(() => undefined);
// C-M3: remember the request and only send once joined; sendQuality() gates
// on this.joined so a pre-join call is a no-op that we replay on join.
this.lastQuality = settings;
this.sendQuality();
}
// C-M3: push the last-requested quality to the fork. Gated on this.joined so
// we never send io.lotus.set_quality before the fork's handler mounts (a
// pre-join send would pend to a 10s widget timeout).
private sendQuality(): void {
if (!this.joined || !this.lastQuality) return;
this.call.transport.send('io.lotus.set_quality', this.lastQuality).catch(() => undefined);
}
public dispose() {
if (this.bodyMutationTimer !== undefined) {
clearTimeout(this.bodyMutationTimer);
this.bodyMutationTimer = undefined;
}
this.bodyMutationObserver.disconnect();
this.controlMutationObserver.disconnect();
}