fix(extension): make anime-style nested players selectable again

Reproduced against the live yummyanime.tv layout, which is:

  top (no video)
  ├── visible same-origin wrapper 830x498 -> cross-origin player
  ├── hidden same-origin wrapper    0x0   -> cross-origin mirror
  └── hidden cross-origin trailer   0x0

Three things kept that page from ever settling on a target.

Equally-ranked players were a hard failure. Several mirrors or dubs loaded at
once is an ordinary layout for these sites, and refusing to activate made them
unusable. The resolver now holds the top frame and waits for one of them to
start playing, which is the signal that breaks the tie.

Inconclusive probes moved the target. A page whose players are still loading
resolves differently from one call to the next, and every difference triggered
a full teardown and reinjection, so activation never finished — the popup sat
on "activating" with nothing in the log. A probe that finds no video now leaves
the target where it is.

The visibility handshake expired mid-probe. Its listener lived 1000ms while the
probe sequence is six separate executeScript round trips; on a heavy page it
was gone before the answer arrived, leaving every frame's visibility unknown —
the exact state that makes two players look equal. It now outlives the sequence.

A settled failure also no longer reports itself as "activating".

Covered by two fixtures built from the real page: one where the player exists
up front, and one where the host only creates it on play, asserting the target
is promoted into the deep cross-origin frame without touching the popup again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Timo
2026-08-18 00:47:29 +02:00
parent aa5173e0d4
commit 096775d39f
8 changed files with 222 additions and 27 deletions
+22 -15
View File
@@ -11,7 +11,6 @@ import { createChatActivityStore } from './chat-activity.js';
import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js';
import {
MEDIA_FRAME_ACCESS_REQUIRED,
MEDIA_FRAME_AMBIGUOUS,
listMediaFrameScriptTargets,
resolveMediaContentTarget
} from './media-frame-target.js';
@@ -2404,7 +2403,8 @@ async function injectContentScript(tabId, {
originPattern: error.originPattern
}, requestAdded, error);
}
if (error?.code === MEDIA_FRAME_AMBIGUOUS) throw error;
// MEDIA_FRAME_AMBIGUOUS is no longer fatal: the resolver falls back to
// the top frame and the monitor promotes the player that starts playing.
addLog(`Media frame probe fell back to the top frame: ${error.message}`, 'warn');
}
@@ -2933,20 +2933,25 @@ async function reactivateCurrentTarget(tabId, { expectedGeneration = targetActiv
* like Drive and YouTube produce while simply playing.
*/
async function selectedMediaTargetMoved(tabId) {
let resolved;
try {
const resolved = await resolveMediaContentTarget(chrome, tabId, { attempts: 1 });
if (normalizeTabId(currentTabId) !== normalizeTabId(tabId)) return false;
const frameMoved = normalizeFrameId(resolved.frameId) !== normalizeFrameId(currentTargetFrameId);
const documentMoved = typeof resolved.documentId === 'string'
&& typeof currentTargetDocumentId === 'string'
&& resolved.documentId !== currentTargetDocumentId;
const gainedVideo = resolved.hasVideo === true && currentTargetHasVideo !== true;
return frameMoved || documentMoved || gainedVideo;
resolved = await resolveMediaContentTarget(chrome, tabId, { attempts: 1 });
} catch {
// Access-required and ambiguity errors must reach the full activation
// path so the popup can surface them.
// An access-required error must reach the full activation path so the
// popup can surface it.
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.
if (resolved.hasVideo !== true) return false;
if (currentTargetHasVideo !== true) return true;
return normalizeFrameId(resolved.frameId) !== normalizeFrameId(currentTargetFrameId)
|| (typeof resolved.documentId === 'string'
&& typeof currentTargetDocumentId === 'string'
&& resolved.documentId !== currentTargetDocumentId);
}
function refreshCurrentMediaTarget(tabId, { queueIfRunning = false, onlyIfTargetMoved = false } = {}) {
@@ -3461,9 +3466,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
? 'activating'
: pendingTarget?.tabId === publicTargetTabId
? 'access_required'
: normalizeTabId(userSelectionErrorTabId) === publicTargetTabId
? 'error'
: 'activating';
// Nothing is in flight and the target is not live, so
// this is a settled failure. Reporting it as
// "activating" is what left the popup spinning forever
// with no way to tell that it had already given up.
: 'error';
sendResponse({
status,
peerId,
+12 -9
View File
@@ -1,5 +1,4 @@
export const MEDIA_FRAME_ACCESS_REQUIRED = 'media_frame_access_required';
export const MEDIA_FRAME_AMBIGUOUS = 'media_frame_ambiguous';
export const MEDIA_FRAME_PROBE_TIMEOUT = 'media_frame_probe_timeout';
const MIN_PLAYER_FRAME_AREA = 320 * 180;
@@ -246,7 +245,12 @@ export function installParentFrameVisibilityProbe(token) {
};
};
window.addEventListener('message', handler);
timeout = setTimeout(cleanup, 1000);
// The listener has to outlive the whole probe sequence: install, four
// dispatch passes and the final inspection, each a separate executeScript
// round trip. On a heavy page those add up well past a second, and a
// listener that expired first left every frame's visibility unknown — which
// is exactly the state that makes two players look equally ranked.
timeout = setTimeout(cleanup, 15000);
window.__koalaFrameVisibilityCleanup = cleanup;
}
@@ -415,12 +419,6 @@ function accessRequiredError(access) {
return error;
}
function ambiguousFrameError() {
const error = new Error('The active embedded video frame could not be identified safely');
error.code = MEDIA_FRAME_AMBIGUOUS;
return error;
}
function contentTarget(tabId, selected) {
const frameId = normalizeFrameId(selected?.frameId);
const documentId = typeof selected?.documentId === 'string' ? selected.documentId : null;
@@ -619,6 +617,11 @@ export async function resolveMediaContentTarget(chromeApi, tabId, {
// frame so the injected monitor can promote the real player once it loads,
// instead of failing the activation or prompting for nothing.
if (unresolvedGrantedHost) return contentTarget(tabId, null);
if (ambiguous) throw ambiguousFrameError();
// Several equally-ranked players — anime mirrors, alternative dubs — are a
// normal page layout, not an error. Refusing to activate made those pages
// unusable, and flipping between candidates restarted the target forever.
// Hold the top frame and let the monitor promote the one that starts
// playing, which is the signal that breaks the tie.
if (ambiguous) return { ...contentTarget(tabId, null), ambiguous: true };
return contentTarget(tabId, null);
}
+10 -3
View File
@@ -1,7 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import {
MEDIA_FRAME_ACCESS_REQUIRED,
MEDIA_FRAME_AMBIGUOUS,
inspectMediaFrame,
resolveMediaContentTarget,
selectMediaFrame
@@ -432,17 +431,25 @@ describe('cross-origin media-frame targeting', () => {
)).resolves.toMatchObject({ frameId: 0, scriptTarget: { tabId: 44 } });
});
it('reports ambiguity rather than controlling an arbitrary equal player', async () => {
it('holds the top frame instead of guessing between equal players', async () => {
const results = [
frame(3, { parentFrameVisible: null }),
frame(4, { parentFrameVisible: null })
];
const executeScript = vi.fn().mockResolvedValue(results);
// Equally-ranked mirrors are an ordinary anime-site layout. Refusing to
// activate made those pages unusable; the tab stays selected on its top
// frame until one of the players starts and breaks the tie.
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
45,
{ attempts: 1, probeDelayMs: 0 }
)).rejects.toMatchObject({ code: MEDIA_FRAME_AMBIGUOUS });
)).resolves.toMatchObject({
frameId: 0,
hasVideo: false,
ambiguous: true,
scriptTarget: { tabId: 45 }
});
});
});