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:
@@ -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) {
|
||||
|
||||
@@ -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<typeof data>((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<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() {
|
||||
|
||||
Reference in New Issue
Block a user