mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-19 07:36:16 +00:00
fix(extension): control the player frame without having to elect it first
Reported from the live site: a media title was recognised and audio processing worked, but play and pause did nothing. Both halves of the command path were gated on frame election, and the election had named the top frame. Outbound, commands went to the elected frame alone, which holds no video, so they were delivered and ignored. Inbound, isCurrentContentSender() required sender.frameId to equal the elected frame, so the user's own play and pause arriving from the real player frame were discarded as a stale sender — which is why the room never saw them. Neither direction actually needs the election. Every content-script command handler already begins with findVideo() and returns when there is none, so a tab-wide broadcast is delivered to all frames and acted on only by the one that owns the player. And an inbound media event proves where the player is: sender.frameId is authoritative, costs no permission and has no timing window, so the reporting frame is adopted as the target and later commands are addressed directly again. Both relaxations apply only while the elected frame reports no video. A good election still takes the strict path, so the hidden-player rejections are unaffected. This is the general answer to losing webNavigation.getAllFrames(). That call observed the frame tree without touching it, so it never had a failure window; executeScript has to enter every frame and reliably loses that race against a player which renavigates and rebuilds its video, as Kodik does. The fix is to stop depending on the answer rather than to keep chasing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+76
-9
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user