mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-18 07:13:12 +00:00
fix(extension): support cross-origin media frames
This commit is contained in:
@@ -103,19 +103,23 @@ jobs:
|
||||
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
|
||||
echo " ✓ package.json -> $VERSION"
|
||||
|
||||
# 4. website/version.json
|
||||
# 4. package-lock.json root package metadata
|
||||
jq --arg v "$VERSION" '.version = $v | .packages[""].version = $v' package-lock.json > tmp.json && mv tmp.json package-lock.json
|
||||
echo " ✓ package-lock.json -> $VERSION"
|
||||
|
||||
# 5. website/version.json
|
||||
jq -n --arg v "$VERSION" --arg d "$DATE" '{version: $v, date: $d}' > website/version.json
|
||||
echo " ✓ website/version.json -> version $VERSION, date $DATE"
|
||||
|
||||
# 5. website/template.html — SoftwareApplication schema
|
||||
# 6. website/template.html — SoftwareApplication schema
|
||||
sed -i "s/\"softwareVersion\": \".*\"/\"softwareVersion\": \"$VERSION\"/" website/template.html
|
||||
echo " ✓ website/template.html -> softwareVersion $VERSION"
|
||||
|
||||
# 6. website/llms.txt — machine-readable release metadata
|
||||
# 7. website/llms.txt — machine-readable release metadata
|
||||
sed -i "s/Current website release: .*/Current website release: $VERSION/" website/llms.txt
|
||||
echo " ✓ website/llms.txt -> $VERSION"
|
||||
|
||||
# 7. README.md — version badge & banner
|
||||
# 8. README.md — version badge & banner
|
||||
sed -i "s|Release-v[0-9]\+\.[0-9]\+\.[0-9]\+-blue|Release-v$VERSION-blue|g" README.md
|
||||
sed -i "s/New v[0-9]\+\.[0-9]\+\.[0-9]\+ Release/New v$VERSION Release/g" README.md
|
||||
echo " ✓ README.md -> v$VERSION"
|
||||
@@ -126,7 +130,7 @@ jobs:
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html website/llms.txt README.md
|
||||
git add extension/manifest.base.json shared/constants.js package.json package-lock.json website/version.json website/template.html website/llms.txt README.md
|
||||
git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit"
|
||||
git push origin HEAD:main
|
||||
env:
|
||||
|
||||
@@ -4,6 +4,27 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [v3.1.2] — 2026-08-17
|
||||
|
||||
This release adds generic control for HTML5 players inside cross-origin frames.
|
||||
It restores the intended Google Drive support, whose earlier workaround was
|
||||
developed on a separate branch but never merged into `main`, and covers nested
|
||||
external players such as YummyAnime without a site-specific host list.
|
||||
|
||||
### Added
|
||||
- **Extension: Cross-origin player targeting** — Probes every accessible frame, elects the visible real player from video readiness, source, rendering, playback, controls, size and background-video signals, then injects and routes playback, force sync, heartbeat, chat and audio processing to that exact frame and document.
|
||||
- **Extension: Embedded-player recovery** — Re-elects the target when a player frame becomes hidden, loses its video, reloads or is replaced by another frame. Equally ranked frames without visibility evidence are rejected instead of controlling an arbitrary preload or ad.
|
||||
- **Testing: Cross-origin lifecycle E2E coverage** — Drives the packed Chromium extension through ordered play, pause and seek in a two-level external player; verifies hidden duplicate rejection, CSS visibility switches without a remote-command trigger, selected-frame document reloads and videos inserted late inside child frames.
|
||||
|
||||
### Fixed
|
||||
- **Extension: Google Drive playback** — Detects Drive's current visible `youtube.googleapis.com/embed` player and keeps Drive's top-page title, URL and platform identity in debug output while controlling the embedded video.
|
||||
- **Extension: External anime players** — Supports same-origin wrappers whose real player is hosted on a different origin, including the current YummyAnime structure with a visible external player and hidden duplicate frames.
|
||||
- **Extension: Frame-specific message routing** — Sends remote commands, state reads, audio settings, host-control feedback, episode-lobby events and teardown only to the selected frame/document instead of assuming the top frame.
|
||||
- **Extension: Force Sync in embedded players** — Reads the current time through the background's selected-frame route, so Jump to Me no longer queries only the top document.
|
||||
|
||||
### Changed
|
||||
- **Extension: Debug frame context** — Reports the selected frame ID and origin while preserving the selected tab's top-level URL and title. Google Drive is identified as Google Drive instead of the embedded YouTube API host.
|
||||
|
||||
## [v3.1.1] — 2026-08-15
|
||||
|
||||
A single fix: the popup could get stuck at double width for the rest of a
|
||||
|
||||
+11
-8
@@ -49,14 +49,6 @@
|
||||
|
||||
*Ideas and feature requests under evaluation.*
|
||||
|
||||
### Cross-origin frame video detection and control
|
||||
|
||||
- **Priority:** P3
|
||||
- **Category:** Compatibility / Embedded Players
|
||||
- **Background:** KoalaSync injects on demand into the selected tab's top frame. Since the same-origin frame walk shipped, the top-frame script also reaches players inside first-party iframes (`jkanime.net`-style `/jkplayer/` frames, `srcdoc` and `about:blank` frames that inherit the parent origin). What remains uncovered is the real `<video>` living inside a **cross-origin** iframe, where `contentDocument` is unreachable by design.
|
||||
- **Possible approach:** Add an opt-in frame bridge (`allFrames: true` injection) where child frames announce detected videos to the top frame, and the top frame routes remote play/pause/seek commands to the active child video. Needs a frame-election rule so ad frames cannot claim the session.
|
||||
- **Status:** Same-origin part completed; cross-origin frame bridge still open. Not needed for current Emby behavior.
|
||||
|
||||
### Sticky player selection
|
||||
|
||||
- **Priority:** P3
|
||||
@@ -90,6 +82,17 @@
|
||||
|
||||
---
|
||||
|
||||
## ✅ Completed
|
||||
|
||||
### Cross-origin frame video detection and control
|
||||
|
||||
- **Priority:** P3
|
||||
- **Category:** Compatibility / Embedded Players
|
||||
- **Completed:** v3.1.2
|
||||
- **Outcome:** The background probes accessible frames, elects the visible HTML5 player without trusting child-frame claims, and routes injection, ordered commands, state, chat, audio and teardown to the selected document. Hidden equal candidates without visibility evidence are rejected. A tab-wide frame sentinel plus subframe-navigation events recover CSS visibility switches, document reloads, lazy video insertion and failed delivery. Google Drive and YummyAnime-style nested external players are covered by live topology inspection plus packed-Chromium E2E fixtures; live two-peer service runs remain tracked separately.
|
||||
|
||||
---
|
||||
|
||||
## ❌ Rejected
|
||||
|
||||
*Declined features with rationale — keeps decisions documented so they don't get re-debated.*
|
||||
|
||||
@@ -26,6 +26,8 @@ This document tracks which streaming platforms and media servers are supported b
|
||||
| **ARD / ZDF Mediathek** | Not tested | Not tested | Not tested | — | — | — | — |
|
||||
| **Vix** | ✅ Full | ✅ Full | ✅ Full | — | — | — | Everything works correctly. |
|
||||
| **JkAnime** | ✅ Full | ❌ | ❌ | 2026-08-14 | Shik3i | v3.1.0 | Player sits in a same-origin `/jkplayer/` iframe, so it needs the same-origin frame walk added in v3.1.0. No MediaSession metadata is exposed, and the page title carries no episode pattern (`… Futari 18 Sub Español …`). |
|
||||
| **Google Drive** | ⚠️ Partial | ✅ Full | ❌ N/A | 2026-08-17 | Shik3i / Codex | v3.1.2 | Live inspection confirms the visible `youtube.googleapis.com/embed` child topology; packed-Chromium fixtures cover exact-document control and recovery. A live two-peer Drive relay run is still pending. |
|
||||
| **YummyAnime** | ⚠️ Partial | ⚠️ Partial | ❌ | 2026-08-17 | Shik3i / Codex | v3.1.2 | Live inspection confirms the same-origin wrapper plus external `thealloha.club` player topology; packed-Chromium fixtures cover nested control and recovery. A live two-peer site run is still pending, and the page title lacks the selected episode. |
|
||||
|
||||
### Legend
|
||||
|
||||
@@ -71,4 +73,4 @@ Websites with heavily obfuscated custom players may require platform-specific wo
|
||||
|
||||
Since v3.1.0 the content script walks **same-origin** frames, so a player wrapped in the site's own iframe is found and controlled without a site-specific workaround. This also covers `srcdoc` and `about:blank` frames, which inherit the parent origin.
|
||||
|
||||
Frames on a **different origin** remain out of reach, because the browser blocks `contentDocument` access by design. Note that a subdomain counts as a different origin: a player served from `player.example.com` inside `example.com` is *not* reachable. Sites that embed their player from an external host (common for anime and sports streaming mirrors) fall into this category. Tracked on the roadmap as the cross-origin frame bridge.
|
||||
Since v3.1.2 the background can also inspect accessible **cross-origin** frames and inject KoalaSync into the exact frame/document containing the visible player. Frame election uses visibility and media signals so hidden preloads, trailers and ad players do not win accidentally. When browser site access for the external player origin is withheld, KoalaSync uses the existing website-access recovery flow instead of bypassing the browser permission.
|
||||
|
||||
@@ -19,6 +19,7 @@ When you push a Git tag matching `v*` (e.g., `v2.5.1`), the GitHub Actions relea
|
||||
- `extension/manifest.base.json`
|
||||
- `shared/constants.js` (updates `APP_VERSION`)
|
||||
- `package.json`
|
||||
- `package-lock.json` (root package metadata)
|
||||
- `website/version.json`
|
||||
- `website/template.html` (updates `softwareVersion` schema)
|
||||
- `README.md` (updates badge and announcement banner)
|
||||
|
||||
+649
-135
File diff suppressed because it is too large
Load Diff
+264
-94
@@ -17,6 +17,7 @@
|
||||
if (window.koalaSyncInjected && chrome.runtime.id) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
} catch (_e) {
|
||||
@@ -40,6 +41,34 @@
|
||||
lifecycleTimeouts.add(timer);
|
||||
return timer;
|
||||
}
|
||||
|
||||
function runtimeMessage(message, callback) {
|
||||
if (destroyed) return Promise.resolve(undefined);
|
||||
try {
|
||||
if (!chrome.runtime?.id) {
|
||||
destroyContentScript();
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return chrome.runtime.sendMessage(message, callback) || Promise.resolve(undefined);
|
||||
} catch (_e) {
|
||||
destroyContentScript();
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const isEmbeddedContentFrame = (() => {
|
||||
try { return window.top !== window; } catch (_e) { return true; }
|
||||
})();
|
||||
let mediaTargetRefreshTimeout = null;
|
||||
let mediaTargetRefreshBlockedUntil = 0;
|
||||
let lastMediaFrameVisible = window.innerWidth > 0 && window.innerHeight > 0;
|
||||
|
||||
function requestMediaTargetRefresh(reason) {
|
||||
if (destroyed || mediaTargetRefreshTimeout || Date.now() < mediaTargetRefreshBlockedUntil) return;
|
||||
mediaTargetRefreshTimeout = scheduleLifecycleTimeout(() => {
|
||||
mediaTargetRefreshTimeout = null;
|
||||
mediaTargetRefreshBlockedUntil = Date.now() + 1500;
|
||||
runtimeMessage({ type: 'MEDIA_TARGET_REFRESH', reason }).catch(() => {});
|
||||
}, 750);
|
||||
}
|
||||
|
||||
@@ -225,8 +254,9 @@
|
||||
if (isDisneyPlusHost()) return null;
|
||||
const current = video.currentTime;
|
||||
return Number.isFinite(current) ? current : null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function getSyncDuration(video) {
|
||||
const siteTimeline = getSiteQuirkTimeline(video);
|
||||
if (siteTimeline) return siteTimeline.duration;
|
||||
if (isDisneyPlusHost()) return 0;
|
||||
@@ -599,12 +629,75 @@
|
||||
// Abort if the reason to snap is gone: user went solo, we're no longer a
|
||||
|
||||
// gated guest, or the video vanished.
|
||||
|
||||
// gated guest, or the video vanished.
|
||||
|
||||
if (hcmDesynced || !hcmIsGuestGated()) { hcmDeferredSnapPending = false; return; }
|
||||
|
||||
const video = findVideo();
|
||||
|
||||
if (hcmDesynced || !hcmIsGuestGated()) { hcmDeferredSnapPending = false; return; }
|
||||
|
||||
const video = findVideo();
|
||||
|
||||
const ready = video && video.readyState >= 3 && !video.seeking;
|
||||
|
||||
if (ready || Date.now() >= deadline) {
|
||||
|
||||
hcmDeferredSnapPending = false;
|
||||
|
||||
hcmRequestHostSyncWithRetry(); // fresh host position + snap once
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
scheduleLifecycleTimeout(poll, 300);
|
||||
};
|
||||
|
||||
poll();
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Entry point: background told us our local action was blocked in host-only.
|
||||
|
||||
function hcmHandleBlocked(action, target) {
|
||||
|
||||
// HOST_BLOCKED is only ever sent to a gated guest (background verifies
|
||||
|
||||
// host-only + !host before sending), so it's authoritative. Adopt the
|
||||
|
||||
// role/mode from it in case our CONTROL_MODE broadcast hasn't landed yet
|
||||
|
||||
// (join race, EC-5) — otherwise we'd miss the dialog/snap-back.
|
||||
|
||||
hcmControlMode = 'host-only';
|
||||
|
||||
hcmAmController = false;
|
||||
|
||||
if (hcmDesynced) return; // already solo, nothing to do
|
||||
|
||||
|
||||
|
||||
const intent = hcmClassifyIntent();
|
||||
|
||||
if (intent === 'live') return; // EC-15: leave the guest alone on live
|
||||
|
||||
if (intent === 'involuntary') {
|
||||
|
||||
// EC-4 loop guard: only the silent auto snap-back is suppressed by the
|
||||
|
||||
// cooldown — the deliberate dialog path below must still go through,
|
||||
|
||||
// otherwise a second deliberate pause inside the cooldown window leaves
|
||||
|
||||
// the user stuck paused with no UI (M-3).
|
||||
|
||||
if (Date.now() < hcmSnapBackCooldownUntil || hcmDeferredSnapPending) return;
|
||||
|
||||
// Buffering/ads/throttle — silently re-sync, no dialog spam.
|
||||
|
||||
const video = findVideo();
|
||||
|
||||
if (video && video.readyState >= 3 && !video.seeking) {
|
||||
|
||||
// Ready now → snap immediately. Use the captured target if it's
|
||||
|
||||
// usable, otherwise re-query+retry (host state may not be known yet)
|
||||
|
||||
@@ -612,11 +705,19 @@
|
||||
|
||||
// deferred and "Stay in sync" paths).
|
||||
|
||||
hcmRequestHostSyncWithRetry(); // fresh host position + snap once
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
if (target && Number.isFinite(target.targetTime)) hcmSnapBackToHost(target);
|
||||
|
||||
else hcmRequestHostSyncWithRetry();
|
||||
|
||||
} else {
|
||||
|
||||
hcmDeferredSnapBack(); // buffering → wait for ready, then snap once (#3)
|
||||
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
// Deliberate: offer the choice (Teleparty-style), default = snap back.
|
||||
|
||||
@@ -631,22 +732,32 @@
|
||||
// Built with the DOM API (CSSOM .style is CSP-safe; inline style="" in innerHTML
|
||||
|
||||
// is stripped by strict style-src on Netflix/YouTube/Disney+). Hosted in a
|
||||
// HOST_BLOCKED is only ever sent to a gated guest (background verifies
|
||||
|
||||
// host-only + !host before sending), so it's authoritative. Adopt the
|
||||
|
||||
// Shadow DOM so the page's CSS can't restyle or hide our controls.
|
||||
|
||||
let hcmDialogHost = null; // shadow host element for the dialog
|
||||
|
||||
let hcmBadgeHost = null; // shadow host element for the persistent badge
|
||||
|
||||
let hcmBadgePending = false; // retry flag for early-injection badge creation (L-4)
|
||||
|
||||
|
||||
|
||||
function hcmEl(tag, css, text) {
|
||||
|
||||
const el = document.createElement(tag);
|
||||
|
||||
if (css) el.style.cssText = css; // CSSOM assignment — not gated by CSP
|
||||
hcmControlMode = 'host-only';
|
||||
|
||||
hcmAmController = false;
|
||||
|
||||
if (hcmDesynced) return; // already solo, nothing to do
|
||||
|
||||
|
||||
|
||||
|
||||
if (text != null) el.textContent = text;
|
||||
|
||||
return el;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function hcmRemoveDialog() {
|
||||
|
||||
// Cancel any pending auto-stay timer so a replaced dialog's stale closure
|
||||
|
||||
@@ -658,11 +769,11 @@
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
function hcmShowDesyncDialog(action, target) {
|
||||
|
||||
|
||||
if (!document.body) { hcmSnapBackToHost(target); return; }
|
||||
|
||||
hcmRemoveDialog();
|
||||
|
||||
@@ -1307,7 +1418,10 @@
|
||||
limiter.threshold.value = 0;
|
||||
limiter.knee.value = 0;
|
||||
limiter.ratio.value = 20;
|
||||
|
||||
limiter.attack.value = 0;
|
||||
limiter.release.value = 0.1;
|
||||
|
||||
const chain = { compressor, dryGain, compGain, outputGain, limiter, active: false, signature: '' };
|
||||
audioChains.set(videoEl, chain);
|
||||
|
||||
currentAudioVideo = videoEl;
|
||||
@@ -1447,10 +1561,10 @@
|
||||
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
|
||||
|
||||
return null;
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
function sameEpisode(titleA, titleB) {
|
||||
|
||||
@@ -1477,12 +1591,14 @@
|
||||
// Returns true only when we are CERTAIN the episodes differ.
|
||||
|
||||
// Permissive: only blocks if BOTH titles have parseable IDs AND they differ.
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Films, music, unparseable titles always pass through.
|
||||
|
||||
function isDifferentEpisode(titleA, titleB) {
|
||||
|
||||
if (!titleA || !titleB) return false; // Unknown → allow
|
||||
|
||||
|
||||
const idA = extractEpisodeId(titleA);
|
||||
|
||||
const idB = extractEpisodeId(titleB);
|
||||
|
||||
@@ -1529,12 +1645,14 @@
|
||||
|
||||
|
||||
function onEpisodeTransition(newTitle) {
|
||||
const current = video ? getSyncCurrentTime(video) : null;
|
||||
// Only trigger if: we had a previous title, the title changed,
|
||||
|
||||
|
||||
// Debounce: prevent duplicate fires from multiple signals
|
||||
|
||||
if (episodeTransitionDebounce) return;
|
||||
|
||||
|
||||
if (lastKnownMediaTitle && currentTitle
|
||||
episodeTransitionDebounce = setTimeout(() => {
|
||||
|
||||
episodeTransitionDebounce = null;
|
||||
|
||||
}, 2000);
|
||||
|
||||
@@ -1589,7 +1707,7 @@
|
||||
_setSuppress('paused');
|
||||
|
||||
video.pause();
|
||||
// and sends back PAUSE_FOR_LOBBY so we only freeze if the feature is on.
|
||||
|
||||
}
|
||||
|
||||
stopLobbyPoll();
|
||||
@@ -1600,7 +1718,12 @@
|
||||
payload: { title: currentTitle }
|
||||
|
||||
}).catch(() => {});
|
||||
function checkAndReportLobbyReady(expectedTitle) {
|
||||
|
||||
reportLog(`Episode lobby: Ready for "${currentTitle}"`, 'success');
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -1612,17 +1735,28 @@
|
||||
|
||||
stopLobbyPoll();
|
||||
|
||||
expectedSeekTime = 0;
|
||||
video.currentTime = 0;
|
||||
_pendingLobbyTitle = expectedTitle;
|
||||
|
||||
|
||||
|
||||
// NOTE: Do NOT pause here. Three callers reach this function:
|
||||
|
||||
// 1. PAUSE_FOR_LOBBY (initiator): already paused by that handler before calling us.
|
||||
|
||||
// 2. EPISODE_LOBBY (non-initiator): peer may still be on the PREVIOUS episode — pausing
|
||||
|
||||
// would freeze them mid-episode. The pause happens inside checkAndReportLobbyReady()
|
||||
|
||||
// only once their title actually matches.
|
||||
|
||||
|
||||
// 3. CONTENT_BOOT recovery: same reasoning as (2).
|
||||
|
||||
|
||||
|
||||
// Check immediately
|
||||
|
||||
video.pause();
|
||||
|
||||
|
||||
if (checkAndReportLobbyReady(expectedTitle)) return;
|
||||
|
||||
|
||||
|
||||
// Poll every 2 seconds — no log spam, internal only
|
||||
@@ -1653,11 +1787,11 @@
|
||||
|
||||
}
|
||||
|
||||
|
||||
// NOTE: Do NOT pause here. Three callers reach this function:
|
||||
|
||||
// 1. PAUSE_FOR_LOBBY (initiator): already paused by that handler before calling us.
|
||||
|
||||
|
||||
|
||||
function getPlayerActionFixes() {
|
||||
return [
|
||||
{
|
||||
name: 'youtube-player-buttons',
|
||||
urls: ['youtube.com'],
|
||||
playPauseButtonSelector: '.ytp-play-button'
|
||||
@@ -1672,25 +1806,48 @@
|
||||
|
||||
function getActivePlayerActionFix() {
|
||||
return getPlayerActionFixes().find(fix => matchesPlayerUrls(fix.urls)) || null;
|
||||
|
||||
|
||||
// Poll every 2 seconds — no log spam, internal only
|
||||
|
||||
}
|
||||
|
||||
function tryPlayerActionFix(fix, action, video, data) {
|
||||
if (!fix) return false;
|
||||
const button = document.querySelector(fix.playPauseButtonSelector);
|
||||
if (!button) return false;
|
||||
|
||||
const isCurrentlyPlaying = !video.paused;
|
||||
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
|
||||
_setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||
button.click();
|
||||
}
|
||||
if (action === EVENTS.SEEK) {
|
||||
seekVideo(video, data.targetTime, data.delta);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Helper: site-specific player actions, then native HTML5 fallback ---
|
||||
function tryMediaAction(action, data) {
|
||||
const video = findVideo();
|
||||
if (!video) return;
|
||||
|
||||
|
||||
if (action === EVENTS.SEEK) {
|
||||
|
||||
const target = data ? (data.targetTime !== undefined ? data.targetTime : data.currentTime) : undefined;
|
||||
|
||||
if (!Number.isFinite(target)) {
|
||||
|
||||
reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error');
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
data = { ...data, targetTime: target };
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function stopLobbyPoll() {
|
||||
|
||||
_pendingLobbyTitle = null;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const actionFix = getActivePlayerActionFix();
|
||||
if (tryPlayerActionFix(actionFix, action, video, data)) {
|
||||
return;
|
||||
@@ -1717,16 +1874,24 @@
|
||||
|
||||
} else if (action === EVENTS.SEEK) {
|
||||
seekVideo(video, data.targetTime, data.delta);
|
||||
}
|
||||
|
||||
function getActivePlayerActionFix() {
|
||||
return getPlayerActionFixes().find(fix => matchesPlayerUrls(fix.urls)) || null;
|
||||
}
|
||||
|
||||
function tryPlayerActionFix(fix, action, video, data) {
|
||||
if (!fix) return false;
|
||||
const button = document.querySelector(fix.playPauseButtonSelector);
|
||||
if (!button) return false;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
|
||||
reportLog(`Media Action Error: ${e.message}`, 'error');
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- Helper: Wait until video is ready for playback (buffered & seeked) ---
|
||||
|
||||
function pollSeekReady(targetTime, timeoutMs = 8000) {
|
||||
return new Promise((resolve) => {
|
||||
const interval = 150;
|
||||
let elapsed = 0;
|
||||
const timer = setInterval(() => {
|
||||
if (destroyed) {
|
||||
clearInterval(timer);
|
||||
@@ -1750,7 +1915,12 @@
|
||||
const timeDiff = current !== null ? Math.abs(current - targetTime) : Infinity;
|
||||
const ready = video.readyState >= 3 && timeDiff < 2.0;
|
||||
if (ready) {
|
||||
|
||||
clearInterval(timer);
|
||||
seekPollTimers.delete(timer);
|
||||
resolve(true);
|
||||
} else if (elapsed >= timeoutMs) {
|
||||
clearInterval(timer);
|
||||
seekPollTimers.delete(timer);
|
||||
resolve(false);
|
||||
}
|
||||
}, interval);
|
||||
@@ -1775,7 +1945,12 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
reportLog(`Playback prevented: ${e.message}`, 'warn');
|
||||
|
||||
if (message.action === 'APPLY_AUDIO_SETTINGS') {
|
||||
|
||||
_audioProcessingAllowed = true;
|
||||
|
||||
_audioSettings = mergeAudioSettings(message.settings);
|
||||
|
||||
const video = findVideo();
|
||||
|
||||
@@ -1790,7 +1965,12 @@
|
||||
|
||||
|
||||
if (message.action === 'RESET_AUDIO_PROCESSING') {
|
||||
|
||||
|
||||
_audioProcessingAllowed = false;
|
||||
|
||||
bypassCurrentAudioProcessing();
|
||||
|
||||
sendResponse({ ok: true });
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1851,8 +2031,9 @@
|
||||
if (message.type === 'REQUEST_HEARTBEAT') {
|
||||
|
||||
sendHeartbeat();
|
||||
return true;
|
||||
}
|
||||
|
||||
sendResponse({ ok: true });
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
@@ -1889,13 +2070,11 @@
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (message.type === 'CONTROL_MODE') {
|
||||
|
||||
|
||||
// Guard: Don't execute sync commands if peers are on different episodes.
|
||||
@@ -1909,25 +2088,16 @@
|
||||
const syncActions = [EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
|
||||
|
||||
EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE];
|
||||
// host identity changes (room switch, host-leave fallback, missed
|
||||
|
||||
// teardown broadcast) — clears stale desync so a rejoin starts clean (H-3).
|
||||
|
||||
const hostChanged = prevHostPeerId !== null && hcmHostPeerId !== prevHostPeerId;
|
||||
|
||||
if ((wasGated && !hcmIsGuestGated()) || hostChanged) hcmReset();
|
||||
|
||||
sendResponse({ ok: true });
|
||||
|
||||
return true;
|
||||
|
||||
|
||||
if (_autoSyncEnabled && syncActions.includes(action)) {
|
||||
|
||||
const senderTitle = payload?.mediaTitle;
|
||||
|
||||
const myTitle = getMediaTitle();
|
||||
|
||||
if (isDifferentEpisode(senderTitle, myTitle)) {
|
||||
|
||||
reportLog(`Episode mismatch: sender="${senderTitle || '?'}" vs mine="${myTitle || '?'}" — skipping ${action}. Disable "Auto-Sync next Episode" in settings if this causes issues.`, 'warn');
|
||||
|
||||
if (action !== EVENTS.FORCE_SYNC_PREPARE && action !== EVENTS.FORCE_SYNC_EXECUTE) {
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"scripting",
|
||||
"alarms",
|
||||
"activeTab",
|
||||
"notifications"
|
||||
"notifications",
|
||||
"webNavigation"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Lightweight per-frame sentinel. It does not control media; it only tells the
|
||||
* background that the selected tab's candidate set or frame layout changed.
|
||||
*/
|
||||
(function installKoalaMediaFrameMonitor() {
|
||||
try { window.__koalaMediaFrameMonitorCleanup?.(); } catch { /* stale monitor */ }
|
||||
|
||||
let destroyed = false;
|
||||
let notifyTimer = null;
|
||||
const hookedFrames = new Set();
|
||||
let lastCandidateSignature = null;
|
||||
|
||||
function geometryBucket(value) {
|
||||
return Math.round(value / 8);
|
||||
}
|
||||
|
||||
function elementStylesAllowRendering(element) {
|
||||
let current = element;
|
||||
while (current) {
|
||||
try {
|
||||
const style = window.getComputedStyle(current);
|
||||
if (style.display === 'none'
|
||||
|| style.visibility === 'hidden'
|
||||
|| Number(style.opacity) === 0) {
|
||||
return false;
|
||||
}
|
||||
} catch { /* detached or browser-owned node */ }
|
||||
const parent = current.parentElement;
|
||||
if (parent) {
|
||||
current = parent;
|
||||
continue;
|
||||
}
|
||||
try { current = current.getRootNode?.().host || null; } catch { current = null; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function candidateSignature() {
|
||||
const parts = [];
|
||||
for (const element of document.querySelectorAll('video, iframe, frame')) {
|
||||
try {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
const browserReportsVisible = typeof element.checkVisibility === 'function'
|
||||
? element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
|
||||
: true;
|
||||
const visible = rect.width > 0
|
||||
&& rect.height > 0
|
||||
&& rect.bottom > 0
|
||||
&& rect.right > 0
|
||||
&& rect.top < window.innerHeight
|
||||
&& rect.left < window.innerWidth
|
||||
&& browserReportsVisible
|
||||
&& elementStylesAllowRendering(element)
|
||||
&& style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number(style.opacity) !== 0;
|
||||
const source = element.tagName === 'VIDEO'
|
||||
? (element.currentSrc || element.src || element.querySelector?.('source[src]')?.src || '')
|
||||
: (element.src || '');
|
||||
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
|
||||
].join(',')
|
||||
: '';
|
||||
parts.push([
|
||||
element.tagName,
|
||||
source,
|
||||
visible ? 1 : 0,
|
||||
geometryBucket(rect.left),
|
||||
geometryBucket(rect.top),
|
||||
geometryBucket(rect.width),
|
||||
geometryBucket(rect.height),
|
||||
mediaState
|
||||
].join(':'));
|
||||
} catch {
|
||||
parts.push('detached');
|
||||
}
|
||||
}
|
||||
return parts.join('|');
|
||||
}
|
||||
|
||||
function send(reason) {
|
||||
if (destroyed) return;
|
||||
try {
|
||||
chrome.runtime.sendMessage({ type: 'MEDIA_FRAME_CANDIDATE_CHANGED', reason }).catch(() => {});
|
||||
} catch {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function schedule(reason, { force = false } = {}) {
|
||||
if (destroyed || notifyTimer !== null) return;
|
||||
notifyTimer = setTimeout(() => {
|
||||
notifyTimer = null;
|
||||
const nextSignature = candidateSignature();
|
||||
if (!force && nextSignature === lastCandidateSignature) return;
|
||||
lastCandidateSignature = nextSignature;
|
||||
send(reason);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function containsAddedMediaNode(node) {
|
||||
return node?.nodeType === 1
|
||||
&& (node.matches?.('video, iframe, frame') || node.querySelector?.('video, iframe, frame'));
|
||||
}
|
||||
|
||||
function attributeAffectsCandidate(node) {
|
||||
return node?.nodeType === 1
|
||||
&& (node.matches?.('video, iframe, frame') || node.querySelector?.('video, iframe, frame'));
|
||||
}
|
||||
|
||||
function hookFrames() {
|
||||
for (const frame of hookedFrames) {
|
||||
if (frame.isConnected) continue;
|
||||
frame.removeEventListener('load', handleFrameLoad);
|
||||
hookedFrames.delete(frame);
|
||||
}
|
||||
for (const frame of document.querySelectorAll('iframe, frame')) {
|
||||
if (hookedFrames.has(frame)) continue;
|
||||
hookedFrames.add(frame);
|
||||
frame.addEventListener('load', handleFrameLoad);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFrameLoad() {
|
||||
hookFrames();
|
||||
schedule('frame_load', { force: true });
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
let relevant = false;
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === 'attributes') {
|
||||
if (attributeAffectsCandidate(mutation.target)) relevant = true;
|
||||
} else if ([...mutation.addedNodes, ...mutation.removedNodes].some(containsAddedMediaNode)) {
|
||||
relevant = true;
|
||||
}
|
||||
if (relevant) break;
|
||||
}
|
||||
if (!relevant) return;
|
||||
hookFrames();
|
||||
schedule('media_dom_changed');
|
||||
});
|
||||
|
||||
function handlePageHide() { send('frame_pagehide'); }
|
||||
function handlePageShow() { schedule('frame_pageshow', { force: true }); }
|
||||
function handleResize() { schedule('frame_resize'); }
|
||||
function handleMediaState(event) {
|
||||
if (event.target?.tagName === 'VIDEO') schedule(`media_${event.type}`);
|
||||
}
|
||||
function handleMessage(message) {
|
||||
if (message?.type === 'MEDIA_MONITOR_DEACTIVATE') cleanup();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
if (notifyTimer !== null) clearTimeout(notifyTimer);
|
||||
notifyTimer = null;
|
||||
observer.disconnect();
|
||||
for (const frame of hookedFrames) frame.removeEventListener('load', handleFrameLoad);
|
||||
hookedFrames.clear();
|
||||
window.removeEventListener('pagehide', handlePageHide);
|
||||
window.removeEventListener('pageshow', handlePageShow);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
for (const type of MEDIA_STATE_EVENTS) {
|
||||
document.removeEventListener(type, handleMediaState, true);
|
||||
}
|
||||
try { chrome.runtime.onMessage.removeListener(handleMessage); } catch { /* invalidated */ }
|
||||
if (window.__koalaMediaFrameMonitorCleanup === cleanup) {
|
||||
delete window.__koalaMediaFrameMonitorCleanup;
|
||||
}
|
||||
}
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'hidden', 'src', 'controls']
|
||||
});
|
||||
hookFrames();
|
||||
lastCandidateSignature = candidateSignature();
|
||||
window.addEventListener('pagehide', handlePageHide);
|
||||
window.addEventListener('pageshow', handlePageShow);
|
||||
window.addEventListener('resize', handleResize, { passive: true });
|
||||
const MEDIA_STATE_EVENTS = ['play', 'pause', 'loadedmetadata', 'loadeddata', 'canplay', 'durationchange', 'emptied'];
|
||||
for (const type of MEDIA_STATE_EVENTS) {
|
||||
document.addEventListener(type, handleMediaState, true);
|
||||
}
|
||||
chrome.runtime.onMessage.addListener(handleMessage);
|
||||
window.__koalaMediaFrameMonitorCleanup = cleanup;
|
||||
})();
|
||||
@@ -0,0 +1,536 @@
|
||||
export const MEDIA_FRAME_ACCESS_REQUIRED = 'media_frame_access_required';
|
||||
export const MEDIA_FRAME_AMBIGUOUS = 'media_frame_ambiguous';
|
||||
|
||||
const MIN_PLAYER_FRAME_AREA = 320 * 180;
|
||||
const MIN_PLAYER_ASPECT_RATIO = 1.15;
|
||||
const MAX_PLAYER_ASPECT_RATIO = 2.6;
|
||||
|
||||
function normalizeFrameId(value) {
|
||||
return Number.isInteger(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
function safeOrigin(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === 'http:' || url.protocol === 'https:') ? url.origin : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function originPattern(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
// WebExtension match patterns intentionally omit ports. Chromium treats
|
||||
// the host pattern port-independently; Firefox rejects explicit ports.
|
||||
return `${url.protocol}//${url.hostname}/*`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isGoogleDrivePlayerUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.hostname.toLowerCase() !== 'youtube.googleapis.com'
|
||||
|| (url.pathname !== '/embed' && !url.pathname.startsWith('/embed/'))) {
|
||||
return false;
|
||||
}
|
||||
const parentOrigin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
|
||||
return parentOrigin === 'https://drive.google.com';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs inside every frame through chrome.scripting.executeScript. Keep this
|
||||
* function self-contained: extension functions outside its body are not
|
||||
* available in the injected isolated world.
|
||||
*/
|
||||
export function inspectMediaFrame(expectedVisibilityToken = null) {
|
||||
const elementIsVisible = (element, rect) => {
|
||||
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
|
||||
const view = element.ownerDocument?.defaultView || window;
|
||||
const style = view.getComputedStyle(element);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
|
||||
return false;
|
||||
}
|
||||
if (typeof element.checkVisibility === 'function'
|
||||
&& !element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) {
|
||||
return false;
|
||||
}
|
||||
return rect.bottom > 0
|
||||
&& rect.right > 0
|
||||
&& rect.top < view.innerHeight
|
||||
&& rect.left < view.innerWidth;
|
||||
};
|
||||
|
||||
const collectVideos = (doc, depth = 0, ancestorVisible = true, videos = [], seen = new Set()) => {
|
||||
if (depth >= 4 || typeof doc.querySelectorAll !== 'function') return videos;
|
||||
for (const video of doc.querySelectorAll('video')) {
|
||||
if (!seen.has(video)) {
|
||||
seen.add(video);
|
||||
videos.push({ video, ancestorVisible });
|
||||
}
|
||||
}
|
||||
const hosts = doc.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player');
|
||||
for (const host of hosts) {
|
||||
if (!host.shadowRoot) continue;
|
||||
for (const video of host.shadowRoot.querySelectorAll('video')) {
|
||||
if (!seen.has(video)) {
|
||||
seen.add(video);
|
||||
videos.push({ video, ancestorVisible });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const frame of doc.querySelectorAll('iframe, frame')) {
|
||||
try {
|
||||
const frameRect = frame.getBoundingClientRect();
|
||||
const frameVisible = ancestorVisible && elementIsVisible(frame, frameRect);
|
||||
const frameDoc = frame.contentDocument;
|
||||
if (frameDoc) collectVideos(frameDoc, depth + 1, frameVisible, videos, seen);
|
||||
} catch {
|
||||
// Cross-origin media is inspected in its own execution result.
|
||||
}
|
||||
}
|
||||
return videos;
|
||||
};
|
||||
|
||||
const videoDetails = collectVideos(document).map(({ video, ancestorVisible }) => {
|
||||
const rect = video.getBoundingClientRect();
|
||||
const rendered = ancestorVisible && elementIsVisible(video, rect);
|
||||
const hasSource = !!(video.currentSrc || video.src || video.srcObject
|
||||
|| video.querySelector?.('source[src]'));
|
||||
const background = !!video.loop && !!video.muted && !video.controls;
|
||||
const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0;
|
||||
const shortUncontrolled = !video.controls && duration > 0 && duration < 300;
|
||||
const renderedArea = Math.max(0, rect.width) * Math.max(0, rect.height);
|
||||
return {
|
||||
hasSource,
|
||||
rendered,
|
||||
background,
|
||||
shortUncontrolled,
|
||||
sizeBucket: Math.round(Math.sqrt(renderedArea) / 40),
|
||||
playing: video.paused === false && video.ended !== true,
|
||||
controls: !!video.controls,
|
||||
readyState: Number.isInteger(video.readyState) ? video.readyState : 0,
|
||||
duration,
|
||||
renderedArea
|
||||
};
|
||||
});
|
||||
|
||||
const compareVideo = (left, right) => {
|
||||
const leftRank = [
|
||||
left.hasSource ? 1 : 0,
|
||||
left.rendered ? 1 : 0,
|
||||
left.background ? 0 : 1,
|
||||
left.shortUncontrolled ? 0 : 1,
|
||||
left.playing ? 1 : 0,
|
||||
left.controls ? 1 : 0,
|
||||
left.readyState,
|
||||
left.duration,
|
||||
left.sizeBucket
|
||||
];
|
||||
const rightRank = [
|
||||
right.hasSource ? 1 : 0,
|
||||
right.rendered ? 1 : 0,
|
||||
right.background ? 0 : 1,
|
||||
right.shortUncontrolled ? 0 : 1,
|
||||
right.playing ? 1 : 0,
|
||||
right.controls ? 1 : 0,
|
||||
right.readyState,
|
||||
right.duration,
|
||||
right.sizeBucket
|
||||
];
|
||||
for (let index = 0; index < leftRank.length; index++) {
|
||||
if (leftRank[index] !== rightRank[index]) return rightRank[index] - leftRank[index];
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
videoDetails.sort(compareVideo);
|
||||
|
||||
// Recursively list direct and same-origin-descendant frame elements. This
|
||||
// lets the background identify a large inaccessible player origin even if
|
||||
// an all-frame probe is rejected by the browser's site-access policy.
|
||||
const embeddedFrames = [];
|
||||
const collectEmbeddedFrames = (doc, depth = 0, ancestorVisible = true) => {
|
||||
if (depth >= 4 || typeof doc.querySelectorAll !== 'function') return;
|
||||
for (const frame of doc.querySelectorAll('iframe, frame')) {
|
||||
const rect = frame.getBoundingClientRect();
|
||||
const directVisible = elementIsVisible(frame, rect);
|
||||
const visible = ancestorVisible && directVisible;
|
||||
let href = '';
|
||||
try { href = new URL(frame.src || '', doc.location.href).href; } catch { href = ''; }
|
||||
embeddedFrames.push({
|
||||
href,
|
||||
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),
|
||||
height: Math.max(0, rect.height),
|
||||
visible,
|
||||
depth: depth + 1,
|
||||
mediaHint: frame.allowFullscreen === true
|
||||
|| frame.hasAttribute?.('allowfullscreen')
|
||||
|| /autoplay|fullscreen|picture-in-picture|encrypted-media/i.test(frame.getAttribute('allow') || '')
|
||||
|| /player|video|stream|watch|embed|media|xfp/i.test([
|
||||
frame.id,
|
||||
frame.name,
|
||||
frame.className,
|
||||
frame.title,
|
||||
href
|
||||
].join(' '))
|
||||
});
|
||||
try {
|
||||
const frameDoc = frame.contentDocument;
|
||||
if (frameDoc) collectEmbeddedFrames(frameDoc, depth + 1, visible);
|
||||
} catch {
|
||||
// Cross-origin descendants are represented by their frame URL.
|
||||
}
|
||||
}
|
||||
};
|
||||
collectEmbeddedFrames(document);
|
||||
|
||||
const storedParentVisibility = window.__koalaParentFrameVisibility;
|
||||
const parentVisibility = expectedVisibilityToken
|
||||
&& storedParentVisibility?.token === expectedVisibilityToken
|
||||
? storedParentVisibility
|
||||
: null;
|
||||
return {
|
||||
href: window.location.href,
|
||||
origin: window.location.origin,
|
||||
isTop: window.top === window,
|
||||
videoCount: videoDetails.length,
|
||||
bestVideo: videoDetails[0] || null,
|
||||
frameArea: Math.max(0, window.innerWidth) * Math.max(0, window.innerHeight),
|
||||
parentFrameVisible: window.top === window
|
||||
? true
|
||||
: parentVisibility?.visible ?? null,
|
||||
parentFrameArea: window.top === window
|
||||
? Math.max(0, window.innerWidth) * Math.max(0, window.innerHeight)
|
||||
: (Number.isFinite(parentVisibility?.area) ? parentVisibility.area : null),
|
||||
embeddedFrames
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs inside every frame before the visibility dispatch. */
|
||||
export function installParentFrameVisibilityProbe(token) {
|
||||
try { window.__koalaFrameVisibilityCleanup?.(); } catch { /* stale probe */ }
|
||||
window.__koalaParentFrameVisibility = { token, visible: null, area: null };
|
||||
let timeout = null;
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('message', handler);
|
||||
if (timeout !== null) clearTimeout(timeout);
|
||||
if (window.__koalaFrameVisibilityCleanup === cleanup) {
|
||||
delete window.__koalaFrameVisibilityCleanup;
|
||||
}
|
||||
};
|
||||
const handler = (event) => {
|
||||
if (event.source !== window.parent
|
||||
|| event.data?.type !== 'KOALASYNC_FRAME_VISIBILITY'
|
||||
|| event.data?.token !== token) {
|
||||
return;
|
||||
}
|
||||
window.__koalaParentFrameVisibility = {
|
||||
token,
|
||||
visible: event.data.visible === true,
|
||||
area: Number.isFinite(event.data.area) ? event.data.area : 0
|
||||
};
|
||||
};
|
||||
window.addEventListener('message', handler);
|
||||
timeout = setTimeout(cleanup, 1000);
|
||||
window.__koalaFrameVisibilityCleanup = cleanup;
|
||||
}
|
||||
|
||||
/** Runs inside every frame; each parent reports geometry to its direct children. */
|
||||
export function dispatchParentFrameVisibilityProbe(token) {
|
||||
const ancestor = window.top === window ? { visible: true, area: Infinity } : window.__koalaParentFrameVisibility;
|
||||
for (const frame of document.querySelectorAll('iframe, frame')) {
|
||||
try {
|
||||
const rect = frame.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(frame);
|
||||
const area = Math.max(0, rect.width) * Math.max(0, rect.height);
|
||||
const intersectsViewport = rect.bottom > 0
|
||||
&& rect.right > 0
|
||||
&& rect.top < window.innerHeight
|
||||
&& rect.left < window.innerWidth;
|
||||
const browserReportsVisible = typeof frame.checkVisibility === 'function'
|
||||
? frame.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
|
||||
: true;
|
||||
const directlyVisible = area > 0
|
||||
&& intersectsViewport
|
||||
&& browserReportsVisible
|
||||
&& style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number(style.opacity) !== 0;
|
||||
const visible = directlyVisible && ancestor?.visible !== false;
|
||||
const inheritedArea = Number.isFinite(ancestor?.area) ? ancestor.area : area;
|
||||
const effectiveArea = Math.min(area, inheritedArea);
|
||||
frame.contentWindow?.postMessage({
|
||||
type: 'KOALASYNC_FRAME_VISIBILITY',
|
||||
token,
|
||||
visible,
|
||||
area: effectiveArea
|
||||
}, '*');
|
||||
} catch {
|
||||
// A detached or browser-owned frame is not a candidate.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mediaCandidateRank(entry) {
|
||||
const result = entry.result;
|
||||
const video = result.bestVideo;
|
||||
const visibility = result.parentFrameVisible === true
|
||||
? 2
|
||||
: result.parentFrameVisible === false
|
||||
? 0
|
||||
: 1;
|
||||
return [
|
||||
visibility,
|
||||
video.hasSource ? 1 : 0,
|
||||
video.rendered ? 1 : 0,
|
||||
video.background ? 0 : 1,
|
||||
video.shortUncontrolled ? 0 : 1,
|
||||
video.playing ? 1 : 0,
|
||||
video.controls ? 1 : 0,
|
||||
video.readyState,
|
||||
video.duration,
|
||||
video.sizeBucket,
|
||||
result.isTop ? 1 : 0,
|
||||
Number.isFinite(result.parentFrameArea) ? result.parentFrameArea : result.frameArea
|
||||
];
|
||||
}
|
||||
|
||||
function compareRanks(left, right) {
|
||||
const leftRank = mediaCandidateRank(left);
|
||||
const rightRank = mediaCandidateRank(right);
|
||||
for (let index = 0; index < leftRank.length; index++) {
|
||||
if (leftRank[index] !== rightRank[index]) return rightRank[index] - leftRank[index];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function sameMeaningfulRank(left, right) {
|
||||
const leftRank = mediaCandidateRank(left);
|
||||
const rightRank = mediaCandidateRank(right);
|
||||
// Ignore duration, size, top-frame preference, and raw frame area. Two
|
||||
// otherwise identical frames are unsafe to distinguish by preload metadata.
|
||||
return leftRank.slice(0, 8).every((value, index) => value === rightRank[index]);
|
||||
}
|
||||
|
||||
export function selectMediaFrame(injectionResults) {
|
||||
const candidates = (Array.isArray(injectionResults) ? injectionResults : [])
|
||||
.filter(entry => Number.isInteger(entry?.frameId)
|
||||
&& entry?.result?.bestVideo?.rendered === true)
|
||||
.filter(entry => entry.result.parentFrameVisible !== false)
|
||||
.sort(compareRanks);
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length > 1
|
||||
&& candidates[0].result.parentFrameVisible !== true
|
||||
&& candidates[1].result.parentFrameVisible !== true
|
||||
&& sameMeaningfulRank(candidates[0], candidates[1])) {
|
||||
return null;
|
||||
}
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function findMissingPlayerAccess(results) {
|
||||
const accessibleOrigins = new Set();
|
||||
for (const entry of results) {
|
||||
const origin = safeOrigin(entry?.result?.href);
|
||||
if (origin) accessibleOrigins.add(origin);
|
||||
}
|
||||
|
||||
const missingByOrigin = new Map();
|
||||
for (const entry of results) {
|
||||
for (const frame of entry?.result?.embeddedFrames || []) {
|
||||
if (!frame.visible || accessibleOrigins.has(frame.origin) || !frame.origin) continue;
|
||||
const aspectRatio = frame.height > 0 ? frame.width / frame.height : 0;
|
||||
const drivePlayer = isGoogleDrivePlayerUrl(frame.href);
|
||||
const looksLikePlayer = frame.mediaHint === true
|
||||
&& frame.area >= MIN_PLAYER_FRAME_AREA
|
||||
&& aspectRatio >= MIN_PLAYER_ASPECT_RATIO
|
||||
&& aspectRatio <= MAX_PLAYER_ASPECT_RATIO;
|
||||
if (!drivePlayer && !looksLikePlayer) continue;
|
||||
const previous = missingByOrigin.get(frame.origin);
|
||||
if (!previous || frame.area > previous.area || drivePlayer) {
|
||||
missingByOrigin.set(frame.origin, { ...frame, drivePlayer });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const missing = Array.from(missingByOrigin.values()).sort((left, right) => {
|
||||
if (left.drivePlayer !== right.drivePlayer) return left.drivePlayer ? -1 : 1;
|
||||
return right.area - left.area;
|
||||
});
|
||||
if (missing.length === 0) return null;
|
||||
if (!missing[0].drivePlayer && missing.length > 1 && missing[0].area < missing[1].area * 1.5) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
host: new URL(missing[0].origin).hostname,
|
||||
originPattern: originPattern(missing[0].origin),
|
||||
area: missing[0].area,
|
||||
drivePlayer: missing[0].drivePlayer === true
|
||||
};
|
||||
}
|
||||
|
||||
function shouldPreferMissingAccess(access, selected) {
|
||||
if (!access) return false;
|
||||
if (access.drivePlayer || !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;
|
||||
return weakAccessibleCandidate
|
||||
&& access.area >= Math.max(MIN_PLAYER_FRAME_AREA, selectedArea * 1.5);
|
||||
}
|
||||
|
||||
function accessRequiredError(access) {
|
||||
const error = new Error(`Embedded player access is required for ${access.host}`);
|
||||
error.code = MEDIA_FRAME_ACCESS_REQUIRED;
|
||||
error.host = access.host;
|
||||
error.originPattern = access.originPattern;
|
||||
return error;
|
||||
}
|
||||
|
||||
function ambiguousFrameError() {
|
||||
const error = new Error('The active embedded video frame could not be identified safely');
|
||||
error.code = MEDIA_FRAME_AMBIGUOUS;
|
||||
return error;
|
||||
}
|
||||
|
||||
function contentTarget(tabId, selected) {
|
||||
const frameId = normalizeFrameId(selected?.frameId);
|
||||
const documentId = typeof selected?.documentId === 'string' ? selected.documentId : null;
|
||||
return {
|
||||
frameId,
|
||||
documentId,
|
||||
frameUrl: typeof selected?.result?.href === 'string' ? selected.result.href : null,
|
||||
hasVideo: !!selected?.result?.bestVideo,
|
||||
scriptTarget: documentId
|
||||
? { tabId, documentIds: [documentId] }
|
||||
: (frameId === 0 ? { tabId } : { tabId, frameIds: [frameId] })
|
||||
};
|
||||
}
|
||||
|
||||
export async function listMediaFrameScriptTargets(chromeApi, tabId) {
|
||||
if (chromeApi.webNavigation?.getAllFrames) {
|
||||
try {
|
||||
const frames = await chromeApi.webNavigation.getAllFrames({ tabId });
|
||||
if (Array.isArray(frames) && frames.length > 0) {
|
||||
return frames
|
||||
.filter(frame => Number.isInteger(frame?.frameId))
|
||||
.map(frame => (typeof frame.documentId === 'string' && frame.documentId
|
||||
? { tabId, documentIds: [frame.documentId] }
|
||||
: { tabId, frameIds: [frame.frameId] }));
|
||||
}
|
||||
} catch {
|
||||
// Older browsers fall back to the all-frames probe below.
|
||||
}
|
||||
}
|
||||
return [{ tabId, allFrames: true }];
|
||||
}
|
||||
|
||||
async function executeInAccessibleFrames(chromeApi, targets, func, args) {
|
||||
const settled = await Promise.all(targets.map(async target => {
|
||||
try {
|
||||
return await chromeApi.scripting.executeScript({ target, func, args });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}));
|
||||
return settled.flat();
|
||||
}
|
||||
|
||||
export async function resolveMediaContentTarget(chromeApi, tabId, {
|
||||
attempts = 8,
|
||||
retryDelayMs = 200,
|
||||
probeDelayMs = 60
|
||||
} = {}) {
|
||||
let fallback = null;
|
||||
let missingAccess = null;
|
||||
let ambiguous = false;
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const scriptTargets = await listMediaFrameScriptTargets(chromeApi, tabId);
|
||||
let results = await executeInAccessibleFrames(
|
||||
chromeApi,
|
||||
scriptTargets,
|
||||
inspectMediaFrame,
|
||||
[null]
|
||||
);
|
||||
if (results.length === 0) {
|
||||
try {
|
||||
results = await chromeApi.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: inspectMediaFrame,
|
||||
args: [null]
|
||||
});
|
||||
} catch {
|
||||
return contentTarget(tabId, null);
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length > 1) {
|
||||
const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`;
|
||||
try {
|
||||
await executeInAccessibleFrames(
|
||||
chromeApi,
|
||||
scriptTargets,
|
||||
installParentFrameVisibilityProbe,
|
||||
[token]
|
||||
);
|
||||
// Four passes match the maximum same-origin recursion depth.
|
||||
for (let pass = 0; pass < 4; pass++) {
|
||||
await executeInAccessibleFrames(
|
||||
chromeApi,
|
||||
scriptTargets,
|
||||
dispatchParentFrameVisibilityProbe,
|
||||
[token]
|
||||
);
|
||||
await new Promise(resolve => setTimeout(resolve, probeDelayMs));
|
||||
}
|
||||
const inspected = await executeInAccessibleFrames(
|
||||
chromeApi,
|
||||
scriptTargets,
|
||||
inspectMediaFrame,
|
||||
[token]
|
||||
);
|
||||
if (inspected.length > 0) results = inspected;
|
||||
} catch {
|
||||
// Initial results remain usable, but equally-ranked unknown
|
||||
// frames will be rejected below rather than guessed.
|
||||
}
|
||||
}
|
||||
|
||||
const selected = selectMediaFrame(results);
|
||||
const videoCandidates = results.filter(entry => entry?.result?.bestVideo?.rendered === true
|
||||
&& entry.result.parentFrameVisible !== false);
|
||||
const currentMissingAccess = findMissingPlayerAccess(results);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt < attempts - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
|
||||
}
|
||||
}
|
||||
|
||||
if (missingAccess) throw accessRequiredError(missingAccess);
|
||||
if (fallback) return contentTarget(tabId, fallback);
|
||||
if (ambiguous) throw ambiguousFrameError();
|
||||
return contentTarget(tabId, null);
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
MEDIA_FRAME_ACCESS_REQUIRED,
|
||||
MEDIA_FRAME_AMBIGUOUS,
|
||||
inspectMediaFrame,
|
||||
resolveMediaContentTarget,
|
||||
selectMediaFrame
|
||||
} from './media-frame-target.js';
|
||||
|
||||
function video(overrides = {}) {
|
||||
const candidate = {
|
||||
hasSource: true,
|
||||
rendered: true,
|
||||
background: false,
|
||||
shortUncontrolled: false,
|
||||
sizeBucket: 18,
|
||||
playing: false,
|
||||
controls: true,
|
||||
readyState: 4,
|
||||
duration: 1200,
|
||||
renderedArea: 830 * 498,
|
||||
...overrides
|
||||
};
|
||||
candidate.shortUncontrolled = overrides.shortUncontrolled ?? (
|
||||
!candidate.controls && candidate.duration > 0 && candidate.duration < 300
|
||||
);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function frame(frameId, overrides = {}) {
|
||||
return {
|
||||
frameId,
|
||||
documentId: `document-${frameId}`,
|
||||
result: {
|
||||
href: `https://player-${frameId}.example/embed`,
|
||||
origin: `https://player-${frameId}.example`,
|
||||
isTop: frameId === 0,
|
||||
videoCount: 1,
|
||||
bestVideo: video(),
|
||||
frameArea: 830 * 498,
|
||||
parentFrameVisible: true,
|
||||
parentFrameArea: 830 * 498,
|
||||
embeddedFrames: [],
|
||||
...overrides
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('cross-origin media-frame targeting', () => {
|
||||
it('selects a visible cross-origin player over a hidden loaded copy', () => {
|
||||
const selected = selectMediaFrame([
|
||||
frame(4),
|
||||
frame(5, { parentFrameVisible: false, bestVideo: video({ playing: true }) })
|
||||
]);
|
||||
expect(selected.frameId).toBe(4);
|
||||
});
|
||||
|
||||
it('does not select a video hidden inside a same-origin descendant', () => {
|
||||
expect(selectMediaFrame([
|
||||
frame(0, { bestVideo: video({ rendered: false }) }),
|
||||
frame(6, {
|
||||
parentFrameVisible: false,
|
||||
bestVideo: video({ rendered: true, playing: true })
|
||||
})
|
||||
])).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a real player ahead of a larger muted looping background video', () => {
|
||||
const selected = selectMediaFrame([
|
||||
frame(2, { bestVideo: video({ sizeBucket: 24, background: true, controls: false }) }),
|
||||
frame(7, { bestVideo: video({ sizeBucket: 18 }) })
|
||||
]);
|
||||
expect(selected.frameId).toBe(7);
|
||||
});
|
||||
|
||||
it('keeps an active long player ahead of a larger ordinary ad video', () => {
|
||||
const selected = selectMediaFrame([
|
||||
frame(2, { bestVideo: video({ sizeBucket: 24, duration: 30, playing: false, controls: false }) }),
|
||||
frame(7, { bestVideo: video({ sizeBucket: 18, duration: 1200, playing: true, controls: true }) })
|
||||
]);
|
||||
expect(selected.frameId).toBe(7);
|
||||
});
|
||||
|
||||
it('keeps a paused long player ahead of a playing short uncontrolled ad', () => {
|
||||
const selected = selectMediaFrame([
|
||||
frame(2, { bestVideo: video({
|
||||
sizeBucket: 24,
|
||||
duration: 30,
|
||||
playing: true,
|
||||
controls: false,
|
||||
shortUncontrolled: true
|
||||
}) }),
|
||||
frame(7, { bestVideo: video({
|
||||
sizeBucket: 18,
|
||||
duration: 1200,
|
||||
playing: false,
|
||||
controls: true
|
||||
}) })
|
||||
]);
|
||||
expect(selected.frameId).toBe(7);
|
||||
});
|
||||
|
||||
it('keeps same-origin reachable media under the top-frame controller', () => {
|
||||
const sharedVideo = video();
|
||||
const selected = selectMediaFrame([
|
||||
frame(0, { bestVideo: sharedVideo, videoCount: 1 }),
|
||||
frame(6, { bestVideo: sharedVideo, videoCount: 1 })
|
||||
]);
|
||||
expect(selected.frameId).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses to guess between equally-ranked frames without visibility evidence', () => {
|
||||
expect(selectMediaFrame([
|
||||
frame(3, { parentFrameVisible: null }),
|
||||
frame(4, { parentFrameVisible: null })
|
||||
])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the exact selected frame and document after probing', async () => {
|
||||
const results = [frame(0, { bestVideo: null, videoCount: 0 }), frame(8)];
|
||||
const executeScript = vi.fn().mockResolvedValue(results);
|
||||
await expect(resolveMediaContentTarget(
|
||||
{ scripting: { executeScript } },
|
||||
42,
|
||||
{ attempts: 1, probeDelayMs: 0 }
|
||||
)).resolves.toEqual({
|
||||
frameId: 8,
|
||||
documentId: 'document-8',
|
||||
frameUrl: 'https://player-8.example/embed',
|
||||
hasVideo: true,
|
||||
scriptTarget: { tabId: 42, documentIds: ['document-8'] }
|
||||
});
|
||||
const visibilityDispatches = executeScript.mock.calls.filter(([options]) => (
|
||||
options.func?.name === 'dispatchParentFrameVisibilityProbe'
|
||||
));
|
||||
expect(visibilityDispatches).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('keeps the top target inactive when the only discovered video is hidden', async () => {
|
||||
const results = [
|
||||
frame(0, { bestVideo: video({ rendered: false }) }),
|
||||
frame(6, { parentFrameVisible: false })
|
||||
];
|
||||
const executeScript = vi.fn().mockResolvedValue(results);
|
||||
await expect(resolveMediaContentTarget(
|
||||
{ scripting: { executeScript } },
|
||||
42,
|
||||
{ attempts: 1, probeDelayMs: 0 }
|
||||
)).resolves.toEqual({
|
||||
frameId: 0,
|
||||
documentId: null,
|
||||
frameUrl: null,
|
||||
hasVideo: false,
|
||||
scriptTarget: { tabId: 42 }
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an accessible player when an unrelated child frame is denied', async () => {
|
||||
const top = frame(0, {
|
||||
bestVideo: null,
|
||||
videoCount: 0,
|
||||
embeddedFrames: [
|
||||
{
|
||||
href: 'https://player-8.example/embed',
|
||||
origin: 'https://player-8.example',
|
||||
area: 830 * 498,
|
||||
width: 830,
|
||||
height: 498,
|
||||
visible: true,
|
||||
mediaHint: true
|
||||
},
|
||||
{
|
||||
href: 'https://widget-denied.example/frame',
|
||||
origin: 'https://widget-denied.example',
|
||||
area: 300 * 250,
|
||||
width: 300,
|
||||
height: 250,
|
||||
visible: true,
|
||||
mediaHint: false
|
||||
}
|
||||
]
|
||||
});
|
||||
const player = frame(8);
|
||||
const getAllFrames = vi.fn().mockResolvedValue([
|
||||
{ frameId: 0, documentId: 'document-0' },
|
||||
{ frameId: 8, documentId: 'document-8' },
|
||||
{ frameId: 9, documentId: 'document-9' }
|
||||
]);
|
||||
const executeScript = vi.fn().mockImplementation(async ({ target, func }) => {
|
||||
const documentId = target.documentIds?.[0];
|
||||
if (documentId === 'document-9') throw new Error('Cannot access contents of the page');
|
||||
if (func?.name !== 'inspectMediaFrame') return [];
|
||||
return documentId === 'document-8' ? [player] : [top];
|
||||
});
|
||||
|
||||
await expect(resolveMediaContentTarget(
|
||||
{ scripting: { executeScript }, webNavigation: { getAllFrames } },
|
||||
42,
|
||||
{ attempts: 1, probeDelayMs: 0 }
|
||||
)).resolves.toMatchObject({
|
||||
frameId: 8,
|
||||
documentId: 'document-8',
|
||||
hasVideo: true
|
||||
});
|
||||
expect(executeScript.mock.calls.some(([options]) => options.target.allFrames === true)).toBe(false);
|
||||
expect(executeScript.mock.calls.some(([options]) => options.target.documentIds?.[0] === 'document-9')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not trust parent visibility from an older probe token', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
const fakeWindow = {
|
||||
top: {},
|
||||
location: { href: 'https://player.example/embed', origin: 'https://player.example' },
|
||||
innerWidth: 800,
|
||||
innerHeight: 450,
|
||||
__koalaParentFrameVisibility: { token: 'old-token', visible: true, area: 360000 }
|
||||
};
|
||||
const fakeDocument = {
|
||||
location: fakeWindow.location,
|
||||
defaultView: fakeWindow,
|
||||
querySelectorAll: () => []
|
||||
};
|
||||
globalThis.window = fakeWindow;
|
||||
globalThis.document = fakeDocument;
|
||||
try {
|
||||
expect(inspectMediaFrame('new-token')).toMatchObject({
|
||||
parentFrameVisible: null,
|
||||
parentFrameArea: null
|
||||
});
|
||||
} finally {
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
it('recognizes the current Google Drive youtube.googleapis.com player', async () => {
|
||||
const top = frame(0, {
|
||||
href: 'https://drive.google.com/drive/u/0/search?q=video',
|
||||
origin: 'https://drive.google.com',
|
||||
bestVideo: null,
|
||||
videoCount: 0,
|
||||
embeddedFrames: [{
|
||||
href: 'https://youtube.googleapis.com/embed/drive-file-id?origin=https%3A%2F%2Fdrive.google.com',
|
||||
origin: 'https://youtube.googleapis.com',
|
||||
area: 280 * 157,
|
||||
width: 280,
|
||||
height: 157,
|
||||
visible: true,
|
||||
depth: 1,
|
||||
mediaHint: false
|
||||
}]
|
||||
});
|
||||
const executeScript = vi.fn()
|
||||
.mockResolvedValueOnce([top])
|
||||
.mockResolvedValueOnce([top]);
|
||||
|
||||
await expect(resolveMediaContentTarget(
|
||||
{ scripting: { executeScript } },
|
||||
42,
|
||||
{ attempts: 1, probeDelayMs: 0 }
|
||||
)).rejects.toMatchObject({
|
||||
code: MEDIA_FRAME_ACCESS_REQUIRED,
|
||||
host: 'youtube.googleapis.com',
|
||||
originPattern: 'https://youtube.googleapis.com/*'
|
||||
});
|
||||
});
|
||||
|
||||
it('recognizes a YummyAnime-style nested inaccessible player origin', async () => {
|
||||
const top = frame(0, {
|
||||
href: 'https://yummyanime.tv/show.html',
|
||||
origin: 'https://yummyanime.tv',
|
||||
bestVideo: null,
|
||||
videoCount: 0,
|
||||
embeddedFrames: [{
|
||||
href: 'https://absciss.thealloha.club/?token=redacted',
|
||||
origin: 'https://absciss.thealloha.club',
|
||||
area: 830 * 498,
|
||||
width: 830,
|
||||
height: 498,
|
||||
visible: true,
|
||||
depth: 2,
|
||||
mediaHint: true
|
||||
}]
|
||||
});
|
||||
const executeScript = vi.fn()
|
||||
.mockResolvedValueOnce([top])
|
||||
.mockResolvedValueOnce([top]);
|
||||
|
||||
await expect(resolveMediaContentTarget(
|
||||
{ scripting: { executeScript } },
|
||||
43,
|
||||
{ attempts: 1, probeDelayMs: 0 }
|
||||
)).rejects.toMatchObject({
|
||||
code: MEDIA_FRAME_ACCESS_REQUIRED,
|
||||
host: 'absciss.thealloha.club',
|
||||
originPattern: 'https://absciss.thealloha.club/*'
|
||||
});
|
||||
});
|
||||
|
||||
it('requests the inaccessible player instead of selecting an accessible background video', async () => {
|
||||
const top = frame(0, {
|
||||
bestVideo: video({ background: true, controls: false, renderedArea: 900 * 506 }),
|
||||
embeddedFrames: [{
|
||||
href: 'https://player.external.example/watch',
|
||||
origin: 'https://player.external.example',
|
||||
area: 900 * 506,
|
||||
width: 900,
|
||||
height: 506,
|
||||
visible: true,
|
||||
mediaHint: true
|
||||
}]
|
||||
});
|
||||
const executeScript = vi.fn().mockResolvedValue([top]);
|
||||
await expect(resolveMediaContentTarget(
|
||||
{ scripting: { executeScript } },
|
||||
44,
|
||||
{ attempts: 1, probeDelayMs: 0 }
|
||||
)).rejects.toMatchObject({
|
||||
code: MEDIA_FRAME_ACCESS_REQUIRED,
|
||||
originPattern: 'https://player.external.example/*'
|
||||
});
|
||||
});
|
||||
|
||||
it('requests the inaccessible main player instead of selecting a larger short ad', async () => {
|
||||
const top = frame(0, {
|
||||
bestVideo: video({
|
||||
playing: true,
|
||||
controls: false,
|
||||
duration: 30,
|
||||
renderedArea: 500 * 281,
|
||||
sizeBucket: 24
|
||||
}),
|
||||
embeddedFrames: [{
|
||||
href: 'https://player.external.example/watch',
|
||||
origin: 'https://player.external.example',
|
||||
area: 900 * 506,
|
||||
width: 900,
|
||||
height: 506,
|
||||
visible: true,
|
||||
mediaHint: true
|
||||
}]
|
||||
});
|
||||
const executeScript = vi.fn().mockResolvedValue([top]);
|
||||
await expect(resolveMediaContentTarget(
|
||||
{ scripting: { executeScript } },
|
||||
44,
|
||||
{ attempts: 1, probeDelayMs: 0 }
|
||||
)).rejects.toMatchObject({
|
||||
code: MEDIA_FRAME_ACCESS_REQUIRED,
|
||||
originPattern: 'https://player.external.example/*'
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a paused long custom player over a larger inaccessible heuristic frame', async () => {
|
||||
const top = frame(0, {
|
||||
bestVideo: video({
|
||||
playing: false,
|
||||
controls: false,
|
||||
duration: 7200,
|
||||
renderedArea: 600 * 338
|
||||
}),
|
||||
embeddedFrames: [{
|
||||
href: 'https://widget.external.example/watch',
|
||||
origin: 'https://widget.external.example',
|
||||
area: 900 * 506,
|
||||
width: 900,
|
||||
height: 506,
|
||||
visible: true,
|
||||
mediaHint: true
|
||||
}]
|
||||
});
|
||||
const executeScript = vi.fn().mockResolvedValue([top]);
|
||||
await expect(resolveMediaContentTarget(
|
||||
{ scripting: { executeScript } },
|
||||
44,
|
||||
{ attempts: 1, probeDelayMs: 0 }
|
||||
)).resolves.toMatchObject({ frameId: 0, hasVideo: true });
|
||||
});
|
||||
|
||||
it('uses Firefox-compatible portless match patterns for embedded origins', async () => {
|
||||
const top = frame(0, {
|
||||
bestVideo: null,
|
||||
videoCount: 0,
|
||||
embeddedFrames: [{
|
||||
href: 'http://127.0.0.1:4173/player',
|
||||
origin: 'http://127.0.0.1:4173',
|
||||
area: 900 * 506,
|
||||
width: 900,
|
||||
height: 506,
|
||||
visible: true,
|
||||
mediaHint: true
|
||||
}]
|
||||
});
|
||||
const executeScript = vi.fn().mockResolvedValue([top]);
|
||||
await expect(resolveMediaContentTarget(
|
||||
{ scripting: { executeScript } },
|
||||
45,
|
||||
{ attempts: 1, probeDelayMs: 0 }
|
||||
)).rejects.toMatchObject({ originPattern: 'http://127.0.0.1/*' });
|
||||
});
|
||||
|
||||
it('does not request access for one large non-media iframe', async () => {
|
||||
const top = frame(0, {
|
||||
bestVideo: null,
|
||||
videoCount: 0,
|
||||
embeddedFrames: [{
|
||||
href: 'https://maps.example/view',
|
||||
origin: 'https://maps.example',
|
||||
area: 900 * 506,
|
||||
width: 900,
|
||||
height: 506,
|
||||
visible: true,
|
||||
mediaHint: false
|
||||
}]
|
||||
});
|
||||
const executeScript = vi.fn().mockResolvedValue([top]);
|
||||
await expect(resolveMediaContentTarget(
|
||||
{ scripting: { executeScript } },
|
||||
46,
|
||||
{ attempts: 1, probeDelayMs: 0 }
|
||||
)).resolves.toMatchObject({ frameId: 0, hasVideo: false });
|
||||
});
|
||||
|
||||
it('does not retain a permission prompt for a player frame that disappeared', async () => {
|
||||
const withPlayer = frame(0, {
|
||||
bestVideo: null,
|
||||
videoCount: 0,
|
||||
embeddedFrames: [{
|
||||
href: 'https://player.external.example/watch',
|
||||
origin: 'https://player.external.example',
|
||||
area: 900 * 506,
|
||||
width: 900,
|
||||
height: 506,
|
||||
visible: true,
|
||||
mediaHint: true
|
||||
}]
|
||||
});
|
||||
const withoutPlayer = frame(0, { bestVideo: null, videoCount: 0, embeddedFrames: [] });
|
||||
const executeScript = vi.fn()
|
||||
.mockResolvedValueOnce([withPlayer])
|
||||
.mockResolvedValueOnce([withoutPlayer]);
|
||||
await expect(resolveMediaContentTarget(
|
||||
{ scripting: { executeScript } },
|
||||
46,
|
||||
{ attempts: 2, retryDelayMs: 0, probeDelayMs: 0 }
|
||||
)).resolves.toMatchObject({ frameId: 0, hasVideo: false });
|
||||
});
|
||||
|
||||
it('does not request access for small or ambiguously-sized embedded frames', async () => {
|
||||
const top = frame(0, {
|
||||
bestVideo: null,
|
||||
videoCount: 0,
|
||||
embeddedFrames: [
|
||||
{ href: 'https://ad-one.example', origin: 'https://ad-one.example', area: 300 * 250, width: 300, height: 250, visible: true },
|
||||
{ href: 'https://ad-two.example', origin: 'https://ad-two.example', area: 300 * 250, width: 300, height: 250, visible: true }
|
||||
]
|
||||
});
|
||||
const executeScript = vi.fn()
|
||||
.mockResolvedValueOnce([top])
|
||||
.mockResolvedValueOnce([top]);
|
||||
await expect(resolveMediaContentTarget(
|
||||
{ scripting: { executeScript } },
|
||||
44,
|
||||
{ attempts: 1, probeDelayMs: 0 }
|
||||
)).resolves.toMatchObject({ frameId: 0, scriptTarget: { tabId: 44 } });
|
||||
});
|
||||
|
||||
it('reports ambiguity rather than controlling an arbitrary equal player', async () => {
|
||||
const results = [
|
||||
frame(3, { parentFrameVisible: null }),
|
||||
frame(4, { parentFrameVisible: null })
|
||||
];
|
||||
const executeScript = vi.fn().mockResolvedValue(results);
|
||||
await expect(resolveMediaContentTarget(
|
||||
{ scripting: { executeScript } },
|
||||
45,
|
||||
{ attempts: 1, probeDelayMs: 0 }
|
||||
)).rejects.toMatchObject({ code: MEDIA_FRAME_AMBIGUOUS });
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
export function initTabManager({
|
||||
getCurrentTabId,
|
||||
reactivateCurrentTarget,
|
||||
ensureState
|
||||
ensureState,
|
||||
sendToCurrentContent
|
||||
}) {
|
||||
chrome.storage.onChanged.addListener(async (changes, area) => {
|
||||
if (area !== 'local' || !changes.audioSettings) return;
|
||||
@@ -9,7 +10,7 @@ export function initTabManager({
|
||||
const tabId = getCurrentTabId();
|
||||
if (!tabId) return;
|
||||
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
sendToCurrentContent({
|
||||
action: 'APPLY_AUDIO_SETTINGS',
|
||||
settings: changes.audioSettings.newValue
|
||||
}).catch(() => {});
|
||||
|
||||
+2
-2
@@ -2196,7 +2196,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
failForceSyncTime();
|
||||
return;
|
||||
}
|
||||
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
|
||||
chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId }, (retryResponse) => {
|
||||
if (chrome.runtime.lastError || !retryResponse || !Number.isFinite(retryResponse.currentTime)) {
|
||||
failForceSyncTime();
|
||||
return;
|
||||
@@ -2204,7 +2204,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
sendForceSync(retryResponse.currentTime);
|
||||
});
|
||||
};
|
||||
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (response) => {
|
||||
chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId }, (response) => {
|
||||
if (Number.isFinite(response?.currentTime)) {
|
||||
sendForceSync(response.currentTime);
|
||||
return;
|
||||
|
||||
@@ -7,12 +7,15 @@ const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
|
||||
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 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("target: { tabId }");
|
||||
expect(backgroundSource).toContain('contentTarget = await resolveMediaContentTarget(chrome, tabId)');
|
||||
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/*' })");
|
||||
|
||||
@@ -27,20 +30,49 @@ describe('target tab lifecycle', () => {
|
||||
});
|
||||
|
||||
it('fully deactivates old and superseded target injections', () => {
|
||||
expect(backgroundSource).toContain("chrome.tabs.sendMessage(normalizedTabId, { type: 'TARGET_DEACTIVATE' })");
|
||||
expect(backgroundSource.match(/await deactivateTargetTab\(selectedTabId\)/g)?.length).toBeGreaterThanOrEqual(4);
|
||||
expect(backgroundSource).toContain("{ type: 'TARGET_DEACTIVATE' }");
|
||||
expect(backgroundSource).toContain('target.documentId');
|
||||
expect(backgroundSource.match(/await deactivateTargetTab\(selectedTabId,/g)?.length).toBeGreaterThanOrEqual(6);
|
||||
expect(contentSource).toContain("if (message.type === 'TARGET_DEACTIVATE')");
|
||||
expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'");
|
||||
});
|
||||
|
||||
it('binds cross-origin targets to an exact document and monitors every accessible frame', () => {
|
||||
expect(backgroundSource).toContain("files: ['media-frame-monitor.js']");
|
||||
expect(backgroundSource).toContain('const targets = await listMediaFrameScriptTargets(chrome, tabId)');
|
||||
expect(backgroundSource).toContain('One denied widget frame must not block the selected player');
|
||||
expect(backgroundSource).toContain("navigationError.code = 'media_target_navigated'");
|
||||
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');
|
||||
expect(monitorSource).toContain("const MEDIA_STATE_EVENTS = ['play', 'pause', 'loadedmetadata'");
|
||||
expect(monitorSource).toContain("node.querySelector?.('video, iframe, frame')");
|
||||
expect(manifest.permissions).toContain('webNavigation');
|
||||
expect(backgroundSource).toContain('chrome.webNavigation.onCompleted.addListener');
|
||||
});
|
||||
|
||||
it('serializes content commands and coalesces target refreshes', () => {
|
||||
expect(backgroundSource).toContain('contentCommandQueue.catch(() => {}).then(deliver)');
|
||||
expect(backgroundSource).toContain('if (mediaTargetRefreshTask && mediaTargetRefreshTabId === selectedTabId)');
|
||||
expect(backgroundSource).toContain('if (queueIfRunning) mediaTargetRefreshDirty = true');
|
||||
expect(backgroundSource).toContain('&& pass < 2');
|
||||
expect(backgroundSource).toContain('const needsFollowup = mediaTargetRefreshDirty');
|
||||
expect(backgroundSource).not.toContain('Re-elect before every remote command');
|
||||
expect(backgroundSource).toContain('await sendMessageToContentTab(tabId');
|
||||
});
|
||||
|
||||
it('tears down every persistent content-script resource', () => {
|
||||
expect(contentSource).toContain('function destroyContentScript()');
|
||||
expect(contentSource).toContain('observer.disconnect()');
|
||||
expect(contentSource).toContain('keepAlivePort.disconnect()');
|
||||
expect(contentSource).toContain('for (const video of attachedVideos)');
|
||||
expect(contentSource).toContain('for (const video of [...attachedVideos]) detachVideoListeners(video);');
|
||||
expect(contentSource).toContain("document.removeEventListener('visibilitychange', handleVisibilityChange)");
|
||||
expect(contentSource).toContain("window.removeEventListener('pagehide', handlePageHide)");
|
||||
expect(contentSource).toContain("window.removeEventListener('pageshow', handlePageShow)");
|
||||
expect(contentSource).toContain("window.removeEventListener('resize', handleMediaFrameResize)");
|
||||
expect(contentSource).toContain('chrome.storage.onChanged.removeListener(handleStorageChanged)');
|
||||
expect(contentSource).toContain('chrome.runtime.onMessage.removeListener(handleRuntimeMessage)');
|
||||
expect(contentSource).toContain('window.koalaSyncInjected = false');
|
||||
|
||||
@@ -36,6 +36,10 @@ function makeVideo(name, width, height, options = {}) {
|
||||
offsetWidth: width,
|
||||
offsetHeight: height,
|
||||
muted: options.muted ?? true,
|
||||
controls: options.controls ?? true,
|
||||
paused: options.paused ?? true,
|
||||
ended: options.ended ?? false,
|
||||
currentSrc: options.currentSrc ?? 'fixture.mp4',
|
||||
duration: options.duration ?? 0,
|
||||
currentTime: options.currentTime ?? 0,
|
||||
seekable: options.seekable ?? makeSeekable()
|
||||
@@ -66,12 +70,16 @@ const fakeDocument = {
|
||||
const VIDEO_FINDER_PARTS = [
|
||||
'findVideo',
|
||||
'collectVideoCandidates',
|
||||
'getElementRenderBox',
|
||||
'elementStylesAllowRendering',
|
||||
'isElementRendered',
|
||||
'getRenderedVideoArea',
|
||||
'getVideoSizeBucket',
|
||||
'isVideoRendered',
|
||||
'hasPlayableVideoSource',
|
||||
'isBackgroundVideo',
|
||||
'isVideoPlaying',
|
||||
'isShortUncontrolledVideo',
|
||||
'compareVideoRanks',
|
||||
'pickBestVideo'
|
||||
];
|
||||
@@ -97,9 +105,9 @@ assert.strictEqual(
|
||||
'findVideo should score Shadow DOM videos together with light DOM videos'
|
||||
);
|
||||
|
||||
// Invariant that protects every already-working single-player site: with one
|
||||
// candidate the ranking is never consulted, so no signal can turn a page that
|
||||
// used to sync into "no video found".
|
||||
// A hidden preload must not become the active controller merely because it is
|
||||
// the only video currently present. The media-frame monitor re-runs discovery
|
||||
// if its geometry becomes visible later.
|
||||
const lonelyBadCandidate = makeVideo('lonely', 0, 0, { muted: true, duration: 0 });
|
||||
lonelyBadCandidate.loop = true;
|
||||
lonelyBadCandidate.controls = false;
|
||||
@@ -115,8 +123,88 @@ const lonelyDocument = {
|
||||
|
||||
assert.strictEqual(
|
||||
findVideo(lonelyDocument),
|
||||
lonelyBadCandidate,
|
||||
'a single candidate is returned even when every ranking signal is against it'
|
||||
null,
|
||||
'a hidden single candidate is not returned as an active player'
|
||||
);
|
||||
|
||||
function attachRenderEnvironment(documentNode, elements, { frameElement = null } = {}) {
|
||||
const view = {
|
||||
innerWidth: 1000,
|
||||
innerHeight: 700,
|
||||
frameElement,
|
||||
getComputedStyle(element) {
|
||||
return element._style || { display: 'block', visibility: 'visible', opacity: '1' };
|
||||
}
|
||||
};
|
||||
documentNode.defaultView = view;
|
||||
for (const element of elements) {
|
||||
element.ownerDocument = documentNode;
|
||||
element.getBoundingClientRect = () => element._rect || {
|
||||
width: element.offsetWidth,
|
||||
height: element.offsetHeight,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: element.offsetWidth,
|
||||
bottom: element.offsetHeight
|
||||
};
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
const hiddenPlayingVideo = makeVideo('hidden-playing', 900, 506, {
|
||||
controls: true,
|
||||
paused: false,
|
||||
duration: 1200
|
||||
});
|
||||
hiddenPlayingVideo._style = { display: 'block', visibility: 'hidden', opacity: '1' };
|
||||
const visiblePausedVideo = makeVideo('visible-paused', 800, 450, {
|
||||
controls: true,
|
||||
paused: true,
|
||||
duration: 1200
|
||||
});
|
||||
const visibilityDocument = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'video') return [hiddenPlayingVideo, visiblePausedVideo];
|
||||
return [];
|
||||
}
|
||||
};
|
||||
attachRenderEnvironment(visibilityDocument, [hiddenPlayingVideo, visiblePausedVideo]);
|
||||
assert.strictEqual(
|
||||
findVideo(visibilityDocument),
|
||||
visiblePausedVideo,
|
||||
'a hidden playing preload must not outrank the visible paused player'
|
||||
);
|
||||
|
||||
const framedHiddenVideo = makeVideo('framed-hidden', 900, 506, {
|
||||
controls: true,
|
||||
paused: false,
|
||||
duration: 1200
|
||||
});
|
||||
const hiddenFrameDocument = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'video') return [framedHiddenVideo];
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const hiddenAncestorFrame = {
|
||||
offsetWidth: 900,
|
||||
offsetHeight: 506,
|
||||
_style: { display: 'block', visibility: 'hidden', opacity: '1' },
|
||||
contentDocument: hiddenFrameDocument
|
||||
};
|
||||
const hiddenFrameTopDocument = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'video') return [];
|
||||
if (selector === 'iframe, frame') return [hiddenAncestorFrame];
|
||||
return [];
|
||||
}
|
||||
};
|
||||
attachRenderEnvironment(hiddenFrameTopDocument, [hiddenAncestorFrame]);
|
||||
attachRenderEnvironment(hiddenFrameDocument, [framedHiddenVideo], { frameElement: hiddenAncestorFrame });
|
||||
assert.strictEqual(
|
||||
findVideo(hiddenFrameTopDocument),
|
||||
null,
|
||||
'a video inside a hidden same-origin ancestor frame must not be selected'
|
||||
);
|
||||
|
||||
// Same-origin player iframe (jkanime.net): the top document has no <video>,
|
||||
|
||||
@@ -27,16 +27,51 @@ async function selectTargetTab(context, extensionId, pageUrl) {
|
||||
|
||||
async function sendServerCommand(context, extensionId, tabId, action, payload) {
|
||||
return withExtensionPage(context, extensionId, page => page.evaluate(async ({ tabId, action, payload }) => {
|
||||
return chrome.tabs.sendMessage(tabId, {
|
||||
type: 'SERVER_COMMAND',
|
||||
return chrome.runtime.sendMessage({
|
||||
type: 'CONTENT_EVENT',
|
||||
action,
|
||||
payload,
|
||||
actionTimestamp: Date.now(),
|
||||
commandSenderId: 'e2e'
|
||||
payload: payload || {},
|
||||
expectedTabId: tabId
|
||||
});
|
||||
}, { tabId, action, payload }));
|
||||
}
|
||||
|
||||
async function sendServerCommandBurst(context, extensionId, tabId, commands) {
|
||||
return withExtensionPage(context, extensionId, page => page.evaluate(async ({ tabId, commands }) => {
|
||||
return Promise.all(commands.map(({ action, payload }) => chrome.runtime.sendMessage({
|
||||
type: 'CONTENT_EVENT',
|
||||
action,
|
||||
payload: payload || {},
|
||||
expectedTabId: tabId
|
||||
})));
|
||||
}, { tabId, commands }));
|
||||
}
|
||||
|
||||
async function getExtensionState(context, extensionId, message) {
|
||||
return withExtensionPage(context, extensionId, page => page.evaluate(
|
||||
request => chrome.runtime.sendMessage(request),
|
||||
message
|
||||
));
|
||||
}
|
||||
|
||||
async function getFrameMonitorState(context, extensionId, pageUrl, frameUrlPart) {
|
||||
return withExtensionPage(context, extensionId, page => page.evaluate(async ({ pageUrl, frameUrlPart }) => {
|
||||
const [tab] = await chrome.tabs.query({ url: pageUrl });
|
||||
if (!tab) throw new Error(`no tab matched ${pageUrl}`);
|
||||
const frames = await chrome.webNavigation.getAllFrames({ tabId: tab.id });
|
||||
const frame = frames.find(candidate => candidate.url.includes(frameUrlPart));
|
||||
if (!frame) throw new Error(`no frame matched ${frameUrlPart}`);
|
||||
const target = frame.documentId
|
||||
? { tabId: tab.id, documentIds: [frame.documentId] }
|
||||
: { tabId: tab.id, frameIds: [frame.frameId] };
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target,
|
||||
func: () => typeof window.__koalaMediaFrameMonitorCleanup
|
||||
});
|
||||
return result?.result;
|
||||
}, { pageUrl, frameUrlPart }));
|
||||
}
|
||||
|
||||
test('injects into the target tab and attaches to a same-origin frame player', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/iframe-player.html`;
|
||||
const page = await context.newPage();
|
||||
@@ -67,7 +102,8 @@ test('applies remote play, pause and seek to the framed player', async ({ contex
|
||||
return video ? video.dataset.koalaAttached : null;
|
||||
})).toBe('true');
|
||||
|
||||
await sendServerCommand(context, extensionId, tabId, 'play');
|
||||
const playResponse = await sendServerCommand(context, extensionId, tabId, 'play');
|
||||
expect(playResponse).toMatchObject({ status: 'ok_solo' });
|
||||
await expect.poll(() => page.evaluate(FRAMED_VIDEO_PAUSED), { message: 'remote play should start playback' }).toBe(false);
|
||||
|
||||
await sendServerCommand(context, extensionId, tabId, 'pause');
|
||||
@@ -159,6 +195,271 @@ test('re-attaches when a nested player frame swaps its document', async ({ conte
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
test('moves local event listeners after a CSS-only player switch', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/player-css-switch.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||
await selectTargetTab(context, extensionId, url);
|
||||
|
||||
await expect.poll(() => page.locator('#first').getAttribute('data-koala-attached')).toBe('true');
|
||||
await page.waitForTimeout(250);
|
||||
const beforeBurst = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
||||
await page.evaluate(() => {
|
||||
const first = document.getElementById('first');
|
||||
first.dispatchEvent(new window.Event('play'));
|
||||
first.dispatchEvent(new window.Event('pause'));
|
||||
window.switchPlayer();
|
||||
});
|
||||
await expect.poll(async () => {
|
||||
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
||||
return status.lastActionState.action === 'play'
|
||||
&& status.lastActionState.timestamp > beforeBurst.lastActionState.timestamp;
|
||||
}).toBe(true);
|
||||
const leading = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
||||
await expect.poll(
|
||||
() => page.locator('#second').getAttribute('data-koala-attached'),
|
||||
{ message: 'attribute-only visibility changes must move the active controller' }
|
||||
).toBe('true');
|
||||
expect(await page.locator('#first').getAttribute('data-koala-attached')).toBeNull();
|
||||
const afterSwitch = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
||||
expect(afterSwitch.lastActionState.action).toBe('play');
|
||||
expect(afterSwitch.lastActionState.timestamp).toBe(leading.lastActionState.timestamp);
|
||||
|
||||
const before = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
||||
await page.locator('#first').evaluate(video => video.dispatchEvent(new window.Event('play')));
|
||||
await page.waitForTimeout(250);
|
||||
const afterStale = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
||||
expect(afterStale.lastActionState.timestamp).toBe(before.lastActionState.timestamp);
|
||||
|
||||
await page.locator('#second').evaluate(video => video.dispatchEvent(new window.Event('play')));
|
||||
await expect.poll(async () => {
|
||||
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
||||
return status.lastActionState.timestamp;
|
||||
}).toBeGreaterThan(afterStale.lastActionState.timestamp);
|
||||
});
|
||||
|
||||
test('re-elects after a wrapper-only change inside an unselected cross-origin frame', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/cross-origin-internal-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('slot=first'));
|
||||
const second = page.frames().find(frame => frame.url().includes('slot=second'));
|
||||
await selectTargetTab(context, extensionId, url);
|
||||
|
||||
await expect.poll(() => first.locator('video').getAttribute('data-koala-attached')).toBe('true');
|
||||
expect(await second.locator('video').getAttribute('data-koala-attached')).toBeNull();
|
||||
await page.evaluate(() => window.switchInternalPlayer());
|
||||
await expect.poll(
|
||||
() => second.locator('video').getAttribute('data-koala-attached'),
|
||||
{ message: 'an unselected frame must announce its internally-visible player' }
|
||||
).toBe('true');
|
||||
expect(await first.locator('video').getAttribute('data-koala-attached')).toBeNull();
|
||||
});
|
||||
|
||||
test('targets a visible nested cross-origin player and keeps top-page debug context', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/cross-origin-nested.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||
|
||||
const visiblePlayer = () => page.frames().find(frame => frame.url().includes('/frames/player-frame.html?visible=1'));
|
||||
await expect.poll(async () => visiblePlayer()?.locator('video').getAttribute('src')).toContain('player-480p-12s.mp4');
|
||||
|
||||
const { tabId, response } = await selectTargetTab(context, extensionId, url);
|
||||
expect(response).toMatchObject({ status: 'ok' });
|
||||
expect(response.frameId).toBeGreaterThan(0);
|
||||
|
||||
await expect.poll(() => visiblePlayer()?.locator('video').getAttribute('data-koala-attached')).toBe('true');
|
||||
const hiddenPlayer = page.frames().find(frame => frame.url().includes('/frames/player-frame-2.html?hidden=1'));
|
||||
expect(await hiddenPlayer.locator('video').getAttribute('data-koala-attached')).toBeNull();
|
||||
|
||||
const playResponse = await sendServerCommand(context, extensionId, tabId, 'play');
|
||||
expect(playResponse).toMatchObject({ status: 'ok_solo' });
|
||||
await expect.poll(() => visiblePlayer().locator('video').evaluate(video => video.paused)).toBe(false);
|
||||
await sendServerCommand(context, extensionId, tabId, 'pause');
|
||||
await expect.poll(() => visiblePlayer().locator('video').evaluate(video => video.paused)).toBe(true);
|
||||
await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 6 });
|
||||
await expect.poll(() => visiblePlayer().locator('video').evaluate(video => video.currentTime)).toBeGreaterThan(5);
|
||||
|
||||
await sendServerCommand(context, extensionId, tabId, 'pause');
|
||||
await sendServerCommandBurst(context, extensionId, tabId, [
|
||||
{ action: 'seek', payload: { targetTime: 8 } },
|
||||
{ action: 'play', payload: { currentTime: 8 } }
|
||||
]);
|
||||
await expect.poll(() => visiblePlayer().locator('video').evaluate(video => ({
|
||||
paused: video.paused,
|
||||
currentTime: video.currentTime
|
||||
}))).toMatchObject({ paused: false, currentTime: expect.any(Number) });
|
||||
await expect.poll(() => visiblePlayer().locator('video').evaluate(video => video.currentTime)).toBeGreaterThan(7);
|
||||
|
||||
const state = await getExtensionState(context, extensionId, { type: 'GET_VIDEO_STATE', tabId });
|
||||
expect(state).toMatchObject({
|
||||
found: true,
|
||||
url,
|
||||
pageTitle: 'Nested cross-origin player',
|
||||
frameOrigin: new URL(baseURL.replace('localhost', '127.0.0.1')).origin,
|
||||
inIframe: true
|
||||
});
|
||||
});
|
||||
|
||||
test('re-elects the visible cross-origin player after an iframe switch', 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, response } = await selectTargetTab(context, extensionId, url);
|
||||
expect(response).toMatchObject({ status: 'ok' });
|
||||
const firstFrameId = response.frameId;
|
||||
|
||||
await expect.poll(() => first.locator('video').getAttribute('data-koala-attached')).toBe('true');
|
||||
await page.evaluate(() => window.switchPlayer());
|
||||
await expect.poll(() => second.locator('video').getAttribute('data-koala-attached')).toBe('true');
|
||||
await expect.poll(async () => {
|
||||
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
||||
return status.targetFrameId;
|
||||
}).not.toBe(firstFrameId);
|
||||
|
||||
const playResponse = await sendServerCommand(context, extensionId, tabId, 'play');
|
||||
expect(playResponse).toMatchObject({ status: 'ok_solo' });
|
||||
await expect.poll(() => second.locator('video').evaluate(video => video.paused)).toBe(false);
|
||||
expect(await first.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();
|
||||
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 { tabId, response } = await selectTargetTab(context, extensionId, url);
|
||||
expect(response).toMatchObject({ status: 'ok' });
|
||||
await expect.poll(() => first.locator('video').getAttribute('data-koala-attached')).toBe('true');
|
||||
|
||||
await page.evaluate(() => window.startGeometryChurn());
|
||||
try {
|
||||
await page.waitForTimeout(300);
|
||||
await sendServerCommand(context, extensionId, tabId, 'play', { currentTime: 1 });
|
||||
await expect.poll(
|
||||
() => first.locator('video').evaluate(video => video.paused),
|
||||
{ timeout: 3000, message: 'bounded refresh passes must not starve commands' }
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await page.evaluate(() => window.stopGeometryChurn());
|
||||
}
|
||||
});
|
||||
|
||||
test('deactivates media monitors in child frames after a target-tab switch', async ({ context, extensionId, baseURL }) => {
|
||||
const firstUrl = `${baseURL}/pages/cross-origin-nested.html`;
|
||||
const secondUrl = `${baseURL}/pages/simple-player.html`;
|
||||
const firstPage = await context.newPage();
|
||||
const secondPage = await context.newPage();
|
||||
await firstPage.goto(firstUrl);
|
||||
await firstPage.waitForFunction(() => window.__fixtureReady === true);
|
||||
await secondPage.goto(secondUrl);
|
||||
await secondPage.waitForFunction(() => window.__fixtureReady === true);
|
||||
await selectTargetTab(context, extensionId, firstUrl);
|
||||
await expect.poll(() => getFrameMonitorState(
|
||||
context,
|
||||
extensionId,
|
||||
firstUrl,
|
||||
'/frames/player-frame.html?visible=1'
|
||||
)).toBe('function');
|
||||
await selectTargetTab(context, extensionId, secondUrl);
|
||||
await expect.poll(
|
||||
() => getFrameMonitorState(
|
||||
context,
|
||||
extensionId,
|
||||
firstUrl,
|
||||
'/frames/player-frame.html?visible=1'
|
||||
),
|
||||
{ message: 'child-frame monitor should be destroyed with the old target tab' }
|
||||
).toBe('undefined');
|
||||
});
|
||||
|
||||
test('re-attaches after a selected cross-origin frame navigates', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/cross-origin-reloading.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('generation=first'));
|
||||
const { tabId, response } = await selectTargetTab(context, extensionId, url);
|
||||
expect(response).toMatchObject({ status: 'ok' });
|
||||
await expect.poll(() => first.locator('video').getAttribute('data-koala-attached')).toBe('true');
|
||||
const firstDocumentId = (await getExtensionState(context, extensionId, { type: 'GET_STATUS' })).targetDocumentId;
|
||||
|
||||
await page.evaluate(() => window.reloadPlayer());
|
||||
await expect.poll(() => page.frames().find(frame => frame.url().includes('generation=second'))?.locator('video').getAttribute('data-koala-attached')).toBe('true');
|
||||
await expect.poll(async () => {
|
||||
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
||||
return status.targetDocumentId;
|
||||
}).not.toBe(firstDocumentId);
|
||||
|
||||
const state = await getExtensionState(context, extensionId, { type: 'GET_VIDEO_STATE', tabId });
|
||||
expect(state).toMatchObject({ found: true, inIframe: true });
|
||||
});
|
||||
|
||||
test('discovers a video inserted late inside a cross-origin frame', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/cross-origin-late.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||
const lateFrame = page.frames().find(frame => frame.url().includes('/frames/late-player-frame.html'));
|
||||
const { response } = await selectTargetTab(context, extensionId, url);
|
||||
expect(response).toMatchObject({ status: 'ok', hasVideo: false });
|
||||
|
||||
await expect.poll(
|
||||
() => lateFrame.locator('#late-player').getAttribute('data-koala-attached'),
|
||||
{ timeout: 12_000, message: 'late cross-origin video should trigger target re-election' }
|
||||
).toBe('true');
|
||||
await expect.poll(async () => {
|
||||
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
||||
return status.targetHasVideo;
|
||||
}).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects a cross-origin player hidden three frame levels deep', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/deep-hidden-cross-origin.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||
const hiddenFrame = page.frames().find(frame => frame.url().includes('deep=hidden'));
|
||||
await expect.poll(() => hiddenFrame?.locator('video').getAttribute('src')).toContain('player-1080p-30s.mp4');
|
||||
|
||||
const { response } = await selectTargetTab(context, extensionId, url);
|
||||
expect(response).toMatchObject({ status: 'ok', frameId: 0, hasVideo: false });
|
||||
expect(await hiddenFrame.locator('video').getAttribute('data-koala-attached')).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects a player inside a hidden same-origin frame', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/hidden-same-origin.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||
const hiddenFrame = page.frames().find(frame => frame.url().includes('hidden=same-origin'));
|
||||
await expect.poll(() => hiddenFrame?.locator('video').getAttribute('src')).toContain('player-480p-12s.mp4');
|
||||
|
||||
const { response } = await selectTargetTab(context, extensionId, url);
|
||||
expect(response).toMatchObject({ status: 'ok', frameId: 0, hasVideo: false });
|
||||
expect(await hiddenFrame.locator('video').getAttribute('data-koala-attached')).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects a hidden cross-origin player after its iframe URL redirects', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/hidden-redirect-cross-origin.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||
const redirectedFrame = page.frames().find(frame => frame.url().includes('redirected=hidden'));
|
||||
await expect.poll(() => redirectedFrame?.locator('video').getAttribute('src')).toContain('player-1080p-30s.mp4');
|
||||
|
||||
const { response } = await selectTargetTab(context, extensionId, url);
|
||||
expect(response).toMatchObject({ status: 'ok', frameId: 0, hasVideo: false });
|
||||
expect(await redirectedFrame.locator('video').getAttribute('data-koala-attached')).toBeNull();
|
||||
});
|
||||
|
||||
function FRAMED_VIDEO_PAUSED() {
|
||||
return document.querySelector('iframe').contentDocument.querySelector('video').paused;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ const TYPES = {
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const requested = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
|
||||
if (requested === '/redirect/hidden-player') {
|
||||
res.writeHead(302, {
|
||||
Location: `http://127.0.0.1:${port}/pages/frames/player-frame-2.html?redirected=hidden`,
|
||||
'Cache-Control': 'no-store'
|
||||
}).end();
|
||||
return;
|
||||
}
|
||||
const filePath = path.resolve(root, `.${requested}`);
|
||||
|
||||
if (!filePath.startsWith(root + path.sep)) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Cross-origin internal wrapper switch</title>
|
||||
<style>
|
||||
iframe { width: 640px; height: 360px; border: 0; display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe id="first" allow="autoplay; fullscreen"></iframe>
|
||||
<iframe id="second" allow="autoplay; fullscreen"></iframe>
|
||||
<script>
|
||||
const playerOrigin = `http://127.0.0.1:${location.port}`;
|
||||
const first = document.getElementById('first');
|
||||
const second = document.getElementById('second');
|
||||
first.src = `${playerOrigin}/pages/frames/wrapper-switch-player.html?slot=first&visible=1`;
|
||||
second.src = `${playerOrigin}/pages/frames/wrapper-switch-player.html?slot=second&visible=0`;
|
||||
let loaded = 0;
|
||||
const ready = () => {
|
||||
loaded++;
|
||||
if (loaded === 2) window.__fixtureReady = true;
|
||||
};
|
||||
first.addEventListener('load', ready);
|
||||
second.addEventListener('load', ready);
|
||||
window.switchInternalPlayer = () => {
|
||||
first.contentWindow.postMessage('koala-hide', playerOrigin);
|
||||
second.contentWindow.postMessage('koala-show', playerOrigin);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Late cross-origin player</title>
|
||||
<style>body { margin: 0; } iframe { border: 0; }</style>
|
||||
<iframe id="player" width="860" height="490" allowfullscreen></iframe>
|
||||
<script>
|
||||
const player = document.getElementById('player');
|
||||
player.src = `http://127.0.0.1:${location.port}/pages/frames/late-player-frame.html`;
|
||||
player.addEventListener('load', () => { window.__fixtureReady = true; }, { once: true });
|
||||
</script>
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Nested cross-origin player</title>
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
iframe { border: 0; }
|
||||
#hidden-player { display: none; }
|
||||
</style>
|
||||
<iframe id="wrapper" width="870" height="500" src="frames/cross-origin-wrapper.html" allowfullscreen></iframe>
|
||||
<iframe id="hidden-player" width="870" height="500" allowfullscreen></iframe>
|
||||
<script>
|
||||
const hidden = document.getElementById('hidden-player');
|
||||
hidden.src = `http://127.0.0.1:${location.port}/pages/frames/player-frame-2.html?hidden=1`;
|
||||
Promise.all([
|
||||
new Promise(resolve => document.getElementById('wrapper').addEventListener('load', resolve, { once: true })),
|
||||
new Promise(resolve => hidden.addEventListener('load', resolve, { once: true }))
|
||||
]).then(() => { window.__fixtureReady = true; });
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Reloading cross-origin player</title>
|
||||
<style>body { margin: 0; } iframe { border: 0; }</style>
|
||||
<iframe id="player" width="860" height="490" allowfullscreen></iframe>
|
||||
<script>
|
||||
const player = document.getElementById('player');
|
||||
player.src = `http://127.0.0.1:${location.port}/pages/frames/player-frame.html?generation=first`;
|
||||
player.addEventListener('load', () => { window.__fixtureReady = true; }, { once: true });
|
||||
window.reloadPlayer = () => {
|
||||
player.src = `http://127.0.0.1:${location.port}/pages/frames/player-frame-2.html?generation=second`;
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Switching cross-origin players</title>
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
iframe { border: 0; }
|
||||
#second { display: none; }
|
||||
</style>
|
||||
<iframe id="first" width="860" height="490" allowfullscreen></iframe>
|
||||
<iframe id="second" width="860" height="490" allowfullscreen></iframe>
|
||||
<script>
|
||||
const first = document.getElementById('first');
|
||||
const second = document.getElementById('second');
|
||||
first.src = `http://127.0.0.1:${location.port}/pages/frames/player-frame.html?slot=first`;
|
||||
second.src = `http://127.0.0.1:${location.port}/pages/frames/player-frame-2.html?slot=second`;
|
||||
Promise.all([
|
||||
new Promise(resolve => first.addEventListener('load', resolve, { once: true })),
|
||||
new Promise(resolve => second.addEventListener('load', resolve, { once: true }))
|
||||
]).then(() => { window.__fixtureReady = true; });
|
||||
window.switchPlayer = () => {
|
||||
first.style.visibility = 'hidden';
|
||||
first.style.position = 'absolute';
|
||||
first.style.left = '-10000px';
|
||||
second.style.display = 'block';
|
||||
};
|
||||
let geometryChurn = null;
|
||||
window.startGeometryChurn = () => {
|
||||
let wide = false;
|
||||
geometryChurn = setInterval(() => {
|
||||
wide = !wide;
|
||||
first.style.width = wide ? '840px' : '800px';
|
||||
}, 80);
|
||||
};
|
||||
window.stopGeometryChurn = () => {
|
||||
clearInterval(geometryChurn);
|
||||
geometryChurn = null;
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Deep hidden cross-origin player</title>
|
||||
<style>body { margin: 0; } iframe { border: 0; } #hidden-wrapper { visibility: hidden; }</style>
|
||||
<iframe id="hidden-wrapper" width="870" height="500" src="frames/deep-wrapper-1.html" allowfullscreen></iframe>
|
||||
<script>
|
||||
document.getElementById('hidden-wrapper').addEventListener('load', () => {
|
||||
window.__fixtureReady = true;
|
||||
}, { once: true });
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Cross-origin wrapper</title>
|
||||
<style>body { margin: 0; } iframe { border: 0; }</style>
|
||||
<iframe id="inner-player" width="860" height="490" allowfullscreen></iframe>
|
||||
<script>
|
||||
document.getElementById('inner-player').src =
|
||||
`http://127.0.0.1:${location.port}/pages/frames/player-frame.html?visible=1`;
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Deep wrapper 1</title>
|
||||
<style>body { margin: 0; } iframe { border: 0; }</style>
|
||||
<iframe width="860" height="490" src="deep-wrapper-2.html" allowfullscreen></iframe>
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Deep wrapper 2</title>
|
||||
<style>body { margin: 0; } iframe { border: 0; }</style>
|
||||
<iframe id="deep-player" width="850" height="480" allowfullscreen></iframe>
|
||||
<script>
|
||||
document.getElementById('deep-player').src =
|
||||
`http://127.0.0.1:${location.port}/pages/frames/player-frame-2.html?deep=hidden`;
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Late player frame</title>
|
||||
<style>body { margin: 0; }</style>
|
||||
<script>
|
||||
setTimeout(() => {
|
||||
const video = document.createElement('video');
|
||||
video.id = 'late-player';
|
||||
video.width = 854;
|
||||
video.height = 480;
|
||||
video.controls = true;
|
||||
video.src = '../../media/player-480p-12s.mp4';
|
||||
document.body.append(video);
|
||||
}, 8000);
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Wrapper-switch player</title>
|
||||
<style>
|
||||
html, body { margin: 0; }
|
||||
#wrapper.hidden { visibility: hidden; }
|
||||
video { width: 640px; height: 360px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrapper">
|
||||
<video id="player" controls preload="auto"></video>
|
||||
</div>
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const wrapper = document.getElementById('wrapper');
|
||||
const player = document.getElementById('player');
|
||||
wrapper.classList.toggle('hidden', params.get('visible') !== '1');
|
||||
player.src = params.get('slot') === 'second'
|
||||
? '../../media/player-1080p-30s.mp4'
|
||||
: '../../media/player-480p-12s.mp4';
|
||||
window.addEventListener('message', event => {
|
||||
if (event.data === 'koala-show') wrapper.classList.remove('hidden');
|
||||
if (event.data === 'koala-hide') wrapper.classList.add('hidden');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Hidden redirected cross-origin player</title>
|
||||
<style>body { margin: 0; } iframe { border: 0; visibility: hidden; }</style>
|
||||
<iframe id="player" width="860" height="490" allowfullscreen></iframe>
|
||||
<script>
|
||||
const player = document.getElementById('player');
|
||||
player.src = `http://localhost:${location.port}/redirect/hidden-player`;
|
||||
player.addEventListener('load', () => { window.__fixtureReady = true; }, { once: true });
|
||||
</script>
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Hidden same-origin frame</title>
|
||||
<style>
|
||||
iframe { width: 800px; height: 450px; border: 0; visibility: hidden; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe src="frames/player-frame.html?hidden=same-origin" allow="autoplay; fullscreen"></iframe>
|
||||
<script>window.__fixtureReady = true;</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>CSS player switch</title>
|
||||
<style>
|
||||
video { width: 640px; height: 360px; }
|
||||
#second { visibility: hidden; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<video id="first" controls preload="auto" src="../media/player-480p-12s.mp4"></video>
|
||||
<video id="second" controls preload="auto" src="../media/player-1080p-30s.mp4"></video>
|
||||
<script>
|
||||
window.switchPlayer = () => {
|
||||
document.getElementById('first').style.visibility = 'hidden';
|
||||
document.getElementById('second').style.visibility = 'visible';
|
||||
};
|
||||
window.__fixtureReady = true;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user