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