fix(extension): support cross-origin media frames

This commit is contained in:
KoalaDev
2026-08-17 16:49:53 +02:00
parent 77bdf21405
commit 082b69f509
31 changed files with 2858 additions and 263 deletions
+264 -94
View File
@@ -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) {