diff --git a/extension/background.js b/extension/background.js index e2672ec..a8f0aeb 100644 --- a/extension/background.js +++ b/extension/background.js @@ -743,6 +743,27 @@ function sendMessageToChatOverlay(message) { return sendMessageToFrame(tabId, 0, message); } +/** + * Delivers a command to every frame in the tab instead of the elected one. + * + * Frame election is an intervention: executeScript has to enter each frame, it + * is all-or-nothing, and a player that renavigates or rebuilds its video — Kodik + * does both constantly — reliably lands in the window where that fails. The + * election then names the top frame, which holds no video, and playback commands + * go nowhere. webNavigation.getAllFrames() had no such window because it only + * observed; without it, the robust move is to stop needing the answer. + * + * Every content-script command handler already begins with findVideo() and + * returns when there is none, so exactly the frame that owns the video acts. + */ +function broadcastCommandToTab(tabId, message) { + const normalizedTabId = normalizeTabId(tabId); + if (normalizedTabId === null) { + return Promise.reject(new Error('Invalid tab ID')); + } + return chrome.tabs.sendMessage(normalizedTabId, message); +} + function sendMessageToContentTab(tabId, message, callback = null) { if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) { return sendMessageToCurrentContent(message, callback); @@ -762,9 +783,44 @@ function isCurrentContentSender(sender) { && (!activeTargetActivation?.documentId || sender.documentId === activeTargetActivation.documentId); if (Number.isInteger(activeTargetActivation?.frameId)) return matchesActivation; - return senderTabId === normalizeTabId(currentTabId) - && senderFrameId === normalizeFrameId(currentTargetFrameId) + if (senderTabId !== normalizeTabId(currentTabId)) return false; + 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; +} + +/** + * Adopts the frame an accepted media event came from. + * + * This is the self-healing counterpart to the check above: once the real player + * frame identifies itself, later commands can be addressed to it directly + * instead of broadcast. + */ +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; + + currentTargetFrameId = senderFrameId; + currentTargetDocumentId = typeof sender.documentId === 'string' ? sender.documentId : null; + currentTargetHasVideo = true; + rememberFrameId(senderTabId, senderFrameId); + addLog(`Adopted frame ${senderFrameId} as the media target; it reported playback`, 'info'); + chrome.storage.session.set({ + currentTargetFrameId, + currentTargetDocumentId, + currentTargetHasVideo + }).catch(() => {}); + return true; } function isExtensionPageSender(sender) { @@ -3346,14 +3402,22 @@ async function _routeToContentInternal(tabId, action, payload, actionTimestamp, } const targetGeneration = targetActivationGeneration; + const command = { + type: 'SERVER_COMMAND', + action, + payload, + actionTimestamp, + commandSenderId + }; try { - await sendMessageToContentTab(tabId, { - type: 'SERVER_COMMAND', - action, - payload, - actionTimestamp, - commandSenderId - }); + // If the elected frame reports no video, the election is wrong or stale. + // Broadcasting reaches the frame that actually owns the player, and the + // ones that do not own it ignore the command. + if (currentTargetHasVideo === true) { + await sendMessageToContentTab(tabId, command); + } else { + await broadcastCommandToTab(tabId, command); + } } catch (error) { if (!isCurrentTargetIdentity(tabId, targetGeneration)) { if (normalizeTabId(currentTabId) === normalizeTabId(tabId) && retries < 3) { @@ -4059,6 +4123,9 @@ async function handleAsyncMessage(message, sender, sendResponse) { }); } else if (message.type === 'CONTENT_EVENT') { const senderIsContent = !!sender?.tab && !isExtensionPageSender(sender); + // A real player frame just identified itself. Take it as the target so + // subsequent commands can be addressed instead of broadcast. + if (senderIsContent) adoptReportingFrame(sender); if (!senderIsContent && message.expectedTabId !== undefined) { const expectedTabId = normalizeTabId(message.expectedTabId); if (expectedTabId === null || normalizeTabId(currentTabId) !== expectedTabId) { diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 34610ac..dbcf615 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -741,6 +741,38 @@ test('stays ready on a page whose ad frames keep mutating', async ({ context, ex .toBe('ready'); }); +test('controls and adopts a nested player even while the top frame is elected', async ({ context, extensionId, baseURL }) => { + // The failure mode reported from the live site: the election names the top + // frame, which holds no video, so commands go nowhere and the user's own + // play/pause from the real player frame is discarded as a stale sender. + const url = `${baseURL}/pages/yummy-deferred-player.html`; + const page = await context.newPage(); + await page.goto(url); + await page.waitForFunction(() => window.__fixtureReady === true); + + const { tabId, response } = await selectTargetTab(context, extensionId, url); + expect(response).toMatchObject({ status: 'ok', frameId: 0, hasVideo: false }); + + // Build the player without giving the monitor a chance to promote first. + const deferred = page.frames().find(frame => frame.url().endsWith('/frames/deferred-player-frame.html')); + await deferred.locator('#poster').click(); + await expect.poll(() => deferred.locator('video').count()).toBe(1); + + // A command must reach the frame that owns the video regardless of election. + await sendServerCommand(context, extensionId, tabId, 'play', { time: 1 }); + await expect + .poll(() => deferred.locator('video').evaluate(video => video.paused), { timeout: 15000 }) + .toBe(false); + + // And once that frame reports playback, it becomes the addressed target. + await expect + .poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }) + .then(state => state.targetFrameId), { timeout: 15000 }) + .not.toBe(0); + const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); + expect(status).toMatchObject({ targetTabId: tabId, targetHasVideo: true }); +}); + test('keeps the tab selected when its activation fails', async ({ context, extensionId }) => { // A page the extension is not allowed to script stands in for any activation // failure the user can act on. Losing the selection here is what made the