mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-28 03:27:10 +00:00
fix(extension): support cross-origin media frames
This commit is contained in:
+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');
|
||||
|
||||
Reference in New Issue
Block a user