fix: detect videos inside same-origin player iframes

Sites like jkanime.net render the real <video> inside a first-party
iframe, so the top document had zero video elements and the content
script reported "NO VIDEO ELEMENT".

findVideo() now descends into reachable frame documents, the
MutationObserver registers those documents too (frame mutations never
bubble to the parent), and frame load events re-trigger the scan so a
late-loading player is still picked up. Debug reports count videos
across frames and expose an "In Iframe" flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
KoalaDev
2026-08-14 06:36:07 +02:00
parent 48d6c1dc0b
commit 809301b641
3 changed files with 130 additions and 5 deletions
+82 -4
View File
@@ -567,7 +567,7 @@
};
tryOnce();
}
@@ -575,7 +575,21 @@
// Buffer-aware snap-back (#3). An involuntary pause/seek often coincides with the
// player buffering — and a player can't actually play while it's stalled. Snapping
// immediately just fights the buffer (seek → re-buffer → another pause → …), which
// looks like stutter. Instead: if the player isn't ready, wait until it can play
// (readyState>=3, not seeking), then snap ONCE to the host's *current* position
// (re-queried, since the captured target may be stale by the time buffering ends).
// Player-agnostic on purpose — we can't enumerate every site, so this must be safe
// regardless of whether a given player fires 'pause' or only 'waiting'.
function hcmDeferredSnapBack() {
if (hcmDeferredSnapPending) return; // already waiting — don't stack polls
hcmDeferredSnapPending = true;
@@ -602,6 +616,29 @@
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;
@@ -1239,7 +1276,9 @@
const t = chain.dryGain.context.currentTime;
rampGain(chain.dryGain, 1, t);
rampGain(chain.compGain, 0, t);
rampGain(chain.outputGain, 1, t);
chain.limiter.threshold.setValueAtTime(0, t);
chain.active = false;
chain.signature = '';
reportLog('Audio processing disabled', 'info');
}
@@ -1258,7 +1297,7 @@
const mergedSettings = mergeAudioSettings(settings);
const compressorEnabled = mergedSettings.compressor?.enabled === true;
const boostDb = normalizeBoostDb(mergedSettings.boostDb);
if (compressorEnabled) {
if (!mergedSettings.enabled || (!compressorEnabled && boostDb === 0)) {
applyAudioBypass(videoEl);
return;
}
@@ -1330,6 +1369,7 @@
if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`;
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
@@ -1339,6 +1379,7 @@
function sameEpisode(titleA, titleB) {
if (!titleA && !titleB) return true;
@@ -1502,6 +1543,7 @@
}
function startLobbyPoll(expectedTitle) {
stopLobbyPoll();
@@ -1511,6 +1553,7 @@
// 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
@@ -1625,6 +1668,7 @@
// Fallback for native HTML5
if (action === EVENTS.PLAY) {
_setSuppress('playing');
video.play().catch((e) => {
@@ -1646,6 +1690,34 @@
}
} 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);
seekPollTimers.delete(timer);
resolve(false);
return;
}
const video = findVideo(); // Re-query DOM on every iteration
if (!video) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(false);
return;
}
@@ -1657,6 +1729,7 @@
const ready = video.readyState >= 3 && timeDiff < 2.0;
if (ready) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(true);
} else if (elapsed >= timeoutMs) {
clearInterval(timer);
@@ -1763,6 +1836,11 @@
}
// Background asks for an immediate state push (e.g. the first peer just
// joined while we were solo) so the newcomer syncs without waiting.
if (message.type === 'REQUEST_HEARTBEAT') {
sendHeartbeat();
+2 -1
View File
@@ -2565,7 +2565,7 @@ elements.copyLogs.addEventListener('click', () => {
if (vs.pageTitle) lines.push(`- **Title:** ${vs.pageTitle}`);
if (vs.url) lines.push(`- **URL:** ${vs.url}`);
if (vs.platform) lines.push(`- **Platform:** ${safe(vs.platform, '?')}`);
lines.push(`- **Video Count:** ${safe(vs.videoCount, 0)} | **Shadow DOM:** ${vs.inShadowDom ? 'YES' : 'NO'}`);
lines.push(`- **Video Count:** ${safe(vs.videoCount, 0)} | **Shadow DOM:** ${vs.inShadowDom ? 'YES' : 'NO'} | **In Iframe:** ${vs.inIframe ? 'YES' : 'NO'}`);
lines.push('');
// Multi-video overview
@@ -2616,6 +2616,7 @@ elements.copyLogs.addEventListener('click', () => {
lines.push('- **Found:** \u274C NO VIDEO ELEMENT');
if (vs.videoCount != null) lines.push(`- **Video Tags:** ${vs.videoCount}`);
if (vs.inShadowDom != null) lines.push(`- **Shadow DOM:** ${vs.inShadowDom ? 'YES (checked)' : 'NO'}`);
if (vs.inIframe != null) lines.push(`- **In Iframe:** ${vs.inIframe ? 'YES' : 'NO'}`);
if (vs.metadata) {
if (vs.metadata.title) lines.push(`- **MediaSession Title:** "${vs.metadata.title}"`);
if (vs.metadata.artist) lines.push(`- **MediaSession Artist:** "${vs.metadata.artist}"`);
+46
View File
@@ -71,6 +71,52 @@ assert.strictEqual(
'findVideo should score Shadow DOM videos together with light DOM videos'
);
// Same-origin player iframe (jkanime.net): the top document has no <video>,
// the real player lives inside the frame document.
const framedPlayer = makeVideo('framed-player', 1280, 720, { muted: false, duration: 1400 });
const frameDocument = {
querySelectorAll(selector) {
if (selector === 'video') return [framedPlayer];
return [];
}
};
const playerFrame = { contentDocument: frameDocument };
const framedTopDocument = {
querySelectorAll(selector) {
if (selector === 'video') return [];
if (selector === 'iframe, frame') return [playerFrame];
return [];
}
};
assert.strictEqual(
findVideo(framedTopDocument),
framedPlayer,
'findVideo should descend into same-origin player iframes'
);
// A cross-origin frame throws on contentDocument and must not break the scan.
const crossOriginFrame = {
get contentDocument() { throw new Error('blocked by same-origin policy'); }
};
const crossOriginTopDocument = {
querySelectorAll(selector) {
if (selector === 'video') return [lightPreview];
if (selector === 'iframe, frame') return [crossOriginFrame];
return [];
}
};
assert.strictEqual(
findVideo(crossOriginTopDocument),
lightPreview,
'findVideo should skip unreachable cross-origin frames'
);
function makeDocument(nodes = []) {
return {
querySelectorAll() { return nodes; }