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