Merge pull request #33 from Shik3i/fix/3.1.3-clean

fix(extension): nested cross-origin player targeting without webNavigation and instant mirror switching
This commit is contained in:
KoalaDev
2026-08-18 21:27:38 +02:00
committed by GitHub
33 changed files with 3652 additions and 2053 deletions
+151
View File
@@ -0,0 +1,151 @@
# Handoff: frame targeting after removing `webNavigation`
> **Read [`nested-player-frame-targeting.md`](./nested-player-frame-targeting.md) first.** It
> records the page layout, every defect fixed so far and the measurements. This file is
> only about **what to do next** and **the decision that is blocking it**.
>
> Branch: `fix/3.1.3-clean`. Working tree green: lint clean, 94 unit tests, 48 browser
> tests, `npm run verify` passes.
---
## The one thing that matters
v3.1.2 worked on these sites within half an hour of being written, because discovery was
a single call:
```js
chrome.webNavigation.getAllFrames({ tabId }) // complete frame list, instantly
```
Everything built since is a **reconstruction of that one call from indirect signals**:
| replacement | fails when |
| --- | --- |
| `executeScript({ allFrames: true })` sweep | any frame is mid-teardown — Chromium rejects the *whole* call |
| learned frame registry (`sender.frameId`) | nothing has run in the frame yet, so nothing can report itself |
| `media-frame-monitor.js` per frame | the frame is new (rebuilt) and has no monitor |
| bounded discovery poll | it is a workaround for the three above |
Each has a window in which it fails. Closing them one by one is what the last two days
were, and it keeps opening new ones — the most recent regression was self-inflicted (see
*What was just reverted*). **This is the architecture, not bad luck.**
## Recommended next step
Replace the reconstruction with browser-managed injection:
```js
await chrome.scripting.registerContentScripts([{
id: 'koala-media-frames',
matches: ['<all_urls>'], // see "Scoping is not possible" below
allFrames: true,
js: ['media-frame-monitor.js'],
runAt: 'document_idle'
}]);
// ... and chrome.scripting.unregisterContentScripts({ ids: [...] }) on deselect
```
Why this ends the whole failure class:
- The **browser** injects into every frame, including frames created later — exactly what
Kodik does on every quality and part change.
- No enumeration, no sweep, no monitor bootstrapping, no poll, no timing window.
- **No new permission.** `scripting` and `<all_urls>` host permissions are already
declared, and dynamically registered scripts produce no additional permission warning.
- Strictly better than v3.1.2, which still had to re-enumerate after every frame change.
Suggested shape: register on `activateTargetTab()`, unregister in `clearUserSelection()`
and on tab removal. Keep the existing registry, broadcast and adoption as they are — they
become belt-and-braces rather than the primary mechanism. The discovery poll
(`startMediaDiscoveryPoll`) can then be deleted.
### Scoping is not possible — this is the real cost
`matches` is evaluated against **each frame's own URL**, not the tab's. A pattern like
`https://yummyanime.tv/*` would therefore *not* reach the `kodikplayer.com` frame, which is
the only frame that matters. To reach an embedded player whose origin is unknown before
discovery has happened, the pattern has to be `<all_urls>`.
There is also no tab scoping: registration is by URL pattern only. With three tabs open on
the same site, all three get the script — the selected one and the two others.
So the honest trade is: **the monitor runs in every frame of every http/https tab while a
target is selected.** A narrower learned allowlist (register the player origins seen on a
previous visit) helps from the second visit onward but cannot solve the first, which is
the case that is broken today.
## The decision that blocks it
A registered content script is **not tab-scoped**. Scoping by origin means it also runs in
*other tabs of the same site*. That conflicts with an explicit project invariant, asserted
in `extension/target-tab-lifecycle.test.mjs`:
> `injects playback and chat scripts only into the explicitly selected tab`
Mitigations, if the owner accepts the trade:
- Only `media-frame-monitor.js` is registered — a passive sentinel that controls nothing,
reads no page content and only posts `MEDIA_FRAME_CANDIDATE_CHANGED`.
- The background already ignores messages from any tab that is not `currentTabId`, so
other tabs cost a message that is dropped.
- `content.js`, `chat-overlay.js` and the page-API bridge stay programmatically injected
into the selected tab only, so the invariant holds for everything that *acts*.
- Registration exists only while a tab is selected, and is removed on deselect.
What it still means in plain terms: while a watch party is active, a passive script runs in
every frame of every ordinary tab, not just the chosen one. That is a privacy-posture
change for a project whose selling point is that it only touches the tab you picked.
**Nothing should be built until the owner has decided this.** If the answer is no, the
current event-driven design stays and the remaining races have to be accepted or papered
over individually — which is the situation that produced this handoff.
## What was just reverted (already in the tree)
The bounded discovery poll reinstalled monitors every 2s, and a freshly installed monitor
took the current DOM as its baseline:
```js
lastCandidateSignature = candidateSignature(); // a video already there is "not a change"
```
So a video that appeared between two reinstalls was never reported — the user's debug log
had **no `[Content]` lines at all**, which is the signature of this bug. A monitor now
announces a video that is already present when it installs, and the reinstall interval was
raised to 5s. Verified by the rebuild test passing repeatedly at ~8s.
## How to reproduce without the live site
Fixtures rebuilt from the real page, in `tests/e2e/fixtures/pages/`:
| fixture | case |
| --- | --- |
| `yummy-style-player.html` | player present up front, two hidden mirrors |
| `yummy-deferred-player.html` | player built only on play — the live case |
| `yummy-churning-player.html` | live ad churn, the frame-discovery stress case |
| `drive-style-player.html` | chat must stay in the top document |
```bash
npx playwright test --config tests/e2e/playwright.config.mjs -g "anime"
```
The decisive one is `recovers when the adopted player frame is torn down and rebuilt`: it
adopts a nested player, destroys its document the way the real player does, and asserts
both that the dead election is released and that the rebuilt player is picked up again.
**It was flaky before the deadlock was closed — if it goes flaky again, that is the signal
that discovery has a new hole, not that the test is bad.** That mistake was made twice.
## Verifying a build is actually loaded
The extension is loaded unpacked from `dist/chrome` (Vivaldi, id
`agiicmjlekhnkfifidegdhegnomcmpen`). After `npm run build:extension`, it needs a manual
reload in `vivaldi://extensions` — browser-internal pages cannot be driven by tooling, so
this step is always the user's.
Fastest confirmation that the right build is running, from the debug report:
- `In Iframe: YES` and a populated **Video** block — the target is the nested player.
- `In Iframe: NO` with `Video Count: 0` — the target is the top frame; the player was
never picked up.
- No `[Content]` lines at all — nothing was ever reported; suspect discovery, not sync.
+202
View File
@@ -0,0 +1,202 @@
# Targeting a nested cross-origin player without `webNavigation`
> **Status:** Fixed. Everything below was verified against the live site or a fixture
> rebuilt from it.
>
> Context: v3.1.2 added support for players inside cross-origin frames (Google Drive,
> anime hosts) and shipped with a `webNavigation` permission. The permission was not
> acceptable for the store listing, so `4d78970` removed it. Removing it broke the
> feature. This document records why, and what replaced it.
---
## Why the permission mattered
`chrome.webNavigation.getAllFrames({ tabId })` is an **observation**: a browser-side
registry lookup that returns every frame's `frameId` and `documentId` *without touching
the frames*. It answers while a frame is loading, navigating or being rebuilt.
`chrome.scripting.executeScript({ target: { tabId, allFrames: true } })` is an
**intervention**: it must run code *inside* every frame. It is all-or-nothing — one frame
that is mid-teardown makes Chromium reject the whole call — and it only reports frames it
managed to enter.
That difference is the entire regression. The players on these sites renavigate and
rebuild their `<video>` continuously, so an intervention-based discovery reliably lands in
a window where it fails, while an observation-based one never had such a window.
**No permission is needed to close the gap** — see *Replacing the frame list* below.
## The page under test
`yummyanime.tv`, KODIK mirror selected. Verified live:
```
top (yummyanime.tv) 0 videos
├── yastatic share iframe 0x0 hidden
├── xfplayer_<id> 0x0 hidden, same-origin <- parked mirror
│ └── absciss.thealloha.club 0x0 hidden, cross-origin
├── xfplayer_<id> 830x498 visible, same-origin <- active wrapper
│ └── kodikplayer.com 830x498 visible, cross-origin <- the player
└── youtube.com/embed 0x0 hidden, cross-origin <- another mirror
```
Properties that matter, all confirmed:
- The player is at **depth 2**, behind a same-origin wrapper.
- It carries **no `sandbox` attribute**, so it is injectable.
- It has **no `<video>` element at all** until playback starts.
- Its frame **navigates after load** (a `/720p` suffix appears), invalidating `documentId`.
- Unselected mirrors stay in the DOM, collapsed to 0x0.
- Loading `kodikplayer.com` directly fails a referrer check, so it can only be inspected
embedded.
The YouTube mirror works where the others do not, because it is a *direct child of the top
frame*; the failures are specific to depth-2 nesting.
## Fixed
Each item was a separate defect on the path from "user picks a tab" to "playback is
controlled".
**Access was inferred, not measured.** Every probe error was swallowed, and any origin
that failed to answer was reported as missing host access — so a slow player frame
produced a permission prompt for an origin the extension already held. The resolver now
calls `permissions.contains()` before raising an access error.
**Probes were unbounded.** Nine `executeScript` calls in the injection path had no
timeout; one unresponsive frame pinned `activeTargetActivation` and the popup showed
`activating` forever with nothing in the log. All are time-boxed, and a watchdog abandons
any activation still running after 30s.
**Equally-ranked players were a hard failure.** Several mirrors loaded at once is an
ordinary layout here. The resolver now holds the top frame and waits for one of them to
start playing, which breaks the tie.
**Inconclusive probes moved the target.** A page whose players are still loading resolves
differently from call to call, and every difference triggered a full teardown and
reinjection, so activation never settled. A probe that finds no video now leaves the
target alone.
**The video-state poll restarted the target.** `getReadyTabVideoState()` treated "no video
found" as a broken injection and forced a reactivation — on every poll, and the dev panel
polls on a timer.
**Ordinary playback looked like a layout change.** The media-frame monitor's candidate
signature included `paused`/`readyState`/`duration`, so every play, pause and buffering
tick retriggered a full reactivation.
**A hidden srcless iframe could hide the page containing it.** An `<iframe>` without `src`
resolves to its *parent document's* URL. Ad slots are frequently srcless, so a hidden slot
could mark a real player's frame as hidden.
**The chat overlay followed the player into its frame**, rendering on top of the video and
scoping close/minimize to that frame. It is now always installed in the top document.
**A failed activation discarded the user's selection**, so the popup came back empty after
being reopened. The selection is now stored in its own right with a terminal state
(`ready` / `activating` / `access_required` / `error`).
### Replacing the frame list
Two mechanisms together do what `getAllFrames()` did, with no permission:
1. **A learned frame registry.** Every `executeScript` result carries `frameId` and
`documentId`, and every content script that messages the background carries
`sender.frameId`. Those are recorded per tab (capped, top frame never evicted) and any
frame the sweep missed is then probed *individually*, so one rejected probe costs one
frame instead of the whole page.
The registry must **not** be cleared on navigation: `tabs.onUpdated` reports
`status: 'loading'` for same-document History API navigations too, which is exactly
what these sites do when switching mirror or episode part. Clearing there wiped the
registry while the player frame was being built. It self-corrects instead — a probe
that reached more than the top frame supersedes the stored ids.
2. **Not needing the answer.** Frame election is the fragile half, and playback does not
depend on it:
- Outbound, a command is **broadcast** to the tab when the elected frame reports no
video. Every content-script handler starts with `findVideo()` and returns when there
is none, so only the frame that owns the player acts.
- Inbound, `isCurrentContentSender()` no longer requires `sender.frameId` to equal the
elected frame while that frame has no video — otherwise the user's own play/pause
arriving from the real player frame was discarded as a stale sender. The reporting
frame is then **adopted** as the target.
Both relaxations apply only while the elected frame reports no video; a good election
still takes the strict path, so hidden-player rejection is unaffected.
### Latency
Selection felt broken at ~20s. Measured against the two anime fixtures after the fix,
calm page / heavy ad churn:
| | calm | ad churn |
| --- | --- | --- |
| selection | 2.5s | 2.6s |
| player promotion | 0.34s | 2.7s |
What was removed: the visibility handshake ran even when no frame had a video (its only
job is ranking candidates); its pass count was fixed at the worst-case nesting depth of
four rather than the observed two; the retry budget was spent waiting for a video no frame
had; and both probe timeouts were 2000ms for what is synchronous DOM work — a live frame
answers in tens of milliseconds, anything slower is a frame being torn down.
The remaining ~2.5s is the injection chain itself (monitors, page-API bridge, chat
overlay, content script), not discovery.
---
## A dead frame election, and the deadlock behind it
**Reported:** 2026-08-18. `Could not establish connection. Receiving end does not exist.`
with `targetReady: true` and no activation errors — the election named a frame the player
had already torn down.
Three defects were stacked here, each hidden by the one in front of it.
**The election was never released.** `getReadyTabVideoState()` recovered through the
guarded refresh, which reports `unchanged` when no video is reachable, so the stale
`frameId`/`documentId` survived. Adoption compounded it: it sets `hasVideo`, and once that
is true the target only moves on a frame change. An unreachable content script — as
opposed to a page that simply has no video yet — now releases the **frame** election back
to the top frame. The tab selection is never touched.
**Switching frames destroyed the top frame's scripts.** Promoting the target from frame 0
into a nested player called `deactivateTargetTab()` on the previous target, which sent
`TARGET_DEACTIVATE` to frame 0 and tore down both its content script and the chat overlay
there. That is why chat delivery failed after promotion, and why releasing the election
pointed at an empty frame. An in-tab frame switch now leaves the top frame alone.
**Discovery could deadlock.** Monitors announce new players, but a rebuilt frame is a new
document with no monitor, so the video created in it was never reported — and nothing then
triggered the upkeep that would have installed one. Reinstalling monitors is cheap,
bounded and idempotent, so it now runs on every lifecycle notification with a
trailing-edge debounce; and a bounded discovery poll (2s, capped, only while a tab is
selected with no video found, stopping the moment one is) breaks the cycle when no
notification arrives at all.
Covered by `recovers when the adopted player frame is torn down and rebuilt`, which
adopts a nested player, destroys its document the way the real player does, and asserts
both that the election is released and that the rebuilt player is picked up again without
touching the popup.
---
## Guardrails
Contract tests in `extension/target-tab-lifecycle.test.mjs` hold the invariants that were
each violated at least once during this work:
- no `chrome.webNavigation` anywhere, and `permissions` limited to the v3.1.1 set
- the frame registry is bounded and is **not** cleared on navigation
- monitors are injected *and* deactivated across known frames, not only via the sweep
- forcing a rebuild stays rare: only an unreachable content script, an explicit request,
and a completed navigation
- playback state stays out of the monitor's candidate signature
- chat messages never go to the media frame
Browser-level coverage lives in `tests/e2e/extension.spec.mjs` against fixtures rebuilt
from the real page: `yummy-style-player.html` (player present up front),
`yummy-deferred-player.html` (player built on play) and `yummy-churning-player.html`
(live ad churn), plus `drive-style-player.html` for the chat-placement case.
+765 -331
View File
File diff suppressed because it is too large Load Diff
-12
View File
@@ -173,18 +173,6 @@ describe('chat overlay contract', () => {
expect(overlaySource).toContain("if (destroyed || area !== 'local') return");
});
it('persists the last manual open/closed state across context refreshes', () => {
expect(overlaySource).toContain('const openStateKey = `chatOverlayOpen:${location.origin}`');
expect(overlaySource).toContain('let lastUserOpenState = null');
expect(overlaySource).toContain('function setOpened(next, persistPreference = true)');
expect(overlaySource).toContain('setLocalStorage({ [openStateKey]: opened })');
expect(overlaySource).toContain('setOpened(false, false)');
expect(overlaySource).toContain('setOpened(lastUserOpenState ?? (chatStartMode === \'open\'), false)');
expect(overlaySource).toContain('typeof data[openStateKey] === \'boolean\'');
expect(overlaySource).toContain('const previousEnabled = context?.enabled === true');
expect(overlaySource).toContain('(!startStateApplied || !previousEnabled)');
});
it('keeps chat hidden by default without discarding the room chat key', () => {
expect(popupSource).toContain('localData.chatEnabled === true');
expect(backgroundSource).toContain('chatEnabled: data.chatEnabled === true');
+5 -13
View File
@@ -28,7 +28,6 @@
large: Object.freeze({ width: 440, height: 640 })
});
const storageKey = `chatOverlayLayout:${location.origin}`;
const openStateKey = `chatOverlayOpen:${location.origin}`;
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
let context = null;
let opened = false;
@@ -58,7 +57,6 @@
let chatSize = 'standard';
let chatStartMode = 'bubble';
let chatReactionDisplay = 'chat';
let lastUserOpenState = null;
let themeMode = 'system';
let themePalette = 'eucalyptus';
let pageDockTarget = null;
@@ -578,12 +576,8 @@
if (persistPreference) setLocalStorage({ chatSize });
}
function setOpened(next, persistPreference = true) {
function setOpened(next) {
opened = !!next && !!context?.enabled;
if (persistPreference) {
lastUserOpenState = opened;
setLocalStorage({ [openStateKey]: opened });
}
panel.classList.toggle('open', opened);
launcher.style.display = opened ? 'none' : '';
if (opened) {
@@ -596,7 +590,6 @@
function applyContext(next) {
const previousRoomId = context?.roomId;
const previousEnabled = context?.enabled === true;
context = next || null;
const supported = !!context?.supported;
const optedIn = !!context?.enabled;
@@ -611,10 +604,10 @@
launcher.setAttribute('aria-disabled', String(!context?.enabled));
if (!optedIn) startStateApplied = false;
if (!context?.enabled) {
setOpened(false, false);
} else if (preferencesLoaded && (!startStateApplied || !previousEnabled)) {
setOpened(false);
} else if (preferencesLoaded && !startStateApplied) {
startStateApplied = true;
setOpened(lastUserOpenState ?? (chatStartMode === 'open'), false);
setOpened(chatStartMode === 'open');
}
applyStrings();
applyLayout();
@@ -974,7 +967,7 @@
systemTheme.addEventListener('change', handleSystemTheme);
chrome.storage.onChanged.addListener(handleStorage);
chrome.runtime.onMessage.addListener(handleRuntime);
chrome.storage.local.get([storageKey, openStateKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay'], data => {
chrome.storage.local.get([storageKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay'], data => {
if (destroyed) return;
const storedLayout = data[storageKey];
if (storedLayout && typeof storedLayout === 'object') {
@@ -985,7 +978,6 @@
chatPosition = normalizePosition(data.chatPosition);
chatSize = normalizeSize(data.chatSize);
chatStartMode = data.chatStartMode === 'open' ? 'open' : 'bubble';
lastUserOpenState = typeof data[openStateKey] === 'boolean' ? data[openStateKey] : null;
chatReactionDisplay = data.chatReactionDisplay === 'video' ? 'video' : 'chat';
layout.mode = chatPosition;
if (layout.mode === 'detached') layout.detachedInitialized = true;
+1393 -1386
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "eine erzwungene Synchronisation gestartet",
"NOTIF_FORCE_EXECUTE": "alle Teilnehmer synchronisiert",
"DEBUG_NO_TAB": "Kein Ziel-Tab ausgewählt.",
"DEBUG_TARGET_ACTIVATING": "Ziel-Tab ausgewählt; Video-Injection wird vorbereitet.",
"DEBUG_COMM_FAIL": "Kommunikation mit dem Tab-Video fehlgeschlagen.",
"EMPTY_PEERS_TITLE": "Noch keine Teilnehmer",
"EMPTY_PEERS_HINT": "Teile deinen Einladungslink, um loszulegen",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "started force sync",
"NOTIF_FORCE_EXECUTE": "synchronized everyone",
"DEBUG_NO_TAB": "No target tab selected.",
"DEBUG_TARGET_ACTIVATING": "Target tab selected; preparing video injection.",
"DEBUG_COMM_FAIL": "Could not communicate with tab video.",
"EMPTY_PEERS_TITLE": "No peers yet",
"EMPTY_PEERS_HINT": "Share your invite link to get started",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "ha iniciado una sincronización forzada",
"NOTIF_FORCE_EXECUTE": "ha sincronizado a todos",
"DEBUG_NO_TAB": "No hay pestaña objetivo seleccionada.",
"DEBUG_TARGET_ACTIVATING": "Pestaña objetivo seleccionada; preparando la inyección de vídeo.",
"DEBUG_COMM_FAIL": "No se pudo comunicar con el video de la pestaña.",
"EMPTY_PEERS_TITLE": "Sin participantes aún",
"EMPTY_PEERS_HINT": "Comparte tu enlace de invitación para comenzar",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "lancé une synchronisation forcée",
"NOTIF_FORCE_EXECUTE": "synchronisé tout le monde",
"DEBUG_NO_TAB": "Aucun onglet cible sélectionné.",
"DEBUG_TARGET_ACTIVATING": "Onglet cible sélectionné ; préparation de linjection vidéo.",
"DEBUG_COMM_FAIL": "Impossible de communiquer avec l'onglet vidéo.",
"EMPTY_PEERS_TITLE": "Aucun membre pour l'instant",
"EMPTY_PEERS_HINT": "Partagez votre lien d'invitation pour commencer",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "ha avviato una sincronizzazione forzata",
"NOTIF_FORCE_EXECUTE": "ha sincronizzato tutti",
"DEBUG_NO_TAB": "Nessuna scheda selezionata.",
"DEBUG_TARGET_ACTIVATING": "Scheda target selezionata; preparazione delliniezione video.",
"DEBUG_COMM_FAIL": "Errore di comunicazione con il video.",
"EMPTY_PEERS_TITLE": "Nessun partecipante",
"EMPTY_PEERS_HINT": "Condividi il tuo link per iniziare",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "強制同期を開始しました",
"NOTIF_FORCE_EXECUTE": "全員を同期しました",
"DEBUG_NO_TAB": "対象のタブが選択されていません。",
"DEBUG_TARGET_ACTIVATING": "対象のタブを選択しました。動画スクリプトを準備しています。",
"DEBUG_COMM_FAIL": "タブのビデオと通信できませんでした。",
"EMPTY_PEERS_TITLE": "メンバーはまだいません",
"EMPTY_PEERS_HINT": "招待リンクを共有して始めましょう",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "강제 동기화를 시작했습니다",
"NOTIF_FORCE_EXECUTE": "모든 사용자를 동기화했습니다",
"DEBUG_NO_TAB": "대상 탭이 선택되지 않았습니다.",
"DEBUG_TARGET_ACTIVATING": "대상 탭이 선택되었습니다. 동영상 주입을 준비하는 중입니다.",
"DEBUG_COMM_FAIL": "탭 비디오와 통신할 수 없습니다.",
"EMPTY_PEERS_TITLE": "참여자 없음",
"EMPTY_PEERS_HINT": "시작하려면 초대 링크를 공유하세요",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "is een geforceerde sync gestart",
"NOTIF_FORCE_EXECUTE": "heeft iedereen gesynchroniseerd",
"DEBUG_NO_TAB": "Geen doeltabblad geselecteerd.",
"DEBUG_TARGET_ACTIVATING": "Doeltabblad geselecteerd; video-injectie wordt voorbereid.",
"DEBUG_COMM_FAIL": "Kon niet communiceren met de videotab.",
"EMPTY_PEERS_TITLE": "Nog geen deelnemers",
"EMPTY_PEERS_HINT": "Deel uw uitnodigingslink om te beginnen",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "wymusił synchronizację",
"NOTIF_FORCE_EXECUTE": "zsynchronizował wszystkich",
"DEBUG_NO_TAB": "Nie wybrano karty docelowej.",
"DEBUG_TARGET_ACTIVATING": "Wybrano kartę docelową; przygotowywanie wstrzyknięcia wideo.",
"DEBUG_COMM_FAIL": "Nie można skomunikować się z wideo w karcie.",
"EMPTY_PEERS_TITLE": "Brak uczestników",
"EMPTY_PEERS_HINT": "Udostępnij link zaproszenia, aby rozpocząć",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "iniciou uma sincronização forçada",
"NOTIF_FORCE_EXECUTE": "sincronizou todos",
"DEBUG_NO_TAB": "Nenhuma aba selecionada.",
"DEBUG_TARGET_ACTIVATING": "Aba de destino selecionada; preparando a injeção de vídeo.",
"DEBUG_COMM_FAIL": "Erro ao se comunicar com o vídeo.",
"EMPTY_PEERS_TITLE": "Nenhum participante",
"EMPTY_PEERS_HINT": "Compartilhe seu link de convite para começar",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "iniciou uma sincronização forçada",
"NOTIF_FORCE_EXECUTE": "sincronizou todos",
"DEBUG_NO_TAB": "Nenhum separador selecionado.",
"DEBUG_TARGET_ACTIVATING": "Separador de destino selecionado; a preparar a injeção de vídeo.",
"DEBUG_COMM_FAIL": "Erro ao comunicar com o vídeo.",
"EMPTY_PEERS_TITLE": "Nenhum participante",
"EMPTY_PEERS_HINT": "Partilhe o seu link de convite para começar",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "запустил принудительный синхрон",
"NOTIF_FORCE_EXECUTE": "синхронизировал воспроизведение у всех",
"DEBUG_NO_TAB": "Целевая вкладка не выбрана.",
"DEBUG_TARGET_ACTIVATING": "Целевая вкладка выбрана; подготовка внедрения видео.",
"DEBUG_COMM_FAIL": "Не удалось связаться с плеером на вкладке.",
"EMPTY_PEERS_TITLE": "Участников пока нет",
"EMPTY_PEERS_HINT": "Поделитесь ссылкой-приглашением, чтобы начать",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "zorunlu eşitleme başlattı",
"NOTIF_FORCE_EXECUTE": "herkesi eşitledi",
"DEBUG_NO_TAB": "Hedef sekme seçilmedi.",
"DEBUG_TARGET_ACTIVATING": "Hedef sekme seçildi; video enjeksiyonu hazırlanıyor.",
"DEBUG_COMM_FAIL": "Sekme videosuyla iletişim kurulamadı.",
"EMPTY_PEERS_TITLE": "Henüz kimse yok",
"EMPTY_PEERS_HINT": "Başlamak için davet bağlantınızı paylaşın",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "почав примусову синхронізацію",
"NOTIF_FORCE_EXECUTE": "синхронізував усіх",
"DEBUG_NO_TAB": "Цільова вкладка не вибрана.",
"DEBUG_TARGET_ACTIVATING": "Цільову вкладку вибрано; готується впровадження відео.",
"DEBUG_COMM_FAIL": "Не вдалося зв’язатися з відео вкладки.",
"EMPTY_PEERS_TITLE": "Учасників ще немає",
"EMPTY_PEERS_HINT": "Поділіться своїм запрошенням, щоб почати",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "开始强制同步",
"NOTIF_FORCE_EXECUTE": "同步所有人",
"DEBUG_NO_TAB": "未选择目标选项卡。",
"DEBUG_TARGET_ACTIVATING": "已选择目标标签页;正在准备注入视频脚本。",
"DEBUG_COMM_FAIL": "无法与标签视频通信。",
"EMPTY_PEERS_TITLE": "还没有同行",
"EMPTY_PEERS_HINT": "分享您的邀请链接以开始使用",
+12 -4
View File
@@ -62,12 +62,15 @@
const source = element.tagName === 'VIDEO'
? (element.currentSrc || element.src || element.querySelector?.('source[src]')?.src || '')
: (element.src || '');
// Deliberately coarse. This signature answers "which frame is a
// candidate", not "what is it doing". Including paused/readyState
// /duration made every play, pause and buffering tick look like a
// layout change, so ordinary playback retriggered a full target
// reactivation and re-injected the content script under the user.
const mediaState = element.tagName === 'VIDEO'
? [
element.paused ? 0 : 1,
element.controls ? 1 : 0,
Number.isInteger(element.readyState) ? element.readyState : 0,
Number.isFinite(element.duration) ? Math.round(element.duration) : 0
element.readyState > 0 ? 1 : 0,
Number.isFinite(element.duration) && element.duration > 0 ? 1 : 0
].join(',')
: '';
parts.push([
@@ -188,6 +191,11 @@
});
hookFrames();
lastCandidateSignature = candidateSignature();
// A monitor installed after the player already exists would otherwise take
// that player as its baseline and never mention it. Frames get a monitor
// late all the time — a rebuilt document, a reinstall — so announce an
// already-present video once instead of staying silent about it.
if (document.querySelector('video')) schedule('monitor_installed', { force: true });
window.addEventListener('pagehide', handlePageHide);
window.addEventListener('pageshow', handlePageShow);
window.addEventListener('resize', handleResize, { passive: true });
+242 -79
View File
@@ -1,8 +1,14 @@
export const MEDIA_FRAME_ACCESS_REQUIRED = 'media_frame_access_required';
export const MEDIA_FRAME_PROBE_TIMEOUT = 'media_frame_probe_timeout';
const MIN_PLAYER_FRAME_AREA = 320 * 180;
const MIN_PLAYER_ASPECT_RATIO = 1.15;
const MAX_PLAYER_ASPECT_RATIO = 2.6;
const DEFAULT_PROBE_TIMEOUT_MS = 1500;
// inspectMediaFrame is synchronous DOM work: a live frame answers in tens of
// milliseconds, and anything slower is a frame that is navigating or being torn
// down. Waiting seconds for those only delays the answer — a frame dropped here
// is re-probed on the next attempt and reports itself through its monitor.
const DEFAULT_PROBE_TIMEOUT_MS = 750;
function normalizeFrameId(value) {
return Number.isInteger(value) && value >= 0 ? value : 0;
@@ -165,10 +171,16 @@ export function inspectMediaFrame(expectedVisibilityToken = null) {
const rect = frame.getBoundingClientRect();
const directVisible = elementIsVisible(frame, rect);
const visible = ancestorVisible && directVisible;
// An iframe without src resolves to the *parent* document's URL, so
// record whether the element really carried one. Treating a srcless
// ad slot's href as its own would let a hidden slot mark the page
// that contains it as hidden.
const rawSrc = frame.getAttribute?.('src') || '';
let href = '';
try { href = new URL(frame.src || '', doc.location.href).href; } catch { href = ''; }
embeddedFrames.push({
href,
explicitSrc: rawSrc.trim().length > 0,
origin: (() => { try { return new URL(href).origin; } catch { return null; } })(),
area: Math.max(0, rect.width) * Math.max(0, rect.height),
width: Math.max(0, rect.width),
@@ -243,7 +255,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;
}
@@ -328,11 +345,38 @@ function sameMeaningfulRank(left, right) {
return leftRank.slice(0, 8).every((value, index) => value === rightRank[index]);
}
/**
* Frames whose own element was seen as hidden by an ancestor that could inspect
* it directly. A same-origin wrapper collapsed to 0x0 — the usual way an anime
* host parks the mirrors you are not watching — is reported here by the top
* frame itself, so the hidden player can be ruled out without waiting for the
* postMessage visibility handshake to complete.
*/
function hiddenFrameHrefs(injectionResults) {
const visibility = new Map();
for (const entry of Array.isArray(injectionResults) ? injectionResults : []) {
for (const frame of entry?.result?.embeddedFrames || []) {
if (typeof frame?.href !== 'string' || !frame.href) continue;
// Without an explicit src the href is the parent's, not this frame's.
if (frame.explicitSrc !== true) continue;
// A frame cannot testify about the document that reported it.
if (frame.href === entry?.result?.href) continue;
// Any ancestor reporting it visible wins over one reporting it hidden.
visibility.set(frame.href, (visibility.get(frame.href) === true) || frame.visible === true);
}
}
const hidden = new Set();
for (const [href, visible] of visibility) if (!visible) hidden.add(href);
return hidden;
}
export function selectMediaFrame(injectionResults) {
const hidden = hiddenFrameHrefs(injectionResults);
const candidates = (Array.isArray(injectionResults) ? injectionResults : [])
.filter(entry => Number.isInteger(entry?.frameId)
&& entry?.result?.bestVideo?.rendered === true)
.filter(entry => entry.result.parentFrameVisible !== false)
.filter(entry => !hidden.has(entry.result.href))
.sort(compareRanks);
if (candidates.length === 0) return null;
if (candidates.length > 1
@@ -387,13 +431,19 @@ function findMissingPlayerAccess(results) {
function shouldPreferMissingAccess(access, selected) {
if (!access) return false;
if (access.drivePlayer || !selected?.result?.bestVideo) return true;
if (!selected?.result?.bestVideo) return true;
const video = selected.result.bestVideo;
if (!video.hasSource || !video.rendered || video.background) return true;
const selectedArea = Number.isFinite(video.renderedArea) ? video.renderedArea : 0;
const weakAccessibleCandidate = !video.controls
&& video.duration > 0
&& video.duration < 300;
// Drive never plays the file in its own document, so its embedded player
// outranks a weak local candidate. It must not outrank a real one: a Drive
// tab can host an ordinary accessible video next to a file preview.
if (access.drivePlayer) {
return weakAccessibleCandidate || selectedArea < MIN_PLAYER_FRAME_AREA;
}
return weakAccessibleCandidate
&& access.area >= Math.max(MIN_PLAYER_FRAME_AREA, selectedArea * 1.5);
}
@@ -406,11 +456,14 @@ function accessRequiredError(access) {
return error;
}
function contentTarget(tabId, selected, monitorTargets = null) {
function contentTarget(tabId, selected, discoveredFrameIds = null) {
const frameId = normalizeFrameId(selected?.frameId);
const documentId = typeof selected?.documentId === 'string' ? selected.documentId : null;
const target = {
return {
frameId,
// Every frame this probe reached, so the caller can remember them and
// recover directly next time the all-frames sweep is rejected.
discoveredFrameIds: Array.isArray(discoveredFrameIds) ? discoveredFrameIds : [],
documentId,
frameUrl: typeof selected?.result?.href === 'string' ? selected.result.href : null,
hasVideo: !!selected?.result?.bestVideo,
@@ -418,148 +471,220 @@ function contentTarget(tabId, selected, monitorTargets = null) {
? { tabId, documentIds: [documentId] }
: (frameId === 0 ? { tabId } : { tabId, frameIds: [frameId] })
};
if (Array.isArray(monitorTargets) && monitorTargets.length > 0) {
target.monitorTargets = monitorTargets;
}
return target;
}
export function listMediaFrameScriptTargets(tabId) {
return [{ tabId, allFrames: true }];
}
function listFrameProbeTargets(tabId, embeddedFrameCount = 0) {
// Chromium can reject one all-frames executeScript call when a single
// child frame is browser-owned or temporarily unavailable. Frame IDs are
// not exposed without webNavigation, so probe a bounded range individually
// after the top frame tells us that embedded frames exist. Each rejected
// probe is isolated and cannot hide the other frames.
const maxFrameId = Math.min(64, Math.max(8, (embeddedFrameCount * 4) + 4));
return Array.from({ length: maxFrameId }, (_, frameId) => ({
tabId,
frameIds: [frameId]
}));
}
/** Pins one probe to one frame, preferring the exact document when known. */
function frameScriptTarget(tabId, entry) {
const frameId = normalizeFrameId(entry?.frameId);
return typeof entry?.documentId === 'string' && entry.documentId
? { tabId, documentIds: [entry.documentId] }
: (frameId === 0 ? { tabId, frameIds: [0] } : { tabId, frameIds: [frameId] });
: { tabId, frameIds: [frameId] };
}
/**
* Later results replace earlier ones for the same frame. A frame that navigated
* between two probes must not appear twice, because two stale copies of one
* frame look exactly like two competing players.
*/
function mergeFrameResults(...groups) {
const merged = new Map();
for (const group of groups) {
for (const entry of Array.isArray(group) ? group : []) {
if (!Number.isInteger(entry?.frameId)) continue;
// A frame ID identifies the current slot. If its document changed
// between the broad probe and the exact probe, the exact result
// must replace the stale document rather than create a duplicate
// candidate that can trigger a false ambiguity.
const key = `frame:${entry.frameId}`;
merged.set(key, entry);
merged.set(`frame:${entry.frameId}`, entry);
}
}
return Array.from(merged.values());
}
function probeTimeoutError(label, timeoutMs) {
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
error.code = MEDIA_FRAME_PROBE_TIMEOUT;
return error;
}
function executeWithTimeout(task, timeoutMs, label) {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return task();
let timeoutId = null;
const timeout = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
error.code = 'media_frame_probe_timeout';
reject(error);
}, timeoutMs);
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);
});
}
async function executeInAccessibleFrames(chromeApi, targets, func, args, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
async function executeInAccessibleFrames(chromeApi, targets, func, args, timeoutMs) {
const errors = [];
const settled = await Promise.all(targets.map(async target => {
try {
const result = await executeWithTimeout(
() => chromeApi.scripting.executeScript({ target, func, args }),
timeoutMs,
`Frame probe for ${JSON.stringify(target)}`
`Frame probe ${JSON.stringify(target)}`
);
return Array.isArray(result) ? result : [];
} catch {
} catch (error) {
// A failed probe is recorded, never silently dropped. Only the
// caller can tell "withheld origin" from "frame is still loading",
// and guessing that difference is what produced false permission
// prompts for players the extension was already allowed to touch.
errors.push({ target, error });
return [];
}
}));
return settled.flat();
return { results: settled.flat(), errors };
}
/**
* Asks the browser whether an origin is genuinely withheld.
*
* Returns true when the grant is missing, false when it is held, and null when
* the browser cannot answer. A frame that did not respond to a probe is not
* evidence of a missing grant: that inference is what made Drive and
* YummyAnime demand access for an origin the extension already had.
*/
async function originAccessIsWithheld(chromeApi, originPattern) {
if (typeof originPattern !== 'string' || !originPattern) return null;
if (typeof chromeApi?.permissions?.contains !== 'function') return null;
try {
const granted = await executeWithTimeout(
() => Promise.resolve(chromeApi.permissions.contains({ origins: [originPattern] })),
1000,
`Permission check for ${originPattern}`
);
if (granted === true) return false;
if (granted === false) return true;
return null;
} catch {
return null;
}
}
export async function resolveMediaContentTarget(chromeApi, tabId, {
// v3.1.2's retry budget: a player frame can take several seconds to appear,
// and giving up early is what turns a slow page into "no video found".
attempts = 8,
retryDelayMs = 200,
probeDelayMs = 60,
probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS
probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
// Frame ids the background has seen in this tab. They rescue the probe when
// the all-frames sweep is rejected wholesale by one unrelated frame.
knownFrameIds = [],
// ...but the budget is now wall-clock bounded, so a page whose frames all
// time out cannot hold the activation open for minutes.
deadlineMs = 12000
} = {}) {
const startedAt = Date.now();
let fallback = null;
let missingAccess = null;
let monitorTargets = [];
let ambiguous = false;
let unresolvedGrantedHost = null;
const discovered = new Set(Array.isArray(knownFrameIds) ? knownFrameIds : []);
for (let attempt = 0; attempt < attempts; attempt++) {
const topResults = await executeInAccessibleFrames(
const scriptTargets = listMediaFrameScriptTargets(tabId);
let { results } = await executeInAccessibleFrames(
chromeApi,
[{ tabId, frameIds: [0] }],
scriptTargets,
inspectMediaFrame,
[null],
probeTimeoutMs
);
const allFrameResults = await executeInAccessibleFrames(
chromeApi,
listMediaFrameScriptTargets(tabId),
inspectMediaFrame,
[null],
probeTimeoutMs
);
let results = mergeFrameResults(topResults, allFrameResults);
const embeddedFrameCount = topResults.reduce(
(count, entry) => Math.max(count, entry?.result?.embeddedFrames?.length || 0),
0
);
if (embeddedFrameCount > 0) {
const individuallyProbed = await executeInAccessibleFrames(
// Any frame the sweep missed but that we know exists gets asked directly.
// One rejected probe then costs one frame, not the whole page.
const missingFrameIds = knownFrameIds.filter(frameId => Number.isInteger(frameId)
&& !results.some(entry => entry.frameId === frameId));
if (missingFrameIds.length > 0) {
const { results: recovered } = await executeInAccessibleFrames(
chromeApi,
listFrameProbeTargets(tabId, embeddedFrameCount),
missingFrameIds.map(frameId => ({ tabId, frameIds: [frameId] })),
inspectMediaFrame,
[null],
probeTimeoutMs
);
results = mergeFrameResults(results, individuallyProbed);
if (recovered.length > 0) results = mergeFrameResults(results, recovered);
}
if (results.length === 0) return contentTarget(tabId, null);
if (results.length > 1) {
const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`;
const frameTargets = results.map(entry => frameScriptTarget(tabId, entry));
if (results.length === 0) {
// The all-frames sweep answered for nothing at all, so fall back to
// the top document alone. Every probe is time-boxed: an unreachable
// player frame must never stall the whole activation.
try {
const topResults = await executeWithTimeout(
() => chromeApi.scripting.executeScript({
target: { tabId },
func: inspectMediaFrame,
args: [null]
}),
probeTimeoutMs,
'Top-frame probe'
);
results = Array.isArray(topResults) ? topResults : [];
if (results.length === 0) return contentTarget(tabId, null);
} catch {
return contentTarget(tabId, null);
}
}
const candidateCount = results.filter(
entry => entry?.result?.bestVideo?.rendered === true
).length;
// The visibility handshake only exists to rank and exclude video
// candidates. With no video on the page yet there is nothing to rank, and
// running it anyway cost several seconds on every attempt — the whole
// reason selecting an anime tab before playback felt broken.
if (results.length > 1 && candidateCount > 0) {
const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`;
// Address each discovered frame on its own from here on. A single
// allFrames call is all-or-nothing: one player or ad frame that
// never answers takes the whole probe down with it. v3.1.2 avoided
// that by listing frames through webNavigation — but the sweep
// above already reports frameId and documentId for every frame it
// reached, so the same isolation costs no permission at all.
// A leaf frame with no video and no nested frames can never be a
// candidate nor an ancestor of one. Ad slots are exactly that, and
// they churn constantly, so every phase below would otherwise wait
// on a frame that was already being torn down.
const relevant = results.filter(entry => (entry?.result?.videoCount || 0) > 0
|| (entry?.result?.embeddedFrames?.length || 0) > 0
|| entry?.result?.isTop === true);
const frameTargets = (relevant.length > 0 ? relevant : results)
.map(entry => frameScriptTarget(tabId, entry));
try {
const visibilityTimeoutMs = Math.min(probeTimeoutMs, 750);
await executeInAccessibleFrames(
chromeApi,
frameTargets,
installParentFrameVisibilityProbe,
[token],
probeTimeoutMs
visibilityTimeoutMs
);
// Four passes match the maximum same-origin recursion depth.
for (let pass = 0; pass < 4; pass++) {
// One pass per nesting level actually present. Four was the
// worst case, not the common one; these players sit two levels
// down and each surplus pass is a full round trip.
const observedDepth = results.reduce((deepest, entry) => Math.max(
deepest,
...(entry?.result?.embeddedFrames || []).map(frame => frame.depth || 1)
), 1);
const passes = Math.min(4, Math.max(2, observedDepth));
for (let pass = 0; pass < passes; pass++) {
await executeInAccessibleFrames(
chromeApi,
frameTargets,
dispatchParentFrameVisibilityProbe,
[token],
probeTimeoutMs
visibilityTimeoutMs
);
await new Promise(resolve => setTimeout(resolve, probeDelayMs));
}
const inspected = await executeInAccessibleFrames(
const { results: inspected } = await executeInAccessibleFrames(
chromeApi,
frameTargets,
inspectMediaFrame,
@@ -572,32 +697,70 @@ export async function resolveMediaContentTarget(chromeApi, tabId, {
// frames will be rejected below rather than guessed.
}
}
// Rebuild these after the visibility refresh so a frame navigation that
// replaced its document ID cannot leave a stale monitor target behind.
monitorTargets = results.map(entry => frameScriptTarget(tabId, entry));
for (const entry of results) {
if (Number.isInteger(entry?.frameId)) discovered.add(entry.frameId);
}
const selected = selectMediaFrame(results);
const currentMissingAccess = findMissingPlayerAccess(results);
const videoCandidates = results.filter(entry => entry?.result?.bestVideo?.rendered === true
&& entry.result.parentFrameVisible !== false);
let currentMissingAccess = findMissingPlayerAccess(results);
if (currentMissingAccess) {
const withheld = await originAccessIsWithheld(
chromeApi,
currentMissingAccess.originPattern
);
if (withheld === false) {
// The grant is already held, so the player frame is merely slow,
// still navigating, or gone. Retrying is correct here; prompting
// for a permission the user already gave is not.
unresolvedGrantedHost = currentMissingAccess.host;
currentMissingAccess = null;
}
}
missingAccess = currentMissingAccess;
fallback = selected;
ambiguous = !selected && videoCandidates.length > 1;
if (selected) {
if (selected.result.bestVideo.hasSource
&& selected.result.bestVideo.rendered
&& !shouldPreferMissingAccess(currentMissingAccess, selected)) {
return contentTarget(tabId, selected, monitorTargets);
return contentTarget(tabId, selected, Array.from(discovered));
}
}
if (Number.isFinite(deadlineMs) && deadlineMs > 0 && Date.now() - startedAt >= deadlineMs) {
break;
}
// Nothing on the page has a video element yet. Spending the retry budget
// cannot change that; the injected monitor reports the player the moment
// it is created, so return now and let selection be instant.
const anyVideo = results.some(entry => (entry?.result?.videoCount || 0) > 0);
if (!anyVideo) {
// Discovery worked and simply found no player yet, so stop: the
// monitor reports one within a fraction of a second once it exists.
// Retry only when the sweep itself came back thin, which is the case
// a second pass can actually fix.
if (results.length > 1) break;
if (attempt >= 1) break;
}
if (attempt < attempts - 1) {
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
}
}
if (missingAccess) throw accessRequiredError(missingAccess);
if (fallback) return contentTarget(tabId, fallback, monitorTargets);
// Selecting a tab must not depend on video detection. A page can be a
// valid target before its player exists, and an ambiguous frame layout is
// recoverable through the injected lifecycle monitor. Keep the top-frame
// target active instead of discarding the user's selection.
return contentTarget(tabId, null, monitorTargets);
if (fallback) return contentTarget(tabId, fallback, Array.from(discovered));
// A player whose origin is already granted but which never answered is a
// timing problem, not a user decision. Keep the tab selected on its top
// 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, Array.from(discovered));
// 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, Array.from(discovered)), ambiguous: true };
return contentTarget(tabId, null, Array.from(discovered));
}
+211 -113
View File
@@ -108,6 +108,63 @@ describe('cross-origin media-frame targeting', () => {
expect(selected.frameId).toBe(0);
});
it('ignores a hidden srcless ad slot that resolves to another frame url', () => {
// An iframe without src resolves to its parent document's URL. A hidden
// ad slot must therefore never be able to declare a real player hidden.
const selected = selectMediaFrame([
frame(0, {
bestVideo: null,
videoCount: 0,
embeddedFrames: [{
href: 'https://player-5.example/embed',
explicitSrc: false,
visible: false,
area: 0,
width: 0,
height: 0,
depth: 1,
mediaHint: false
}]
}),
frame(5, { parentFrameVisible: null })
]);
expect(selected?.frameId).toBe(5);
});
it('drops a player parked inside a collapsed same-origin wrapper', () => {
const selected = selectMediaFrame([
frame(0, {
bestVideo: null,
videoCount: 0,
embeddedFrames: [
{
href: 'https://player-5.example/embed',
explicitSrc: true,
visible: true,
area: 830 * 498,
width: 830,
height: 498,
depth: 2,
mediaHint: true
},
{
href: 'https://player-6.example/embed',
explicitSrc: true,
visible: false,
area: 0,
width: 0,
height: 0,
depth: 2,
mediaHint: true
}
]
}),
frame(5, { parentFrameVisible: null }),
frame(6, { parentFrameVisible: null })
]);
expect(selected?.frameId).toBe(5);
});
it('refuses to guess between equally-ranked frames without visibility evidence', () => {
expect(selectMediaFrame([
frame(3, { parentFrameVisible: null }),
@@ -127,16 +184,47 @@ describe('cross-origin media-frame targeting', () => {
documentId: 'document-8',
frameUrl: 'https://player-8.example/embed',
hasVideo: true,
scriptTarget: { tabId: 42, documentIds: ['document-8'] },
monitorTargets: [
{ tabId: 42, documentIds: ['document-0'] },
{ tabId: 42, documentIds: ['document-8'] }
]
// Reported back so the caller can address these frames directly when
// a later all-frames sweep is rejected wholesale.
discoveredFrameIds: [0, 8],
scriptTarget: { tabId: 42, documentIds: ['document-8'] }
});
const visibilityDispatches = executeScript.mock.calls.filter(([options]) => (
options.func?.name === 'dispatchParentFrameVisibilityProbe'
));
expect(visibilityDispatches.length).toBeGreaterThanOrEqual(4);
// Two passes — the floor — across both discovered frames, each addressed
// on its own so a frame that never answers cannot cancel the others.
// These fixtures report no nesting, so the depth-scaled pass count must
// not spend the worst-case four round trips here.
expect(visibilityDispatches).toHaveLength(4);
expect(visibilityDispatches.every(([options]) => options.target.allFrames !== true)).toBe(true);
});
it('skips the visibility handshake when no frame has a video yet', async () => {
// Selecting an anime tab before playback must be immediate: there is
// nothing to rank, so the handshake and the retry budget are pure delay.
const results = [
frame(0, { bestVideo: null, videoCount: 0 }),
frame(3, { bestVideo: null, videoCount: 0 }),
frame(4, { bestVideo: null, videoCount: 0 })
];
const executeScript = vi.fn().mockResolvedValue(results);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
42,
{ probeDelayMs: 0, retryDelayMs: 0 }
)).resolves.toMatchObject({ frameId: 0, hasVideo: false });
expect(executeScript.mock.calls.filter(([options]) => (
options.func?.name === 'dispatchParentFrameVisibilityProbe'
|| options.func?.name === 'installParentFrameVisibilityProbe'
))).toHaveLength(0);
// And it must not burn all eight attempts waiting for a video that no
// frame has: the injected monitor reports one the moment it appears.
const inspections = executeScript.mock.calls.filter(([options]) => (
options.func?.name === 'inspectMediaFrame'
));
expect(inspections.length).toBeLessThanOrEqual(4);
});
it('keeps the top target inactive when the only discovered video is hidden', async () => {
@@ -154,11 +242,8 @@ describe('cross-origin media-frame targeting', () => {
documentId: null,
frameUrl: null,
hasVideo: false,
scriptTarget: { tabId: 42 },
monitorTargets: [
{ tabId: 42, documentIds: ['document-0'] },
{ tabId: 42, documentIds: ['document-6'] }
]
discoveredFrameIds: [0, 6],
scriptTarget: { tabId: 42 }
});
});
@@ -172,108 +257,9 @@ describe('cross-origin media-frame targeting', () => {
)).resolves.toMatchObject({
frameId: 8,
hasVideo: true,
scriptTarget: { tabId: 42, documentIds: ['document-8'] },
monitorTargets: expect.arrayContaining([
{ tabId: 42, documentIds: ['document-8'] }
])
});
expect(executeScript.mock.calls.some(([options]) => (
options.target?.tabId === 42 && options.target?.allFrames === true
))).toBe(true);
});
it('isolates a rejected all-frame sweep and still finds an embedded player', async () => {
const top = frame(0, {
href: 'https://anime.example/watch',
origin: 'https://anime.example',
bestVideo: null,
videoCount: 0,
embeddedFrames: [{
href: 'https://player.example/embed',
origin: 'https://player.example',
area: 860 * 490,
width: 860,
height: 490,
visible: true,
mediaHint: true
}]
});
const player = frame(1, {
href: 'https://player.example/embed'
});
const executeScript = vi.fn(async options => {
if (options.target?.allFrames === true) throw new Error('one frame rejected');
const frameId = options.target?.frameIds?.[0];
if (options.func?.name === 'inspectMediaFrame') {
if (frameId === 0) return [top];
if (frameId === 1) return [player];
return [];
}
return [];
});
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
47,
{ attempts: 1, probeDelayMs: 0 }
)).resolves.toMatchObject({
frameId: 1,
documentId: 'document-1',
hasVideo: true,
scriptTarget: { tabId: 47, documentIds: ['document-1'] },
monitorTargets: expect.arrayContaining([
{ tabId: 47, documentIds: ['document-0'] },
{ tabId: 47, documentIds: ['document-1'] }
])
});
});
it('sweeps individual frame IDs when an all-frame result is partial', async () => {
const top = frame(0, {
href: 'https://anime.example/watch',
origin: 'https://anime.example',
bestVideo: null,
videoCount: 0,
embeddedFrames: [{
href: 'https://player.example/embed',
origin: 'https://player.example',
area: 860 * 490,
width: 860,
height: 490,
visible: true,
mediaHint: true
}]
});
const partialFrame = frame(2, { bestVideo: null, videoCount: 0 });
const player = frame(3, { href: 'https://player.example/embed' });
const executeScript = vi.fn(async options => {
if (options.target?.allFrames === true) {
return options.func?.name === 'inspectMediaFrame' ? [top, partialFrame] : [];
}
const frameId = options.target?.frameIds?.[0];
if (options.func?.name === 'inspectMediaFrame') {
if (frameId === 0) return [top];
if (frameId === 3) return [player];
return [];
}
return [];
});
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
48,
{ attempts: 1, probeDelayMs: 0 }
)).resolves.toMatchObject({
frameId: 3,
documentId: 'document-3',
hasVideo: true,
scriptTarget: { tabId: 48, documentIds: ['document-3'] },
monitorTargets: expect.arrayContaining([
{ tabId: 48, documentIds: ['document-0'] },
{ tabId: 48, documentIds: ['document-2'] },
{ tabId: 48, documentIds: ['document-3'] }
])
scriptTarget: { tabId: 42, documentIds: ['document-8'] }
});
expect(executeScript.mock.calls[0][0].target).toEqual({ tabId: 42, allFrames: true });
});
it('does not trust parent visibility from an older probe token', () => {
@@ -538,12 +524,15 @@ describe('cross-origin media-frame targeting', () => {
)).resolves.toMatchObject({ frameId: 0, scriptTarget: { tabId: 44 } });
});
it('keeps the tab target active when equal player frames are ambiguous', 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,
@@ -551,7 +540,116 @@ describe('cross-origin media-frame targeting', () => {
)).resolves.toMatchObject({
frameId: 0,
hasVideo: false,
ambiguous: true,
scriptTarget: { tabId: 45 }
});
});
});
describe('embedded player access diagnosis', () => {
function driveTop() {
return frame(0, {
href: 'https://drive.google.com/file/d/abc/view',
origin: 'https://drive.google.com',
bestVideo: null,
videoCount: 0,
embeddedFrames: [{
href: 'https://youtube.googleapis.com/embed/abc?origin=https%3A%2F%2Fdrive.google.com',
origin: 'https://youtube.googleapis.com',
area: 640 * 360,
width: 640,
height: 360,
visible: true,
depth: 1,
mediaHint: true
}]
});
}
it('does not demand access for a player origin the extension already holds', async () => {
const executeScript = vi.fn().mockResolvedValue([driveTop()]);
const contains = vi.fn().mockResolvedValue(true);
// The player frame never answered the probe, but the grant exists. That
// is a loading race, not a user decision, so the tab stays selected on
// its top frame instead of raising a permission prompt.
await expect(resolveMediaContentTarget(
{ scripting: { executeScript }, permissions: { contains } },
42,
{ attempts: 1, probeDelayMs: 0 }
)).resolves.toEqual({
frameId: 0,
documentId: null,
frameUrl: null,
hasVideo: false,
discoveredFrameIds: [0],
scriptTarget: { tabId: 42 }
});
expect(contains).toHaveBeenCalledWith({
origins: ['https://youtube.googleapis.com/*']
});
});
it('demands access only when the browser confirms the origin is withheld', async () => {
const executeScript = vi.fn().mockResolvedValue([driveTop()]);
const contains = vi.fn().mockResolvedValue(false);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript }, permissions: { contains } },
42,
{ attempts: 1, probeDelayMs: 0 }
)).rejects.toMatchObject({
code: MEDIA_FRAME_ACCESS_REQUIRED,
host: 'youtube.googleapis.com'
});
});
it('keeps demanding access when the browser cannot answer', async () => {
const executeScript = vi.fn().mockResolvedValue([driveTop()]);
const contains = vi.fn().mockRejectedValue(new Error('unavailable'));
await expect(resolveMediaContentTarget(
{ scripting: { executeScript }, permissions: { contains } },
42,
{ attempts: 1, probeDelayMs: 0 }
)).rejects.toMatchObject({ code: MEDIA_FRAME_ACCESS_REQUIRED });
});
it('resolves instead of hanging when a frame probe never settles', async () => {
const executeScript = vi.fn()
.mockImplementationOnce(() => new Promise(() => {}))
.mockResolvedValue([frame(0, { bestVideo: null, videoCount: 0 })]);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
42,
{ attempts: 1, probeDelayMs: 0, probeTimeoutMs: 20 }
)).resolves.toMatchObject({ frameId: 0, scriptTarget: { tabId: 42 } });
});
it('prefers a real accessible player over a Drive embed', async () => {
const top = frame(0, {
href: 'https://drive.google.com/file/d/abc/view',
origin: 'https://drive.google.com',
bestVideo: video({ controls: true, duration: 2400, renderedArea: 900 * 506 }),
embeddedFrames: [{
href: 'https://youtube.googleapis.com/embed/abc?origin=https%3A%2F%2Fdrive.google.com',
origin: 'https://youtube.googleapis.com',
area: 320 * 180,
width: 320,
height: 180,
visible: true,
depth: 1,
mediaHint: true
}]
});
const executeScript = vi.fn().mockResolvedValue([top]);
const contains = vi.fn().mockResolvedValue(false);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript }, permissions: { contains } },
42,
{ attempts: 1, probeDelayMs: 0 }
)).resolves.toMatchObject({ frameId: 0, hasVideo: true });
});
});
+31 -45
View File
@@ -468,12 +468,12 @@ async function init() {
// Keep a denied selection visible while Chrome waits for the user
// to grant access; it becomes active automatically after approval.
await populateTabs(res.peers, res.targetTabId);
await populateTabs(res.peers, res.targetTabId || res.pendingTargetTabId);
// Render lobby status if active
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
if (res.status === 'connected' && normalizeTabId(res.targetTabId) === null && localData.roomId) {
if (res.status === 'connected' && !res.targetTabId && !res.pendingTargetTabId && localData.roomId) {
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
if (syncTabBtn) syncTabBtn.click();
showSelectVideoHint();
@@ -682,7 +682,7 @@ async function refreshTargetAccessState() {
}
await populateTabs(
status.peers,
status.targetTabId
status.targetTabId ?? status.pendingTargetTabId ?? null
);
}
@@ -1222,14 +1222,14 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
const blacklistDomains = getEffectiveBlacklistDomains(await readBlacklistOverrides());
const isFilterActive = data.filterNoise !== false;
let currentTargetTabId = normalizeTabId(providedTargetTabId);
let currentTargetTabId = providedTargetTabId;
if (currentTargetTabId === null) {
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
if (chrome.runtime.lastError) {
if (populateTabsToken !== token) return;
currentTargetTabId = null;
} else {
currentTargetTabId = status?.targetTabId ?? null;
currentTargetTabId = status?.targetTabId || status?.pendingTargetTabId;
}
}
@@ -1260,7 +1260,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
const filteredTabs = tabs.filter(tab => {
if (!tab.url || tab.url.startsWith('chrome://')) return false;
if (isFilterActive && currentTargetTabId !== null && tab.id !== currentTargetTabId) {
if (isFilterActive && tab.id !== parseInt(currentTargetTabId)) {
if (isUrlBlacklisted(tab.url, blacklistDomains)) return false;
}
return true;
@@ -1311,7 +1311,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
// Sort: 1. Current tab first, 2. Matches, 3. Rest alphabetically
const options = Array.from(elements.targetTab.options);
const placeholder = options.shift();
const currentTabId = currentTargetTabId;
const currentTabId = providedTargetTabId ? parseInt(providedTargetTabId) : null;
options.sort((a, b) => {
const aId = parseInt(a.value);
@@ -1331,7 +1331,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
elements.targetTab.appendChild(placeholder);
options.forEach(opt => elements.targetTab.appendChild(opt));
if (currentTargetTabId !== null) {
if (currentTargetTabId) {
elements.targetTab.value = currentTargetTabId;
} else {
const matchOpt = options.find(o => o.textContent.includes('⭐ MATCH:'));
@@ -1745,7 +1745,7 @@ if (elements.langSelector) {
} else {
hideSiteAccessNotice();
}
await populateTabs(res.peers, res.targetTabId);
await populateTabs(res.peers, res.targetTabId || res.pendingTargetTabId);
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
} else {
applyConnectionStatus('disconnected');
@@ -2094,7 +2094,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
elements.forceSyncBtn.disabled = true;
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
if (chrome.runtime.lastError || !status || status.targetReady !== true || normalizeTabId(status.targetTabId) === null) {
if (chrome.runtime.lastError || !status || !status.targetTabId) {
elements.forceSyncBtn.disabled = false;
return;
}
@@ -2160,8 +2160,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
resolve(false);
return;
}
resolve(currentStatus?.targetReady === true
&& normalizeTabId(currentStatus?.targetTabId) === tabId);
resolve(normalizeTabId(currentStatus?.targetTabId) === tabId);
});
});
@@ -2580,9 +2579,8 @@ elements.copyLogs.addEventListener('click', () => {
logs = logs || [];
history = history || [];
const targetTabId = normalizeTabId(status.targetTabId);
const videoPromise = (targetTabId !== null && status.targetReady === true)
? new Promise(resolve => chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: targetTabId }, resolve))
const videoPromise = (status && status.targetTabId)
? new Promise(resolve => chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: status.targetTabId }, resolve))
: Promise.resolve(null);
videoPromise.then(rawVideo => {
@@ -2602,11 +2600,6 @@ elements.copyLogs.addEventListener('click', () => {
lines.push(`- **User Agent:** ${userAgent}`);
lines.push('');
lines.push('## Target');
lines.push(`- **Target Tab ID:** ${targetTabId ?? 'none'}`);
lines.push(`- **Activation:** ${safe(status.targetActivationState, 'unknown')}`);
lines.push('');
// ── Tab ──
if (rawVideo) {
lines.push('## Tab');
@@ -2659,9 +2652,7 @@ elements.copyLogs.addEventListener('click', () => {
// ── Video ──
lines.push('## Video');
if (!rawVideo) {
lines.push(targetTabId !== null
? '- *Target tab selected; video communication is not ready yet*'
: '- *No tab selected / communication failed*');
lines.push('- *No tab selected / communication failed*');
} else if (!vs.found) {
lines.push('- **Found:** \u274C NO VIDEO ELEMENT');
if (vs.videoCount != null) lines.push(`- **Video Tags:** ${vs.videoCount}`);
@@ -2801,38 +2792,33 @@ function refreshDebugInfo() {
if (!devTab || devTab.style.display === 'none') return;
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
if (!res || normalizeTabId(res.targetTabId) === null) {
if (res?.targetActivationState === 'activating' || res?.targetActivationState === 'access_required') {
if (elements.videoDebug) {
elements.videoDebug.textContent = getMessage('DEBUG_TARGET_ACTIVATING');
}
return;
}
if (res?.targetActivationState === 'error') {
if (elements.videoDebug) {
elements.videoDebug.textContent = res.targetActivationError
? `Injection fehlgeschlagen: ${res.targetActivationError}`
: 'Video-Injection fehlgeschlagen.';
}
return;
}
if (!res || !res.targetTabId) {
if (elements.videoDebug) elements.videoDebug.textContent = getMessage('DEBUG_NO_TAB');
return;
}
if (res.targetReady !== true) {
if (elements.videoDebug) {
elements.videoDebug.textContent = res.targetActivationState === 'error'
? `Injection fehlgeschlagen: ${res.targetActivationError || 'unbekannter Fehler'}`
: getMessage('DEBUG_TARGET_ACTIVATING');
}
// A target that never finished activating has no content script to talk
// to. Reporting that as a communication failure hides the actual reason,
// which is the only thing that makes the problem fixable.
if (res.targetReady === false && elements.videoDebug) {
const reason = res.targetActivationError
|| (res.targetActivationState === 'access_required'
? `Website access required${res.pendingTargetHost ? ` for ${res.pendingTargetHost}` : ''}`
: null);
elements.videoDebug.textContent = reason
? `${res.targetActivationState}: ${reason}`
: `${res.targetActivationState}`;
return;
}
// Request direct state from the content script via background
chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: res.targetTabId }, (state) => {
if (!state || (!state.found && state.error)) {
if (elements.videoDebug) elements.videoDebug.textContent = getMessage('DEBUG_COMM_FAIL');
if (elements.videoDebug) {
elements.videoDebug.textContent = state?.error
? `${getMessage('DEBUG_COMM_FAIL')} (${state.error})`
: getMessage('DEBUG_COMM_FAIL');
}
return;
}
+72 -55
View File
@@ -8,14 +8,16 @@ const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'
const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8');
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
const monitorSource = fs.readFileSync(path.join(extensionDir, 'media-frame-monitor.js'), 'utf8');
const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8');
const manifest = JSON.parse(fs.readFileSync(path.join(extensionDir, 'manifest.base.json'), 'utf8'));
describe('target tab lifecycle', () => {
it('injects playback and chat scripts only into the explicitly selected tab', () => {
expect(backgroundSource).not.toContain('chrome.tabs.onActivated');
expect(backgroundSource).not.toContain('chrome.tabs.query({})');
expect(backgroundSource).toContain('contentTarget = await resolveMediaContentTarget(chrome, tabId)');
expect(backgroundSource).toMatch(/contentTarget = await resolveMediaContentTarget\(chrome, tabId, \{\s*knownFrameIds/);
// Frame ids must come from observed senders, never from a navigation permission.
expect(backgroundSource).toContain('function rememberFrameId(tabId, frameId)');
expect(backgroundSource).toContain('rememberFrameId(senderTabId, sender?.frameId)');
expect(backgroundSource).toContain('target: scriptTarget');
expect(backgroundSource).toContain("files: ['chat-format.js', 'chat-overlay.js', 'content.js']");
expect(backgroundSource).toContain("chrome.tabs.query({ url: 'https://sync.koalastuff.net/*' })");
@@ -23,23 +25,74 @@ describe('target tab lifecycle', () => {
const activationStart = backgroundSource.indexOf('async function activateTargetTab');
const activationEnd = backgroundSource.indexOf('async function reactivateCurrentTarget', activationStart);
const activationSource = backgroundSource.slice(activationStart, activationEnd);
expect(activationSource.indexOf('await injectContentScript(selectedTabId'))
.toBeLessThan(activationSource.indexOf('await deactivateTargetTab(previousTabId, previousContentTarget)'));
expect(activationSource.indexOf('await deactivateTargetTab(previousTabId)'))
.toBeLessThan(activationSource.indexOf('await injectContentScript(selectedTabId'));
expect(activationSource).toContain('previousTabId !== selectedTabId');
expect(activationSource).toContain('keeping tab ${previousTabId} selected');
expect(contentSource).toContain('if (window.koalaSyncInjected && chrome.runtime.id)');
expect(overlaySource).toContain('if (window.koalaSyncChatOverlay?.refresh)');
});
it('keeps the chat overlay in the top document when the player is nested', () => {
expect(backgroundSource).toContain('function sendMessageToChatOverlay(message)');
expect(backgroundSource).toContain('return sendMessageToFrame(tabId, 0, message)');
// Every chat-facing message must reach the overlay's frame, not the
// player's. A stray sendMessageToCurrentContent here renders the chat
// inside the video on Drive.
expect(backgroundSource).not.toMatch(/sendMessageToCurrentContent\(\{\s*type: 'CHAT/);
expect(backgroundSource).toMatch(
/target: \{ tabId, frameIds: \[0\] \},\s*files: \['chat-format\.js', 'chat-overlay\.js'\]/
);
expect(backgroundSource).toContain("files: ['content.js']");
expect(backgroundSource).toContain('if (normalizeFrameId(target.frameId) !== 0)');
});
it('does not reactivate the target for ordinary playback churn', () => {
expect(backgroundSource).toContain('async function selectedMediaTargetMoved(tabId)');
expect(backgroundSource).toContain('onlyIfTargetMoved = true');
// Forcing a rebuild must stay rare and deliberate: an unreachable content
// script, an explicit request, and a completed navigation. Everything else
// takes the guarded path by default.
expect(backgroundSource.match(/onlyIfTargetMoved: false/g)?.length).toBe(3);
// Playback state must stay out of the candidate signature, otherwise
// every play/pause looks like a frame layout change.
expect(monitorSource).not.toContain('element.paused ? 0 : 1');
expect(monitorSource).toContain('element.readyState > 0 ? 1 : 0');
});
it('keeps the frame registry bounded and free of a navigation permission', () => {
expect(backgroundSource).toContain('const MAX_KNOWN_FRAMES_PER_TAB = 24');
// Frame ids are learned, never enumerated through a permission.
expect(backgroundSource).toContain('function rememberFrameId(tabId, frameId)');
expect(backgroundSource).toContain('rememberFrameId(senderTabId, sender?.frameId)');
expect(backgroundSource).toContain('contentTarget.discoveredFrameIds');
// The registry must never be cleared on navigation: tabs.onUpdated
// reports 'loading' for same-document History API navigations too, which
// is exactly when these players are built. It self-corrects instead.
expect(backgroundSource).toContain('function refreshFrameIds(tabId, frameIds)');
expect(backgroundSource).not.toMatch(/changeInfo\.status === 'loading'[\s\S]{0,160}forgetFrameIds/);
expect(backgroundSource).toContain('refreshFrameIds(tabId, contentTarget.discoveredFrameIds)');
expect(backgroundSource).not.toMatch(/chrome\.webNavigation/);
});
it('bounds every frame probe and verifies withheld origins', () => {
const resolverSource = fs.readFileSync(
path.join(extensionDir, 'media-frame-target.js'),
'utf8'
);
expect(resolverSource).toContain('async function originAccessIsWithheld(chromeApi, originPattern)');
expect(resolverSource).toContain('probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS');
expect(resolverSource).toContain('attempts = 8');
expect(resolverSource).toContain('deadlineMs = 12000');
// A swallowed probe error is what turned a slow player frame into a
// permission prompt for an origin the extension already held.
expect(resolverSource).toContain('errors.push({ target, error })');
});
it('fully deactivates old and superseded target injections', () => {
expect(backgroundSource).toContain("{ type: 'TARGET_DEACTIVATE' }");
expect(backgroundSource).toContain('target.documentId');
expect(backgroundSource).toContain('await resetAudioProcessingInTab(normalizedTabId, target);');
expect(backgroundSource).toContain("{ action: 'RESET_AUDIO_PROCESSING' }");
expect(backgroundSource.match(/await deactivateTargetTab\(selectedTabId,/g)?.length).toBeGreaterThanOrEqual(6);
expect(contentSource).toContain("if (message.type === 'TARGET_DEACTIVATE')");
expect(contentSource).toContain('destroyContentScript({ preserveAudioRoute: true });');
expect(contentSource).toContain('window.__koalaSyncAudioRoute');
expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'");
});
@@ -52,11 +105,15 @@ describe('target tab lifecycle', () => {
it('uses all-frame probing for cross-origin targets without navigation permissions', () => {
expect(backgroundSource).toContain("files: ['media-frame-monitor.js']");
expect(backgroundSource).toContain('...listMediaFrameScriptTargets(tabId)');
// Monitors must reach the frames we know about, not only whatever the
// all-frames sweep happens to accept — both on the way in and out.
expect(backgroundSource.match(/\.\.\.listMediaFrameScriptTargets\(tabId\),/g)?.length).toBe(2);
expect(backgroundSource.match(/\.\.\.listKnownFrameIds\(tabId\)/g)?.length).toBe(2);
expect(backgroundSource).toContain('One denied widget frame must not block the selected player');
expect(backgroundSource).toContain("navigationError.code = 'media_target_navigated'");
expect(backgroundSource).toContain('async function deactivateMediaFrameMonitors(tabId, contentTarget');
expect(backgroundSource).toContain('func: deactivateMediaFrameMonitor');
expect(backgroundSource).toContain("{ type: 'MEDIA_MONITOR_DEACTIVATE' }");
expect(backgroundSource).toContain('async function deactivateMediaFrameMonitors(tabId)');
expect(backgroundSource).toContain('{ documentId }');
expect(monitorSource).toContain("type: 'MEDIA_FRAME_CANDIDATE_CHANGED'");
expect(monitorSource).toContain("attributeFilter: ['class', 'style', 'hidden', 'src', 'controls']");
expect(monitorSource).toContain('if (!force && nextSignature === lastCandidateSignature) return');
@@ -73,49 +130,6 @@ describe('target tab lifecycle', () => {
expect(backgroundSource).not.toMatch(/chrome\.(?:web)?Navigation/);
});
it('keeps the selected frame recoverable when an all-frame sweep is rejected', () => {
expect(backgroundSource).toContain('contentTarget?.scriptTarget');
expect(backgroundSource).toContain('...(contentTarget?.monitorTargets || [])');
expect(backgroundSource).toContain('function uniqueScriptTargets(targets)');
expect(backgroundSource).toContain('function deactivateMediaFrameMonitor()');
expect(backgroundSource).toContain('func: deactivateMediaFrameMonitor');
expect(backgroundSource).toContain('isMissingContentReceiverError(error)');
expect(backgroundSource).toContain('await refreshCurrentMediaTarget(tabId, { queueIfRunning: true })');
expect(backgroundSource).toContain("activation?.status === 'activation_in_progress'");
expect(backgroundSource).not.toContain('Media frame probe fell back to the top frame');
});
it('does not discard a selected tab when its media frame refresh is transiently unavailable', () => {
const refreshFailureGuard = backgroundSource.slice(
backgroundSource.indexOf('const isCurrentTargetRefresh'),
backgroundSource.indexOf('currentTabId = null', backgroundSource.indexOf('const isCurrentTargetRefresh'))
);
expect(refreshFailureGuard).toContain('keeping the selected target for recovery');
expect(refreshFailureGuard).not.toContain('currentTabId = null');
const routeSource = backgroundSource.slice(
backgroundSource.indexOf('async function _routeToContentInternal'),
backgroundSource.indexOf('// --- Keep-Alive Mechanism ---')
);
expect(routeSource).toContain('keeping the selected target for recovery');
expect(routeSource).not.toContain('clearTargetTabForIdle(tabId, targetGeneration)');
});
it('persists the user target while dynamic-frame activation is still retrying', () => {
expect(backgroundSource).toContain('let requestedTargetTabId = null;');
expect(backgroundSource).toContain('let pendingRequestedActivationCount = 0;');
expect(backgroundSource).toContain('await rememberRequestedTarget(selectedTabId, message.tabTitle);');
expect(backgroundSource).toContain('pendingRequestedActivationCount > 0');
expect(backgroundSource).toContain('await retryRequestedTarget();');
expect(backgroundSource).toContain('targetTabId,');
expect(backgroundSource).toContain('targetReady');
expect(backgroundSource).toContain("targetActivationState");
expect(backgroundSource).toContain('await clearRequestedTarget(selectedTabId);');
expect(popupSource).not.toContain('getSelectedTargetTabId');
expect(popupSource).toContain('await populateTabs(res.peers, res.targetTabId);');
expect(popupSource).toContain('res.targetReady !== true');
});
it('serializes content commands and coalesces target refreshes', () => {
expect(backgroundSource).toContain('contentCommandQueue.catch(() => {}).then(deliver)');
expect(backgroundSource).toContain('if (mediaTargetRefreshTask && mediaTargetRefreshTabId === selectedTabId)');
@@ -128,6 +142,9 @@ describe('target tab lifecycle', () => {
it('tears down every persistent content-script resource', () => {
expect(contentSource).toContain('function destroyContentScript({ preserveAudioRoute = false } = {})');
// Deselecting a tab hands the page back to itself; it must not go mute.
expect(contentSource).toContain('destroyContentScript({ preserveAudioRoute: true });');
expect(contentSource).toContain('if (!preserveAudioRoute) closeAudioContext();');
expect(contentSource).toContain('observer.disconnect()');
expect(contentSource).toContain('keepAlivePort.disconnect()');
expect(contentSource).toContain('for (const video of [...attachedVideos]) detachVideoListeners(video);');
+391
View File
@@ -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();
@@ -607,6 +638,366 @@ test('rejects a hidden cross-origin player after its iframe URL redirects', asyn
expect(await redirectedFrame.locator('video').getAttribute('data-koala-attached')).toBeNull();
});
test('selects the visible anime player nested behind a same-origin wrapper', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/yummy-style-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', hasVideo: true });
expect(response.frameId).not.toBe(0);
// The playable element is the one inside the visible wrapper. The two
// zero-sized mirrors must be ignored, not treated as equal candidates.
const playerFrame = suffix => page.frames()
.find(frame => frame.url().endsWith(`/frames/${suffix}`));
await expect
.poll(() => playerFrame('player-frame.html').locator('video').getAttribute('data-koala-attached'))
.toBe('true');
expect(await playerFrame('player-frame-2.html').locator('video')
.getAttribute('data-koala-attached')).toBeNull();
// And the selection has to settle, not keep re-resolving.
await page.waitForTimeout(1500);
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
expect(status).toMatchObject({
targetTabId: tabId,
targetReady: true,
targetActivationState: 'ready'
});
});
test('selects an anime tab before playback and promotes the player once it appears', async ({ context, extensionId, baseURL }) => {
// The live case: at selection time the page has no video anywhere, because
// the host only builds the player when the viewer presses play.
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);
// Selecting must succeed and settle even with nothing to control yet.
expect(response).toMatchObject({ status: 'ok', frameId: 0, hasVideo: false });
await page.waitForTimeout(1200);
const idle = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
expect(idle).toMatchObject({
targetTabId: tabId,
targetReady: true,
targetActivationState: 'ready'
});
const deferred = page.frames().find(frame => frame.url().endsWith('/frames/deferred-player-frame.html'));
await deferred.locator('#poster').click();
// The monitor has to hand the target over to the frame that now owns the
// video, without the user touching the popup again.
await expect
.poll(() => deferred.locator('video').getAttribute('data-koala-attached'), { timeout: 15000 })
.toBe('true');
await expect
.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })
.then(state => ({ ready: state.targetReady, frame: state.targetFrameId })), { timeout: 15000 })
.toMatchObject({ ready: true });
const promoted = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
expect(promoted.targetFrameId).not.toBe(0);
expect(promoted).toMatchObject({ targetTabId: tabId, targetActivationState: 'ready' });
});
test('polling video state on a page with no video does not restart the target', async ({ context, extensionId, baseURL }) => {
// The dev panel polls GET_VIDEO_STATE on a timer. On an anime page the
// answer is legitimately "no video" until playback starts, and treating
// that as a broken injection reactivated the target on every poll — which
// is what pinned the popup on "activating" forever.
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 } = await selectTargetTab(context, extensionId, url);
for (let poll = 0; poll < 6; poll++) {
const state = await getExtensionState(context, extensionId, { type: 'GET_VIDEO_STATE', tabId });
expect(state?.error, 'reading video state must not report a target change').toBeFalsy();
expect(state).toMatchObject({ found: false });
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
expect(status, `poll ${poll} must leave the target ready`).toMatchObject({
targetTabId: tabId,
targetReady: true,
targetActivationState: 'ready'
});
}
// And the player is still picked up once it exists.
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').getAttribute('data-koala-attached'), { timeout: 15000 })
.toBe('true');
});
test('stays ready on a page whose ad frames keep mutating', async ({ context, extensionId, baseURL }) => {
test.setTimeout(90000);
// Live ad churn wakes the media-frame monitor several times a second. Each
// wake used to schedule a trailing refresh that rebuilt the target
// unconditionally, and rebuilding produced more churn — a loop that never
// let the activation settle and pinned the popup on "activating".
const url = `${baseURL}/pages/yummy-churning-player.html`;
const page = await context.newPage();
await page.goto(url);
await page.waitForFunction(() => window.__fixtureReady === true);
const { tabId } = await selectTargetTab(context, extensionId, url);
for (let sample = 0; sample < 8; sample++) {
await page.waitForTimeout(700);
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
expect(status, `sample ${sample} must not be stuck activating`).toMatchObject({
targetTabId: tabId,
targetReady: true,
targetActivationState: 'ready'
});
}
// The player still has to be picked up while the churn continues.
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').getAttribute('data-koala-attached'), { timeout: 20000 })
.toBe('true');
await expect
.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })
.then(state => state.targetActivationState), { timeout: 20000 })
.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('recovers when the adopted player frame is torn down and rebuilt', async ({ context, extensionId, baseURL }) => {
test.setTimeout(90000);
// Kodik rebuilds its player frame on quality and part changes, which kills
// the documentId the election is pinned to. The election has to be given up,
// otherwise every later message fails with "Receiving end does not exist"
// and nothing moves the target back.
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 } = await selectTargetTab(context, extensionId, url);
// After the rebuild both the detached and the live frame carry the same URL,
// so take the most recent attached one or the test drives a dead document.
const deferredFrame = () => page.frames()
.filter(frame => !frame.isDetached()
&& frame.url().endsWith('/frames/deferred-player-frame.html'))
.pop();
await deferredFrame().locator('#poster').click();
await expect
.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })
.then(state => state.targetHasVideo), { timeout: 20000 })
.toBe(true);
const adoptedFrameId = (await getExtensionState(context, extensionId, { type: 'GET_STATUS' })).targetFrameId;
expect(adoptedFrameId).not.toBe(0);
// Destroy the elected document the way the real player does.
const wrapper = page.frames().find(frame => frame.url().includes('xfp-wrapper.html?player=deferred'));
await wrapper.evaluate(() => {
const inner = document.getElementById('inner');
inner.src = inner.src;
});
await expect.poll(() => deferredFrame()?.locator('#poster').count().catch(() => 0)).toBe(1);
// The dead election must be released rather than kept forever.
await expect
.poll(() => getExtensionState(context, extensionId, { type: 'GET_VIDEO_STATE', tabId })
.then(state => state?.error || null), { timeout: 20000 })
.toBeNull();
const released = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
expect(released).toMatchObject({ targetTabId: tabId, targetHasVideo: false });
// And the rebuilt player is picked up again without touching the popup. The
// rebuilt document wires its poster from an inline script, so a click can
// land before the handler exists — retry until the player is really built.
await expect.poll(async () => {
const frame = deferredFrame();
if (!frame) return 0;
if (await frame.locator('video').count() > 0) return 1;
await frame.locator('#poster').click({ timeout: 2000 }).catch(() => {});
return 0;
}, { timeout: 20000 }).toBe(1);
await expect
.poll(() => deferredFrame().locator('video').getAttribute('data-koala-attached'), { timeout: 20000 })
.toBe('true');
await expect
.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })
.then(state => state.targetHasVideo), { timeout: 20000 })
.toBe(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
// popup come back empty after it was closed and reopened.
const page = await context.newPage();
await page.goto('chrome://version');
const { tabId, response } = await selectTargetTab(context, extensionId, 'chrome://version/*');
expect(response?.status).not.toBe('ok');
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
expect(status).toMatchObject({
targetTabId: tabId,
targetReady: false,
targetActivationState: 'error'
});
expect(status.targetActivationError).toBeTruthy();
// Reopening the popup must not quietly retry and must not lose the choice.
const second = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
expect(second).toMatchObject({ targetTabId: tabId, targetActivationState: 'error' });
});
test('drops the selection only when the user clears it', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/simple-player.html`;
const page = await context.newPage();
await page.goto(url);
await page.waitForFunction(() => window.__fixtureReady === true);
const { tabId } = await selectTargetTab(context, extensionId, url);
await expect
.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })
.then(state => state.targetTabId))
.toBe(tabId);
const cleared = await getExtensionState(context, extensionId, {
type: 'SET_TARGET_TAB',
tabId: null
});
expect(cleared).toMatchObject({ status: 'ok', tabId: null });
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
expect(status).toMatchObject({
targetTabId: null,
targetReady: false,
targetActivationState: 'none'
});
});
/**
* Reads one global from the top document and from the player frame separately,
* so a test can prove which frame a script was installed in.
*/
async function readPerFrameGlobal(context, extensionId, pageUrl, globalName) {
return withExtensionPage(context, extensionId, page => page.evaluate(async ({ pageUrl, globalName }) => {
const [tab] = await chrome.tabs.query({ url: pageUrl });
if (!tab) throw new Error(`no tab matched ${pageUrl}`);
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id, allFrames: true },
func: name => ({
href: location.href,
isTop: window.top === window,
present: typeof window[name] !== 'undefined' && window[name] !== null
}),
args: [globalName]
});
const entries = results.map(entry => entry.result).filter(Boolean);
return {
top: entries.find(entry => entry.isTop)?.present ?? null,
player: entries.find(entry => !entry.isTop && entry.href.includes('player-frame'))?.present ?? null
};
}, { pageUrl, globalName }));
}
test('controls a Drive-style cross-origin player without moving the chat into it', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/drive-style-player.html`;
const page = await context.newPage();
await page.goto(url);
await page.waitForFunction(() => window.__fixtureReady === true);
const { response } = await selectTargetTab(context, extensionId, url);
// The top document hosts no video, so the target must be the player frame.
expect(response).toMatchObject({ status: 'ok', hasVideo: true });
expect(response.frameId).not.toBe(0);
const playerFrame = page.frames().find(frame => frame.url().includes('player-frame'));
await expect
.poll(() => playerFrame.locator('video').getAttribute('data-koala-attached'))
.toBe('true');
// The controller belongs in the player frame...
await expect
.poll(() => readPerFrameGlobal(context, extensionId, url, 'koalaSyncInjected'))
.toMatchObject({ player: true });
// ...and the chat overlay belongs in the top document, never inside the
// video. Installing it in the player frame is what rendered the chat on top
// of the picture and made closing it affect only that frame.
await expect
.poll(() => readPerFrameGlobal(context, extensionId, url, 'koalaSyncChatOverlay'))
.toMatchObject({ top: true, player: false });
});
test('keeps controlling a Drive-style player across an ordinary play and pause', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/drive-style-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' });
const selectedFrameId = response.frameId;
const playerFrame = page.frames().find(frame => frame.url().includes('player-frame'));
await playerFrame.locator('video').evaluate(video => video.play());
await playerFrame.locator('video').evaluate(video => video.pause());
await page.waitForTimeout(750);
// Playback state changes are not frame layout changes. If they were treated
// as such, the target would be torn down and re-injected mid-playback.
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
expect(status).toMatchObject({
targetTabId: tabId,
targetReady: true,
targetActivationState: 'ready',
targetFrameId: selectedFrameId
});
const command = await sendServerCommand(context, extensionId, tabId, 'play', { time: 1 });
expect(command).toMatchObject({ status: 'ok_solo' });
await expect.poll(() => playerFrame.locator('video').evaluate(video => video.paused)).toBe(false);
});
function FRAMED_VIDEO_PAUSED() {
return document.querySelector('iframe').contentDocument.querySelector('video').paused;
}
@@ -0,0 +1,23 @@
<!doctype html>
<meta charset="utf-8">
<title>Drive-style embedded player</title>
<style>
body { margin: 0; font: 14px sans-serif; }
#shell { padding: 24px; }
iframe { border: 0; }
</style>
<!--
Mirrors the Google Drive / YummyAnime layout: the top document owns the page
chrome and hosts no video at all, while the only player lives in a visible
cross-origin iframe. Playback control belongs in that frame; the chat overlay
must stay in this document.
-->
<div id="shell">
<h1>Shared file</h1>
<iframe id="player-frame" width="854" height="480" allowfullscreen></iframe>
</div>
<script>
const frame = document.getElementById('player-frame');
frame.src = `http://127.0.0.1:${location.port}/pages/frames/player-frame.html`;
frame.addEventListener('load', () => { window.__fixtureReady = true; }, { once: true });
</script>
@@ -0,0 +1,27 @@
<!doctype html>
<meta charset="utf-8">
<title>Deferred player frame</title>
<style>
body { margin: 0; background: #111; }
#poster { width: 854px; height: 480px; color: #fff; font: 16px sans-serif;
display: flex; align-items: center; justify-content: center; cursor: pointer; }
</style>
<!--
The shape anime hosts actually ship: a poster with a play overlay, and no
<video> in the document at all until the viewer starts playback. Selecting the
tab before that point must still work, and the real player has to be picked up
the moment it appears.
-->
<div id="poster">Play</div>
<script>
document.getElementById('poster').addEventListener('click', () => {
const video = document.createElement('video');
video.id = 'deferred-player';
video.width = 854;
video.height = 480;
video.controls = true;
video.src = '../../media/player-480p-12s.mp4';
document.body.append(video);
document.getElementById('poster').remove();
}, { once: true });
</script>
@@ -0,0 +1,19 @@
<!doctype html>
<meta charset="utf-8">
<title>xfp wrapper</title>
<style>
html, body { margin: 0; height: 100%; }
iframe { border: 0; display: block; }
</style>
<!--
Same-origin shell the anime site puts between the page and the real player.
It holds no video itself; the playable element lives one more level down in a
cross-origin frame.
-->
<iframe id="inner" width="830" height="498" allowfullscreen></iframe>
<script>
const inner = document.getElementById('inner');
const params = new URLSearchParams(location.search);
const target = params.get('player') || 'player-frame.html';
inner.src = `http://127.0.0.1:${location.port}/pages/frames/${target}`;
</script>
@@ -0,0 +1,43 @@
<!doctype html>
<meta charset="utf-8">
<title>Anime page with live ad churn</title>
<style>
body { margin: 0; font: 14px sans-serif; }
#info { height: 900px; padding: 24px; }
iframe { border: 0; }
#visible-wrapper { display: block; }
#hidden-wrapper { width: 0; height: 0; display: block; }
#ads iframe { width: 300px; height: 60px; display: block; }
</style>
<!--
The anime layout plus what the real site actually does while you look at it:
ad slots that add, remove and resize frames continuously. Every one of those
mutations wakes the media-frame monitor, so this is the fixture that exposes a
reactivation loop — a static page never will.
-->
<div id="info">
<h1>Series page</h1>
<div id="ads"></div>
</div>
<iframe id="visible-wrapper" name="xfplayer_visible" width="830" height="498"
src="frames/xfp-wrapper.html?player=deferred-player-frame.html" allowfullscreen></iframe>
<iframe id="hidden-wrapper" name="xfplayer_hidden"
src="frames/xfp-wrapper.html?player=player-frame-2.html" allowfullscreen></iframe>
<script>
const ads = document.getElementById('ads');
let tick = 0;
setInterval(() => {
tick++;
const stale = ads.firstElementChild;
if (stale && tick % 2 === 0) stale.remove();
const slot = document.createElement('iframe');
slot.src = `about:blank#ad-${tick}`;
slot.style.height = `${40 + (tick % 5) * 8}px`;
ads.append(slot);
}, 150);
const wrappers = [...document.querySelectorAll('#visible-wrapper, #hidden-wrapper')];
Promise.all(wrappers.map(f => new Promise(resolve => {
f.addEventListener('load', resolve, { once: true });
}))).then(() => { window.__fixtureReady = true; });
</script>
@@ -0,0 +1,29 @@
<!doctype html>
<meta charset="utf-8">
<title>Anime-style player built on demand</title>
<style>
body { margin: 0; font: 14px sans-serif; }
#info { height: 900px; padding: 24px; }
iframe { border: 0; }
#visible-wrapper { display: block; }
#hidden-wrapper { width: 0; height: 0; display: block; }
</style>
<!--
Same layout as yummy-style-player.html, except the visible player builds its
<video> only when the viewer presses play. Until then the tab has no video at
all, which is the state the extension is in when the user picks the tab.
-->
<div id="info">
<h1>Series page</h1>
<p>Description block that pushes the player below the fold.</p>
</div>
<iframe id="visible-wrapper" name="xfplayer_visible" width="830" height="498"
src="frames/xfp-wrapper.html?player=deferred-player-frame.html" allowfullscreen></iframe>
<iframe id="hidden-wrapper" name="xfplayer_hidden"
src="frames/xfp-wrapper.html?player=player-frame-2.html" allowfullscreen></iframe>
<script>
const frames = [...document.querySelectorAll('iframe')];
Promise.all(frames.map(f => new Promise(resolve => {
f.addEventListener('load', resolve, { once: true });
}))).then(() => { window.__fixtureReady = true; });
</script>
@@ -0,0 +1,36 @@
<!doctype html>
<meta charset="utf-8">
<title>Anime-style nested player</title>
<style>
body { margin: 0; font: 14px sans-serif; }
#info { height: 900px; padding: 24px; }
iframe { border: 0; }
#visible-wrapper { display: block; }
#hidden-wrapper, #hidden-trailer { width: 0; height: 0; display: block; }
</style>
<!--
Mirrors the live yummyanime.tv layout:
top (no video)
├── visible same-origin wrapper 830x498 -> cross-origin player (the real one)
├── hidden same-origin wrapper 0x0 -> cross-origin mirror
└── hidden cross-origin trailer 0x0
The player sits two levels down and below the fold, and two other players are
loaded at zero size. Picking the visible one is the whole job.
-->
<div id="info">
<h1>Series page</h1>
<p>Description block that pushes the player below the fold.</p>
</div>
<iframe id="visible-wrapper" name="xfplayer_visible" width="830" height="498"
src="frames/xfp-wrapper.html?player=player-frame.html" allowfullscreen></iframe>
<iframe id="hidden-wrapper" name="xfplayer_hidden"
src="frames/xfp-wrapper.html?player=player-frame-2.html" allowfullscreen></iframe>
<iframe id="hidden-trailer" allowfullscreen></iframe>
<script>
const trailer = document.getElementById('hidden-trailer');
trailer.src = `http://127.0.0.1:${location.port}/pages/frames/late-player-frame.html`;
const frames = [...document.querySelectorAll('iframe')];
Promise.all(frames.map(f => new Promise(resolve => {
f.addEventListener('load', resolve, { once: true });
}))).then(() => { window.__fixtureReady = true; });
</script>