mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-19 07:36:16 +00:00
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:
@@ -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.
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 l’injection 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",
|
||||
|
||||
@@ -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 dell’iniezione video.",
|
||||
"DEBUG_COMM_FAIL": "Errore di comunicazione con il video.",
|
||||
"EMPTY_PEERS_TITLE": "Nessun partecipante",
|
||||
"EMPTY_PEERS_HINT": "Condividi il tuo link per iniziare",
|
||||
|
||||
@@ -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": "招待リンクを共有して始めましょう",
|
||||
|
||||
@@ -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": "시작하려면 초대 링크를 공유하세요",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ąć",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Поделитесь ссылкой-приглашением, чтобы начать",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Поділіться своїм запрошенням, щоб почати",
|
||||
|
||||
@@ -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": "分享您的邀请链接以开始使用",
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);');
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user