From 49d9410e3a042cb0f70477ae86fb276a337fd6b7 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 28 Jun 2026 09:17:19 -0400 Subject: [PATCH] fix(calls): resolve EC mute hang, robust camera focus, PiP NaN guard (N122/N123/N126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - N122: setMediaState resolves on EC's transport ACK instead of waiting for a DeviceMute state-echo that EC may elide or skip during teardown — which previously stranded the promise forever and silently skipped the initial deafen state + first StateUpdate on join. Dropped the single-slot mediaStatePromiseResolver; onMediaState remains the authoritative sync path. - N123: focusCameraParticipant now waits for a spotlight videoTile to mount via a MutationObserver (with a 600ms hard-timeout fallback) instead of a fixed 2-frame delay that EC's React commit can exceed on slower devices. - N126: PiP position restored from localStorage is shape+finiteness validated, so corrupt data can't feed NaN into the position math (invalid 'NaNpx' CSS). Co-Authored-By: Claude Opus 4.8 --- src/app/components/CallEmbedProvider.tsx | 20 +++++++++- src/app/plugins/call/CallControl.ts | 47 +++++++++++++++--------- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index b40d5ad14..a6ea82939 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -720,7 +720,25 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { if (pipMode) { if (!wasInPip) { const saved = localStorage.getItem('pip-position'); - const savedPos = saved ? (JSON.parse(saved) as { left: number; top: number }) : null; + let savedPos: { left: number; top: number } | null = null; + if (saved) { + try { + const raw = JSON.parse(saved) as { left?: unknown; top?: unknown }; + // Validate shape + finiteness: a corrupt value would otherwise feed + // NaN into Math.min and produce an invalid `NaNpx` CSS value. + if ( + raw && + typeof raw.left === 'number' && + Number.isFinite(raw.left) && + typeof raw.top === 'number' && + Number.isFinite(raw.top) + ) { + savedPos = { left: raw.left, top: raw.top }; + } + } catch { + savedPos = null; + } + } el.style.right = 'auto'; el.style.bottom = 'auto'; if (savedPos) { diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index 1e34e8a18..27db4f168 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -20,8 +20,6 @@ export class CallControl extends EventEmitter implements CallControlState { private _pipMode = false; - private mediaStatePromiseResolver: undefined | (() => void); - private get document(): Document | undefined { return this.iframe.contentDocument ?? this.iframe.contentWindow?.document; } @@ -183,13 +181,13 @@ export class CallControl extends EventEmitter implements CallControlState { } private async setMediaState(state: ElementMediaStatePayload) { - const data = await this.call.transport.send(ElementWidgetActions.DeviceMute, state); - return new Promise((resolve) => { - if (this.mediaStatePromiseResolver) { - this.mediaStatePromiseResolver(); - } - this.mediaStatePromiseResolver = () => resolve(data); - }); + // transport.send resolves once EC has ACK'd the command, which is enough to + // consider the mute applied. We deliberately do NOT gate completion on a + // follow-up DeviceMute state-echo: EC may elide it (e.g. when the requested + // state already matches its current state) or skip it during teardown, + // which would strand this promise forever and block applyState(). The echo, + // when it does arrive, is still handled authoritatively by onMediaState(). + return this.call.transport.send(ElementWidgetActions.DeviceMute, state); } private setSound(sound: boolean): void { @@ -233,11 +231,6 @@ export class CallControl extends EventEmitter implements CallControlState { if (this.microphone && !this.sound) { this.toggleSound(); } - - if (this.mediaStatePromiseResolver) { - this.mediaStatePromiseResolver(); - this.mediaStatePromiseResolver = undefined; - } } private onControlMutation() { @@ -394,10 +387,30 @@ export class CallControl extends EventEmitter implements CallControlState { } // Switching to spotlight re-renders EC's layout asynchronously; clicking the - // tile in the same tick would land in the old (grid) DOM. Toggle spotlight, - // then click on a later frame once the spotlight tiles have mounted. + // tile in the same tick would land in the old (grid) DOM. A fixed frame + // delay is unreliable (EC's React commit can exceed it on slow devices), so + // watch the iframe DOM for a spotlight video tile to mount, then focus — + // with a hard timeout so the click is always attempted at least once. this.spotlightButton?.click(); - requestAnimationFrame(() => requestAnimationFrame(applyFocus)); + + const tileSelector = '[data-testid="videoTile"]'; + let settled = false; + let observer: MutationObserver | undefined; + let timer: ReturnType | undefined; + const finish = () => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + observer?.disconnect(); + applyFocus(); + }; + observer = new MutationObserver(() => { + if (doc.querySelector(tileSelector)) finish(); + }); + observer.observe(doc.body, { childList: true, subtree: true }); + timer = setTimeout(finish, 600); + // A tile may already be present immediately after toggling spotlight. + if (doc.querySelector(tileSelector)) finish(); } public dispose() {