diff --git a/extension/background.js b/extension/background.js index 8a20d04..61118c6 100644 --- a/extension/background.js +++ b/extension/background.js @@ -152,7 +152,7 @@ function forgetFrameIds(tabId) { // into a storm. // Reinstalling is now informative rather than amnesic, but it is still work in // every frame; keep it well clear of the discovery poll's own cadence. -const MONITOR_REFRESH_INTERVAL_MS = 5000; +const MONITOR_REFRESH_INTERVAL_MS = 1200; const lastMonitorRefreshByTab = new Map(); const pendingMonitorRefreshByTab = new Map(); @@ -864,12 +864,9 @@ function isCurrentContentSender(sender) { const matchesElectedFrame = senderFrameId === normalizeFrameId(currentTargetFrameId) && (!currentTargetDocumentId || sender.documentId === currentTargetDocumentId); if (matchesElectedFrame) return true; - // The elected frame holds no video, so the election is wrong or stale and a - // frame that is reporting media activity knows better. Frame election is the - // fragile half of this system; sender.frameId is authoritative, costs no - // permission and has no timing window. Trust it rather than dropping the - // user's own play and pause because they came from the real player frame. - return currentTargetHasVideo !== true; + // Any frame in the selected target tab reporting playback activity + // is a valid content sender (e.g. user interacted with or switched to another mirror). + return true; } /** @@ -883,13 +880,16 @@ function adoptReportingFrame(sender) { if (!sender?.tab) return false; const senderTabId = normalizeTabId(sender.tab.id); if (senderTabId === null || senderTabId !== normalizeTabId(currentTabId)) return false; - if (currentTargetHasVideo === true) return false; const senderFrameId = normalizeFrameId(sender.frameId); - if (senderFrameId === normalizeFrameId(currentTargetFrameId)) return false; + if (senderFrameId === normalizeFrameId(currentTargetFrameId) + && (!currentTargetDocumentId || sender.documentId === currentTargetDocumentId)) { + return false; + } currentTargetFrameId = senderFrameId; currentTargetDocumentId = typeof sender.documentId === 'string' ? sender.documentId : null; currentTargetHasVideo = true; + stopMediaDiscoveryPoll(); rememberFrameId(senderTabId, senderFrameId); addLog(`Adopted frame ${senderFrameId} as the media target; it reported playback`, 'info'); chrome.storage.session.set({ @@ -2611,8 +2611,11 @@ function executeScriptWithTimeout(options, timeoutMs = SCRIPT_INJECTION_TIMEOUT_ reject(error); }, timeoutMs); }); + const scriptPromise = chrome.scripting.executeScript(options); + // Attach catch to prevent unhandled promise rejection if timeout wins the race and script fails later (e.g. on tab close) + scriptPromise.catch(() => {}); return Promise.race([ - chrome.scripting.executeScript(options), + scriptPromise, timeout ]).finally(() => { if (timeoutId !== null) clearTimeout(timeoutId); @@ -3291,16 +3294,14 @@ async function selectedMediaTargetMoved(tabId) { return true; } if (normalizeTabId(currentTabId) !== normalizeTabId(tabId)) return false; - // An inconclusive probe is not a reason to move. A page whose players are - // still loading, or that offers several equally-ranked mirrors, resolves - // differently from one moment to the next; acting on that flips the target - // back and forth and leaves activation running forever. + // An inconclusive probe is not a reason to move when on top frame. + // However, if a nested frame was elected (currentTargetFrameId !== 0) and is now + // no longer an accessible candidate with video, the target has vacated. if (resolved.hasVideo !== true) { - // No player anywhere yet. This runs on every lifecycle wake-up, so it is - // the reliable place to make sure freshly rebuilt documents carry a - // monitor — without one, the video created there next is never reported - // and the target can never come back. refreshMediaFrameMonitors(tabId).catch(() => {}); + if (normalizeFrameId(currentTargetFrameId) !== 0 || currentTargetHasVideo === true) { + return true; + } return false; } if (currentTargetHasVideo !== true) return true; diff --git a/extension/media-frame-target.js b/extension/media-frame-target.js index 1c8b211..bab8780 100644 --- a/extension/media-frame-target.js +++ b/extension/media-frame-target.js @@ -513,7 +513,9 @@ function executeWithTimeout(task, timeoutMs, label) { const timeout = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(probeTimeoutError(label, timeoutMs)), timeoutMs); }); - return Promise.race([task(), timeout]).finally(() => { + const taskPromise = Promise.resolve().then(task); + taskPromise.catch(() => {}); + return Promise.race([taskPromise, timeout]).finally(() => { if (timeoutId !== null) clearTimeout(timeoutId); }); } diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 9886c76..62e719e 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -476,6 +476,37 @@ test('re-elects the visible cross-origin player after an iframe switch', async ( expect(await first.locator('video').evaluate(video => video.paused)).toBe(true); }); +test('immediately adopts and syncs when switching mirrors while first mirror was active and playing', async ({ context, extensionId, baseURL }) => { + const url = `${baseURL}/pages/cross-origin-switching.html`; + const page = await context.newPage(); + await page.goto(url); + await page.waitForFunction(() => window.__fixtureReady === true); + const first = page.frames().find(frame => frame.url().includes('/frames/player-frame.html?slot=first')); + const second = page.frames().find(frame => frame.url().includes('/frames/player-frame-2.html?slot=second')); + const { tabId } = await selectTargetTab(context, extensionId, url); + + await expect.poll(() => first.locator('video').getAttribute('data-koala-attached')).toBe('true'); + await sendServerCommand(context, extensionId, tabId, 'play'); + await expect.poll(() => first.locator('video').evaluate(video => !video.paused)).toBe(true); + + const firstStatus = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); + expect(firstStatus.targetHasVideo).toBe(true); + + // Switch mirror and play second video directly in the new frame + await page.evaluate(() => window.switchPlayer()); + await second.locator('video').evaluate(video => video.play()); + + // Should immediately adopt the second mirror + await expect.poll(async () => { + const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); + return status.targetFrameId; + }).not.toBe(firstStatus.targetFrameId); + + // Commands must now control the second frame without delay + await sendServerCommand(context, extensionId, tabId, 'pause'); + await expect.poll(() => second.locator('video').evaluate(video => video.paused)).toBe(true); +}); + test('keeps commands flowing during continuous player-frame geometry changes', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/cross-origin-switching.html`; const page = await context.newPage();