fix(calls): resolve EC mute hang, robust camera focus, PiP NaN guard (N122/N123/N126)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 09:17:19 -04:00
parent 84a2e7a93e
commit 49d9410e3a
2 changed files with 49 additions and 18 deletions
+19 -1
View File
@@ -720,7 +720,25 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
if (pipMode) { if (pipMode) {
if (!wasInPip) { if (!wasInPip) {
const saved = localStorage.getItem('pip-position'); 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.right = 'auto';
el.style.bottom = 'auto'; el.style.bottom = 'auto';
if (savedPos) { if (savedPos) {
+30 -17
View File
@@ -20,8 +20,6 @@ export class CallControl extends EventEmitter implements CallControlState {
private _pipMode = false; private _pipMode = false;
private mediaStatePromiseResolver: undefined | (() => void);
private get document(): Document | undefined { private get document(): Document | undefined {
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document; return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
} }
@@ -183,13 +181,13 @@ export class CallControl extends EventEmitter implements CallControlState {
} }
private async setMediaState(state: ElementMediaStatePayload) { private async setMediaState(state: ElementMediaStatePayload) {
const data = await this.call.transport.send(ElementWidgetActions.DeviceMute, state); // transport.send resolves once EC has ACK'd the command, which is enough to
return new Promise<typeof data>((resolve) => { // consider the mute applied. We deliberately do NOT gate completion on a
if (this.mediaStatePromiseResolver) { // follow-up DeviceMute state-echo: EC may elide it (e.g. when the requested
this.mediaStatePromiseResolver(); // state already matches its current state) or skip it during teardown,
} // which would strand this promise forever and block applyState(). The echo,
this.mediaStatePromiseResolver = () => resolve(data); // when it does arrive, is still handled authoritatively by onMediaState().
}); return this.call.transport.send(ElementWidgetActions.DeviceMute, state);
} }
private setSound(sound: boolean): void { private setSound(sound: boolean): void {
@@ -233,11 +231,6 @@ export class CallControl extends EventEmitter implements CallControlState {
if (this.microphone && !this.sound) { if (this.microphone && !this.sound) {
this.toggleSound(); this.toggleSound();
} }
if (this.mediaStatePromiseResolver) {
this.mediaStatePromiseResolver();
this.mediaStatePromiseResolver = undefined;
}
} }
private onControlMutation() { private onControlMutation() {
@@ -394,10 +387,30 @@ export class CallControl extends EventEmitter implements CallControlState {
} }
// Switching to spotlight re-renders EC's layout asynchronously; clicking the // 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, // tile in the same tick would land in the old (grid) DOM. A fixed frame
// then click on a later frame once the spotlight tiles have mounted. // 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(); this.spotlightButton?.click();
requestAnimationFrame(() => requestAnimationFrame(applyFocus));
const tileSelector = '[data-testid="videoTile"]';
let settled = false;
let observer: MutationObserver | undefined;
let timer: ReturnType<typeof setTimeout> | 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() { public dispose() {