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() {