From 75a9ba5d3d63a8a29779de5ba0bcaa7ede4f10df Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:15:10 +0200 Subject: [PATCH] fix(extension): control nested players without permission prompts or churn Google Drive and YummyAnime host their player in a cross-origin iframe. The 3.1.2 targeting work reached those frames but misdiagnosed and destabilized them in four separate ways. No manifest permission is added or restored; webNavigation stays removed. Access diagnosis was inferred, not measured. Every frame probe error was swallowed, and any origin that failed to answer was reported as missing host access. A slow or still-loading player frame therefore produced "Host access required for youtube.googleapis.com" for an origin the extension already held. The resolver now asks permissions.contains() before raising an access error, and treats a granted-but-unresponsive origin as a retry, not a user decision. Probes were unbounded. Every executeScript in the resolver now runs under a timeout, so one unreachable frame can no longer stall an activation, and the retry budget drops from eight passes to three. The chat overlay followed the player into its frame, which rendered it on top of the video and scoped closing and minimizing to that frame. It is now always installed in the tab's top document, with all chat traffic routed to frame 0, while only the playback controller goes into the selected media frame. Nested targets reactivated continuously. Every heartbeat and content event revalidated the target with a full teardown and reinjection, and the media monitor treated ordinary play, pause and buffering as frame layout changes. Both paths now reactivate only when the selected frame or document actually moves. Also restores the audio-route retention that keeps a deselected tab audible: createMediaElementSource() can only be called once per element, so a reinjected content script must adopt the existing route rather than rebuild it. Verified with 90 unit tests, 40 browser E2E tests including two new Drive-shaped fixtures that assert the controller lands in the player frame while the chat stays in the top document, and npm run verify. Co-Authored-By: Claude Opus 5 --- extension/background.js | 157 +- extension/content.js | 2825 +++++++++-------- extension/media-frame-monitor.js | 11 +- extension/media-frame-target.js | 132 +- extension/media-frame-target.test.mjs | 107 + extension/target-tab-lifecycle.test.mjs | 42 +- tests/e2e/extension.spec.mjs | 83 + .../fixtures/pages/drive-style-player.html | 23 + 8 files changed, 1933 insertions(+), 1447 deletions(-) create mode 100644 tests/e2e/fixtures/pages/drive-style-player.html diff --git a/extension/background.js b/extension/background.js index 179908f..1747f78 100644 --- a/extension/background.js +++ b/extension/background.js @@ -653,6 +653,18 @@ function sendMessageToCurrentContent(message, callback = null) { ); } +/** + * Chat is page UI, not player UI. The controlled video can live in a nested + * cross-origin frame (Drive, YummyAnime), but the overlay always belongs to the + * tab's top document: inside the player frame it renders on top of the video, + * and closing or minimizing it only affects that frame. + */ +function sendMessageToChatOverlay(message) { + const tabId = normalizeTabId(currentTabId); + if (tabId === null) return Promise.reject(new Error('No target tab selected')); + return sendMessageToFrame(tabId, 0, message); +} + function sendMessageToContentTab(tabId, message, callback = null) { if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) { return sendMessageToCurrentContent(message, callback); @@ -1140,7 +1152,7 @@ function sendChatActivity(action, senderId, timestamp = Date.now()) { if (!entry) return; if (storageInitialized) chrome.storage.session.set({ chatActivityTimeline: chatActivityStore.snapshot() }).catch(() => {}); if (!currentTabId) return; - sendMessageToCurrentContent({ + sendMessageToChatOverlay({ type: 'CHAT_EVENT', event: entry }).catch(error => addLog(`Chat activity delivery failed: ${error.message}`, 'warn')); @@ -1351,7 +1363,7 @@ async function handleServerEvent(event, data) { hostPeerId = data.hostPeerId || null; controllers = Array.isArray(data.controllers) ? data.controllers : []; serverCapabilities = Array.isArray(data.capabilities) ? data.capabilities : []; - if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); + if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); hcmEnforceDesyncInvariant(); broadcastControlMode(); markRoomPotentiallyIdle(); @@ -1450,7 +1462,7 @@ async function handleServerEvent(event, data) { (typeof candidate === 'object' ? candidate.peerId : candidate) === received.senderId ); if (Number.isInteger(tabId)) { - sendMessageToCurrentContent({ + sendMessageToChatOverlay({ type: 'CHAT_MESSAGE', message: { id: received.id, @@ -2246,6 +2258,15 @@ async function deactivateTargetTab(tabId, contentTarget = null, { deactivateMoni null, target.documentId ).catch(() => {}); + // The overlay lives in the top document whenever the player is nested, so + // clearing only the media frame would leave a stale chat behind on Drive. + if (normalizeFrameId(target.frameId) !== 0) { + await sendMessageToFrame( + normalizedTabId, + 0, + { type: 'CHAT_DESTROY' } + ).catch(() => {}); + } } function createHostAccessRequiredError(access, requestAdded, cause) { @@ -2411,10 +2432,38 @@ async function injectContentScript(tabId, { func: setPageApiSeekEnabled, args: [pageApiSeekReady] }); - const injectionResults = await chrome.scripting.executeScript({ - target: scriptTarget, - files: ['chat-format.js', 'chat-overlay.js', 'content.js'] - }); + // The chat overlay is standalone page UI and carries its own runtime + // message listener, so it is installed in the top document regardless of + // where the player lives. Only the playback controller goes into the + // selected media frame. + let injectionResults; + if (contentTarget.frameId === 0) { + injectionResults = await chrome.scripting.executeScript({ + target: scriptTarget, + files: ['chat-format.js', 'chat-overlay.js', 'content.js'] + }); + } else { + try { + await chrome.scripting.executeScript({ + target: { tabId, frameIds: [0] }, + files: ['chat-format.js', 'chat-overlay.js'] + }); + } catch (err) { + addLog(`Chat overlay injection failed in the top frame: ${err.message}`, 'warn'); + } + // A pre-3.1.3 build may have left an overlay inside the player. + await sendMessageToFrame( + tabId, + contentTarget.frameId, + { type: 'CHAT_DESTROY' }, + null, + contentTarget.documentId + ).catch(() => {}); + injectionResults = await chrome.scripting.executeScript({ + target: scriptTarget, + files: ['content.js'] + }); + } const frameResult = Array.isArray(injectionResults) ? injectionResults.find(result => normalizeFrameId(result?.frameId) === contentTarget.frameId) : null; @@ -2791,7 +2840,32 @@ async function reactivateCurrentTarget(tabId, { expectedGeneration = targetActiv }); } -function refreshCurrentMediaTarget(tabId, { queueIfRunning = false } = {}) { +/** + * Cheap pre-check for lifecycle-driven refreshes. + * + * Reactivation tears down and re-injects the content script, which interrupts + * playback and audio routing. That price is only worth paying when the selected + * frame or document actually moved — not for the constant DOM churn that pages + * like Drive and YouTube produce while simply playing. + */ +async function selectedMediaTargetMoved(tabId) { + try { + const resolved = await resolveMediaContentTarget(chrome, tabId, { attempts: 1 }); + if (normalizeTabId(currentTabId) !== normalizeTabId(tabId)) return false; + const frameMoved = normalizeFrameId(resolved.frameId) !== normalizeFrameId(currentTargetFrameId); + const documentMoved = typeof resolved.documentId === 'string' + && typeof currentTargetDocumentId === 'string' + && resolved.documentId !== currentTargetDocumentId; + const gainedVideo = resolved.hasVideo === true && currentTargetHasVideo !== true; + return frameMoved || documentMoved || gainedVideo; + } catch { + // Access-required and ambiguity errors must reach the full activation + // path so the popup can surface them. + return true; + } +} + +function refreshCurrentMediaTarget(tabId, { queueIfRunning = false, onlyIfTargetMoved = false } = {}) { const selectedTabId = normalizeTabId(tabId); if (selectedTabId === null || normalizeTabId(currentTabId) !== selectedTabId) { return Promise.resolve({ status: 'superseded' }); @@ -2810,6 +2884,10 @@ function refreshCurrentMediaTarget(tabId, { queueIfRunning = false } = {}) { do { pass++; mediaTargetRefreshDirty = false; + if (onlyIfTargetMoved && !(await selectedMediaTargetMoved(selectedTabId))) { + result = { status: 'unchanged' }; + break; + } const expectedGeneration = targetActivationGeneration; result = await reactivateCurrentTarget(selectedTabId, { expectedGeneration }); // Let lifecycle messages queued during the final probe/injection @@ -3104,7 +3182,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => { function leaveOldRoomIfSwitching(newRoomId) { if (currentRoom && currentRoom.roomId !== newRoomId) { - if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_RESET' }).catch(() => {}); + if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_RESET' }).catch(() => {}); addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info'); forceDisconnect(); currentRoom = null; @@ -3194,12 +3272,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { chrome.storage.onChanged.addListener((changes, area) => { if (area !== 'local') return; if (changes.browserNotifications && currentTabId) { - sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); + sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); } if (!changes.roomId && !changes.chatKey && !changes.chatEnabled) return; if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue); invalidateChatSession(); - if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); + if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); }); async function handleAsyncMessage(message, sender, sendResponse) { @@ -3217,7 +3295,11 @@ async function handleAsyncMessage(message, sender, sendResponse) { && isCurrentContentSender(sender) && (message.type === 'CONTENT_EVENT' || message.type === 'HEARTBEAT'); if (mustRevalidateEmbeddedSender) { - await refreshCurrentMediaTarget(senderTabId).catch(() => {}); + // Heartbeats and content events arrive continuously. Revalidating a + // nested target is only about confirming the frame still holds the + // player, so it must not reinject the content script every time: that + // put Drive- and anime-style targets into a permanent activation loop. + await refreshCurrentMediaTarget(senderTabId, { onlyIfTargetMoved: true }).catch(() => {}); } if (message.type === 'CONNECT') { @@ -3228,7 +3310,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) { broadcastConnectionStatus('connected'); - if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); + if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); await broadcastJoinStatus({ type: 'JOIN_STATUS', success: true, message: 'Already in room' }); if (typeof sendResponse === 'function') sendResponse({ status: 'ok' }); return; @@ -3278,12 +3360,26 @@ async function handleAsyncMessage(message, sender, sendResponse) { let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected')); // Distinguish the normal "not in a room" resting state from a real drop. if (status === 'disconnected' && !currentRoom && !connectIntent) status = 'idle'; - sendResponse({ - status, - peerId, + // One public selection, one derived state. Activation is always terminal: + // it settles in ready or access_required, never in an open-ended + // "activating" that the popup keeps retrying on every open. + const selectedTargetTabId = normalizeTabId(currentTabId); + const targetReady = selectedTargetTabId !== null && !activeTargetActivation; + const targetActivationState = targetReady + ? 'ready' + : activeTargetActivation + ? 'activating' + : pendingTarget + ? 'access_required' + : 'none'; + sendResponse({ + status, + peerId, peers: currentRoom ? currentRoom.peers : [], lastActionState, targetTabId: currentTabId, + targetReady, + targetActivationState, targetFrameId: currentTargetFrameId, targetDocumentId: currentTargetDocumentId, targetHasVideo: currentTargetHasVideo, @@ -3607,7 +3703,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) { broadcastConnectionStatus('connected'); - if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); + if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); const statusSent = await broadcastJoinStatus( { type: 'JOIN_STATUS', success: true, message: 'Already in room' }, isCurrentJoin @@ -4151,7 +4247,16 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ status: 'ignored_stale_tab' }); return; } - const activation = await refreshCurrentMediaTarget(tabId, { queueIfRunning: true }); + // A page-driven notification must never surface as a handler failure. + // Reporting it that way turned one unreachable player frame into an + // endless error cascade in the popup. + const activation = await refreshCurrentMediaTarget(tabId, { + queueIfRunning: true, + onlyIfTargetMoved: true + }).catch(error => { + addLog(`Media frame candidate refresh failed: ${error.message}`, 'warn'); + return { status: 'error', message: error.message }; + }); sendResponse(activation || { status: 'invalid_tab' }); } else if (message.type === 'MEDIA_FRAME_VISIBILITY') { if (!isCurrentContentSender(sender)) { @@ -4165,7 +4270,13 @@ async function handleAsyncMessage(message, sender, sendResponse) { const tabId = normalizeTabId(sender.tab?.id); const activation = tabId === null ? null - : await refreshCurrentMediaTarget(tabId, { queueIfRunning: true }); + : await refreshCurrentMediaTarget(tabId, { + queueIfRunning: true, + onlyIfTargetMoved: true + }).catch(error => { + addLog(`Media frame visibility refresh failed: ${error.message}`, 'warn'); + return { status: 'error', message: error.message }; + }); sendResponse(activation || { status: 'invalid_tab' }); } else if (message.type === 'MEDIA_TARGET_REFRESH') { if (!isCurrentContentSender(sender)) { @@ -4175,7 +4286,13 @@ async function handleAsyncMessage(message, sender, sendResponse) { const tabId = normalizeTabId(sender.tab?.id); const activation = tabId === null ? null - : await refreshCurrentMediaTarget(tabId, { queueIfRunning: true }); + : await refreshCurrentMediaTarget(tabId, { + queueIfRunning: true, + onlyIfTargetMoved: true + }).catch(error => { + addLog(`Media target refresh failed: ${error.message}`, 'warn'); + return { status: 'error', message: error.message }; + }); sendResponse(activation || { status: 'invalid_tab' }); } else if (message.type === 'CONTENT_BOOT') { if (sender.tab) { diff --git a/extension/content.js b/extension/content.js index 9a41c01..f1d8995 100644 --- a/extension/content.js +++ b/extension/content.js @@ -1,17 +1,17 @@ -/** - * KoalaSync Content Script - * Injected into video tabs to control playback and detect events. - */ - -(function() { - // Injection Guard: Check if already injected AND context is valid - try { - if (window.koalaSyncInjected && chrome.runtime.id) { - return; - } - } catch (_e) { - // Context invalidated, proceed with re-injection - } +/** + * KoalaSync Content Script + * Injected into video tabs to control playback and detect events. + */ + +(function() { + // Injection Guard: Check if already injected AND context is valid + try { + if (window.koalaSyncInjected && chrome.runtime.id) { + return; + } + } catch (_e) { + // Context invalidated, proceed with re-injection + } window.koalaSyncInjected = true; let destroyed = false; const lifecycleTimeouts = new Set(); @@ -69,50 +69,50 @@ if (isEmbeddedContentFrame) { window.addEventListener('resize', handleMediaFrameResize, { passive: true }); } - - // --- SHARED_EVENTS_INJECT_START --- + + // --- SHARED_EVENTS_INJECT_START --- // This block is automatically updated by /scripts/build-extension.cjs - const EVENTS = { - PLAY: "play", - PAUSE: "pause", - SEEK: "seek", - FORCE_SYNC_PREPARE: "force_sync_prepare", - FORCE_SYNC_ACK: "force_sync_ack", - FORCE_SYNC_EXECUTE: "force_sync_execute", - PEER_STATUS: "peer_status", - EPISODE_LOBBY: "episode_lobby", - EPISODE_READY: "episode_ready" - }; - // --- SHARED_EVENTS_INJECT_END --- - - // Suppresses native event reporting after a programmatic action. - // Each entry is a per-type timer (key = 'playing'|'paused'|'seek'). - // While a timer exists, matching native events are consumed and not relayed. - // Timers self-clean after 300ms if the native event never fires. - let _suppressTimers = {}; - - function _setSuppress(state) { - if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]); - _suppressTimers[state] = setTimeout(() => { - delete _suppressTimers[state]; - }, 300); - } - - function _clearSuppress(state) { - if (_suppressTimers[state]) { - clearTimeout(_suppressTimers[state]); - delete _suppressTimers[state]; - } - } - - // --- Seek Relay Filtering --- - // Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks - // from being relayed to peers as user-initiated seeks. - const MIN_SEEK_DELTA = 2.0; - let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK - let seekDebounceTimer = null; // debounce timer for rapid seek events - let expectedSeekTime = null; // strictly track programmatic seeks - + const EVENTS = { + PLAY: "play", + PAUSE: "pause", + SEEK: "seek", + FORCE_SYNC_PREPARE: "force_sync_prepare", + FORCE_SYNC_ACK: "force_sync_ack", + FORCE_SYNC_EXECUTE: "force_sync_execute", + PEER_STATUS: "peer_status", + EPISODE_LOBBY: "episode_lobby", + EPISODE_READY: "episode_ready" + }; + // --- SHARED_EVENTS_INJECT_END --- + + // Suppresses native event reporting after a programmatic action. + // Each entry is a per-type timer (key = 'playing'|'paused'|'seek'). + // While a timer exists, matching native events are consumed and not relayed. + // Timers self-clean after 300ms if the native event never fires. + let _suppressTimers = {}; + + function _setSuppress(state) { + if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]); + _suppressTimers[state] = setTimeout(() => { + delete _suppressTimers[state]; + }, 300); + } + + function _clearSuppress(state) { + if (_suppressTimers[state]) { + clearTimeout(_suppressTimers[state]); + delete _suppressTimers[state]; + } + } + + // --- Seek Relay Filtering --- + // Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks + // from being relayed to peers as user-initiated seeks. + const MIN_SEEK_DELTA = 2.0; + let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK + let seekDebounceTimer = null; // debounce timer for rapid seek events + let expectedSeekTime = null; // strictly track programmatic seeks + const PAGE_API_SEEK_BRIDGE = 1; // Accurate Disney+ playhead pushed by the MAIN-world page-API bridge // (background.js installPageApiSeekBridge). The isolated content world @@ -128,25 +128,25 @@ } if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { window.addEventListener('message', handlePageApiTime); - } - - function hostMatchesUrl(host, url) { - const normalized = String(url || '') - .replace(/^https?:\/\//i, '') - .split('/')[0] - .toLowerCase(); - return normalized && (host === normalized || host.endsWith(`.${normalized}`)); - } - - function matchesPlayerUrls(urls) { - const host = window.location.hostname.toLowerCase(); - return Array.isArray(urls) && urls.some(url => hostMatchesUrl(host, url)); - } - - function isDisneyPlusHost() { - return matchesPlayerUrls(['disneyplus.com']); - } - + } + + function hostMatchesUrl(host, url) { + const normalized = String(url || '') + .replace(/^https?:\/\//i, '') + .split('/')[0] + .toLowerCase(); + return normalized && (host === normalized || host.endsWith(`.${normalized}`)); + } + + function matchesPlayerUrls(urls) { + const host = window.location.hostname.toLowerCase(); + return Array.isArray(urls) && urls.some(url => hostMatchesUrl(host, url)); + } + + function isDisneyPlusHost() { + return matchesPlayerUrls(['disneyplus.com']); + } + function getDisneyPlusTimeline() { if (!isDisneyPlusHost()) return null; // Exact playhead/duration from the page media player, relayed by the @@ -157,10 +157,10 @@ } return null; } - - // Site-specific player exceptions live here. The default HTML5 path stays below. - function getSiteQuirkAdapters() { - return [{ + + // Site-specific player exceptions live here. The default HTML5 path stays below. + function getSiteQuirkAdapters() { + return [{ name: 'disneyplus-page-api', key: 'disneyPlus', urls: ['disneyplus.com'], @@ -175,22 +175,22 @@ }; } }]; - } - - function getActiveSiteQuirk() { - return getSiteQuirkAdapters().find(adapter => adapter.matches()) || null; - } - - function getSiteQuirkTimeline(video) { - const adapter = getActiveSiteQuirk(); - return adapter ? adapter.getTimeline(video) : null; - } - - function getSiteQuirkDebug(video) { - const adapter = getActiveSiteQuirk(); - return adapter ? adapter.getDebug(video) : null; - } - + } + + function getActiveSiteQuirk() { + return getSiteQuirkAdapters().find(adapter => adapter.matches()) || null; + } + + function getSiteQuirkTimeline(video) { + const adapter = getActiveSiteQuirk(); + return adapter ? adapter.getTimeline(video) : null; + } + + function getSiteQuirkDebug(video) { + const adapter = getActiveSiteQuirk(); + return adapter ? adapter.getDebug(video) : null; + } + function getSyncCurrentTime(video) { const siteTimeline = getSiteQuirkTimeline(video); if (siteTimeline) return siteTimeline.current; @@ -205,23 +205,23 @@ if (isDisneyPlusHost()) return 0; return Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0; } - - function toNativeSeekTime(video, targetTime) { - if (!Number.isFinite(targetTime)) return targetTime; - const siteTimeline = getSiteQuirkTimeline(video); - if (!siteTimeline) return targetTime; - const nativeTarget = siteTimeline.nativeStart + targetTime * siteTimeline.nativeScale; - const max = Number.isFinite(siteTimeline.end) ? siteTimeline.end : nativeTarget; - const min = Number.isFinite(siteTimeline.start) ? siteTimeline.start : 0; - return Math.max(min, Math.min(max, nativeTarget)); - } - - function shouldUsePageApiSeek() { - return window.KOALA_PAGE_API_SEEK_ENABLED === true && - typeof window.koalaFindPageApiSeekProvider === 'function' && - !!window.koalaFindPageApiSeekProvider(window.location.hostname); - } - + + function toNativeSeekTime(video, targetTime) { + if (!Number.isFinite(targetTime)) return targetTime; + const siteTimeline = getSiteQuirkTimeline(video); + if (!siteTimeline) return targetTime; + const nativeTarget = siteTimeline.nativeStart + targetTime * siteTimeline.nativeScale; + const max = Number.isFinite(siteTimeline.end) ? siteTimeline.end : nativeTarget; + const min = Number.isFinite(siteTimeline.start) ? siteTimeline.start : 0; + return Math.max(min, Math.min(max, nativeTarget)); + } + + function shouldUsePageApiSeek() { + return window.KOALA_PAGE_API_SEEK_ENABLED === true && + typeof window.koalaFindPageApiSeekProvider === 'function' && + !!window.koalaFindPageApiSeekProvider(window.location.hostname); + } + function seekVideo(video, targetTime) { // Prefer a precise page-level seek API when available (Netflix, Disney+); // for those players the DOM/button seek path is imprecise or impossible. @@ -233,48 +233,48 @@ expectedSeekTime = targetTime; video.currentTime = toNativeSeekTime(video, targetTime); } - - // --- Play/Pause Coalescing (leading + trailing) --- - // Media players (HLS/DASH, ad insertion, ABR/quality switches, source swaps, - // page teardown) fire bursts of native play/pause events within a few hundred - // ms. Relaying each as a distinct command spams peers and the relay. - // - // Strategy: emit the FIRST event immediately (leading edge → a deliberate - // single play/pause has zero added latency), then hold a short window. If - // more play/pause events arrive during the window it's a burst — we suppress - // the intermediate churn and, once it settles, emit the FINAL state on the - // trailing edge. - // - // We deliberately do NOT dedup the trailing send against the leading one. A - // remote play/pause may be applied mid-window (e.g. I pause, peer plays, I - // pause again within 150ms): the settled state then equals my last *sent* - // state yet is a genuine change versus the now-shared state — suppressing it - // would desync. Re-sending an unchanged state is a harmless no-op on peers, - // so re-sending is always the safe choice. Echo-suppression and seek-flush - // still run synchronously on event arrival (see reportEvent) — only the - // network emit is governed here. - const PLAY_PAUSE_COALESCE_MS = 150; + + // --- Play/Pause Coalescing (leading + trailing) --- + // Media players (HLS/DASH, ad insertion, ABR/quality switches, source swaps, + // page teardown) fire bursts of native play/pause events within a few hundred + // ms. Relaying each as a distinct command spams peers and the relay. + // + // Strategy: emit the FIRST event immediately (leading edge → a deliberate + // single play/pause has zero added latency), then hold a short window. If + // more play/pause events arrive during the window it's a burst — we suppress + // the intermediate churn and, once it settles, emit the FINAL state on the + // trailing edge. + // + // We deliberately do NOT dedup the trailing send against the leading one. A + // remote play/pause may be applied mid-window (e.g. I pause, peer plays, I + // pause again within 150ms): the settled state then equals my last *sent* + // state yet is a genuine change versus the now-shared state — suppressing it + // would desync. Re-sending an unchanged state is a harmless no-op on peers, + // so re-sending is always the safe choice. Echo-suppression and seek-flush + // still run synchronously on event arrival (see reportEvent) — only the + // network emit is governed here. + const PLAY_PAUSE_COALESCE_MS = 150; let playPauseCoalesceTimer = null; // non-null = a coalescing window is open let pendingPlayPauseAction = null; // last play/pause seen during the window, awaiting trailing flush let pendingPlayPauseVideo = null; // source element for rejecting a stale trailing flush - - // --- Episode Auto-Sync State --- - let lastKnownMediaTitle = null; - let episodeTransitionDebounce = null; - let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby) - let lobbyPollTimer = null; - let _autoSyncEnabled = true; // Cached setting, updated via storage.onChanged - let _audioSettings = null; - let _audioProcessingAllowed = true; - - // Cache the autoSyncNextEpisode setting (local-only; never read from sync) + + // --- Episode Auto-Sync State --- + let lastKnownMediaTitle = null; + let episodeTransitionDebounce = null; + let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby) + let lobbyPollTimer = null; + let _autoSyncEnabled = true; // Cached setting, updated via storage.onChanged + let _audioSettings = null; + let _audioProcessingAllowed = true; + + // Cache the autoSyncNextEpisode setting (local-only; never read from sync) chrome.storage.local.get(['autoSyncNextEpisode', 'audioSettings'], (data) => { if (destroyed) return; - _autoSyncEnabled = data.autoSyncNextEpisode !== false; - _audioSettings = mergeAudioSettings(data.audioSettings); - const video = findVideo(); - if (video && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings); - }); + _autoSyncEnabled = data.autoSyncNextEpisode !== false; + _audioSettings = mergeAudioSettings(data.audioSettings); + const video = findVideo(); + if (video && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings); + }); function handleStorageChanged(changes, area) { if (destroyed) return; if (area === 'local' && changes.autoSyncNextEpisode) { @@ -287,266 +287,266 @@ } } chrome.storage.onChanged.addListener(handleStorageChanged); - - // --- Host Control Mode (guest-side) --- - // When a room is in 'host-only' mode and we're a guest, a deliberate local - // pause/seek must not drive the room (background/server already drop it). Here - // we handle the *local* UX: snap back to the host's position, or — if the user - // really wants to — let them go solo (desync) with a resync escape hatch. - let hcmControlMode = 'everyone'; // mirror of room control mode - let hcmAmController = false; // are we allowed to drive (owner or co-host)? - let hcmHostPeerId = null; // last known host peerId (room/host identity) - let hcmDesynced = false; // user chose to go solo - let hcmSnapBackCooldownUntil = 0; // suppress re-trigger right after a snap-back - let hcmDeferredSnapPending = false; // a buffer-aware snap-back is waiting for readiness - let hcmLastUserGestureAt = 0; // for deliberate-vs-involuntary classification + + // --- Host Control Mode (guest-side) --- + // When a room is in 'host-only' mode and we're a guest, a deliberate local + // pause/seek must not drive the room (background/server already drop it). Here + // we handle the *local* UX: snap back to the host's position, or — if the user + // really wants to — let them go solo (desync) with a resync escape hatch. + let hcmControlMode = 'everyone'; // mirror of room control mode + let hcmAmController = false; // are we allowed to drive (owner or co-host)? + let hcmHostPeerId = null; // last known host peerId (room/host identity) + let hcmDesynced = false; // user chose to go solo + let hcmSnapBackCooldownUntil = 0; // suppress re-trigger right after a snap-back + let hcmDeferredSnapPending = false; // a buffer-aware snap-back is waiting for readiness + let hcmLastUserGestureAt = 0; // for deliberate-vs-involuntary classification let hcmBufferingUntil = 0; // set on 'waiting' — buffering grace window let hcmDialogTimer = null; // 8s auto-stay timer — cleared on dialog replace (H-4) let hcmBadgeDomReadyHandler = null; - // Localized strings for the in-page dialog/badge (content has no i18n loader; - // background resolves them via GET_HCM_STRINGS on init). English fallbacks here. - const hcmStrings = { - title: 'KoalaSync · Host controls this room', - body: 'Only the host can control playback in this room. Keep watching together, or watch on your own?', - stay: 'Stay in sync', - solo: 'Watch on my own', - badge: 'Watching on your own', - resync: 'Resync' - }; - const HCM_USER_GESTURE_MS = 1000; - const HCM_BUFFERING_GRACE_MS = 1500; - const HCM_SNAP_BACK_COOLDOWN_MS = 1000; - const HCM_BUFFER_WAIT_MS = 8000; // cap on waiting for a buffering player before snapping anyway - - // Track genuine user input so we can tell a deliberate pause/seek from a - // player-/browser-initiated one. Capturing + passive so we never interfere. - const _hcmGesture = () => { hcmLastUserGestureAt = Date.now(); }; - document.addEventListener('keydown', _hcmGesture, { capture: true, passive: true }); - document.addEventListener('pointerdown', _hcmGesture, { capture: true, passive: true }); - - function hcmIsGuestGated() { - return hcmControlMode === 'host-only' && !hcmAmController; - } - - // EC-9 intent classifier: only a *clearly deliberate* guest action triggers the - // dialog/snap-back. Anything that smells involuntary (buffering, seeking, tab - // refocus, no recent gesture) is treated as involuntary. Bias intentional — - // in host-only the guest never broadcasts anyway, so this only tunes UX. - function hcmClassifyIntent() { - const video = findVideo(); - if (!video) return 'involuntary'; - if (hcmIsLive(video)) return 'live'; // EC-15: degrade, don't gate - if (video.readyState < 3) return 'involuntary'; // buffering / not enough data - if (video.seeking) return 'involuntary'; - if (Date.now() < hcmBufferingUntil) return 'involuntary'; - if (Date.now() < visibilityGraceUntil) return 'involuntary'; - if (Date.now() - hcmLastUserGestureAt > HCM_USER_GESTURE_MS) return 'involuntary'; - return 'deliberate'; - } - - // Live detection (EC-15 + DVR). Pure live reports duration Infinity/NaN. Live-DVR - // (Twitch/YouTube-live with rewind) reports a *finite, sliding* duration — its - // seekable window doesn't start at 0, which we use as the DVR signal. - function hcmIsLive(video) { - // Don't trust duration before metadata has loaded (readyState >= 1) — - // otherwise pre-loaded videos report NaN and get misclassified as live, - // which suppresses the desync dialog (L-2). - if (video.readyState < 1) return false; - if (!isDisneyPlusHost() && !Number.isFinite(video.duration)) return true; - try { - const s = video.seekable; - if (s && s.length > 0 && s.start(0) > 1) return true; // sliding DVR window - } catch (_e) { /* seekable may throw if empty */ } - return false; - } - - // Snap the local player back to the host's current position/state. - function hcmSnapBackToHost(target) { - if (hcmDesynced) return; // user opted out — never yank them back automatically - hcmSnapBackCooldownUntil = Date.now() + HCM_SNAP_BACK_COOLDOWN_MS; - const video = findVideo(); - if (!video) return; - if (target && Number.isFinite(target.targetTime)) { - tryMediaAction(EVENTS.SEEK, { targetTime: target.targetTime }); - } - // Adopt the host's play/pause state — but ONLY if we actually know it. - // Defaulting to PLAY when the state is unknown would auto-resume a paused - // video against the host's real state (H-1). - if (target && target.playbackState === 'paused') { - tryMediaAction(EVENTS.PAUSE); - } else if (target && target.playbackState === 'playing') { - tryMediaAction(EVENTS.PLAY); - } - reportLog('Host-only: snapped back to host position', 'info'); - } - - // Resync to the host's current position, retrying briefly if the host's state - // isn't known yet (e.g. they just paused and no heartbeat has propagated) — - // otherwise the request is a silent no-op and the user thinks they're synced - // when they aren't. Shared by the dialog's "Stay in sync" and the Resync badge. - function hcmRequestHostSyncWithRetry() { - let attempts = 0; - const tryOnce = () => { + // Localized strings for the in-page dialog/badge (content has no i18n loader; + // background resolves them via GET_HCM_STRINGS on init). English fallbacks here. + const hcmStrings = { + title: 'KoalaSync · Host controls this room', + body: 'Only the host can control playback in this room. Keep watching together, or watch on your own?', + stay: 'Stay in sync', + solo: 'Watch on my own', + badge: 'Watching on your own', + resync: 'Resync' + }; + const HCM_USER_GESTURE_MS = 1000; + const HCM_BUFFERING_GRACE_MS = 1500; + const HCM_SNAP_BACK_COOLDOWN_MS = 1000; + const HCM_BUFFER_WAIT_MS = 8000; // cap on waiting for a buffering player before snapping anyway + + // Track genuine user input so we can tell a deliberate pause/seek from a + // player-/browser-initiated one. Capturing + passive so we never interfere. + const _hcmGesture = () => { hcmLastUserGestureAt = Date.now(); }; + document.addEventListener('keydown', _hcmGesture, { capture: true, passive: true }); + document.addEventListener('pointerdown', _hcmGesture, { capture: true, passive: true }); + + function hcmIsGuestGated() { + return hcmControlMode === 'host-only' && !hcmAmController; + } + + // EC-9 intent classifier: only a *clearly deliberate* guest action triggers the + // dialog/snap-back. Anything that smells involuntary (buffering, seeking, tab + // refocus, no recent gesture) is treated as involuntary. Bias intentional — + // in host-only the guest never broadcasts anyway, so this only tunes UX. + function hcmClassifyIntent() { + const video = findVideo(); + if (!video) return 'involuntary'; + if (hcmIsLive(video)) return 'live'; // EC-15: degrade, don't gate + if (video.readyState < 3) return 'involuntary'; // buffering / not enough data + if (video.seeking) return 'involuntary'; + if (Date.now() < hcmBufferingUntil) return 'involuntary'; + if (Date.now() < visibilityGraceUntil) return 'involuntary'; + if (Date.now() - hcmLastUserGestureAt > HCM_USER_GESTURE_MS) return 'involuntary'; + return 'deliberate'; + } + + // Live detection (EC-15 + DVR). Pure live reports duration Infinity/NaN. Live-DVR + // (Twitch/YouTube-live with rewind) reports a *finite, sliding* duration — its + // seekable window doesn't start at 0, which we use as the DVR signal. + function hcmIsLive(video) { + // Don't trust duration before metadata has loaded (readyState >= 1) — + // otherwise pre-loaded videos report NaN and get misclassified as live, + // which suppresses the desync dialog (L-2). + if (video.readyState < 1) return false; + if (!isDisneyPlusHost() && !Number.isFinite(video.duration)) return true; + try { + const s = video.seekable; + if (s && s.length > 0 && s.start(0) > 1) return true; // sliding DVR window + } catch (_e) { /* seekable may throw if empty */ } + return false; + } + + // Snap the local player back to the host's current position/state. + function hcmSnapBackToHost(target) { + if (hcmDesynced) return; // user opted out — never yank them back automatically + hcmSnapBackCooldownUntil = Date.now() + HCM_SNAP_BACK_COOLDOWN_MS; + const video = findVideo(); + if (!video) return; + if (target && Number.isFinite(target.targetTime)) { + tryMediaAction(EVENTS.SEEK, { targetTime: target.targetTime }); + } + // Adopt the host's play/pause state — but ONLY if we actually know it. + // Defaulting to PLAY when the state is unknown would auto-resume a paused + // video against the host's real state (H-1). + if (target && target.playbackState === 'paused') { + tryMediaAction(EVENTS.PAUSE); + } else if (target && target.playbackState === 'playing') { + tryMediaAction(EVENTS.PLAY); + } + reportLog('Host-only: snapped back to host position', 'info'); + } + + // Resync to the host's current position, retrying briefly if the host's state + // isn't known yet (e.g. they just paused and no heartbeat has propagated) — + // otherwise the request is a silent no-op and the user thinks they're synced + // when they aren't. Shared by the dialog's "Stay in sync" and the Resync badge. + function hcmRequestHostSyncWithRetry() { + let attempts = 0; + const tryOnce = () => { runtimeMessage({ type: 'REQUEST_HOST_SYNC' }, (res) => { - if (chrome.runtime.lastError || !res || !res.target) { + if (chrome.runtime.lastError || !res || !res.target) { if (++attempts < 5) scheduleLifecycleTimeout(tryOnce, 250); - else reportLog('Host-only: resync requested but host state unavailable', 'warn'); - return; - } - hcmSnapBackToHost(res.target); - }); - }; - tryOnce(); - } - - // 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; - const deadline = Date.now() + HCM_BUFFER_WAIT_MS; - const poll = () => { - // Abort if the reason to snap is gone: user went solo, we're no longer a - // gated guest, or the video vanished. - 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; - } + else reportLog('Host-only: resync requested but host state unavailable', 'warn'); + return; + } + hcmSnapBackToHost(res.target); + }); + }; + tryOnce(); + } + + // 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; + const deadline = Date.now() + HCM_BUFFER_WAIT_MS; + const poll = () => { + // Abort if the reason to snap is gone: user went solo, we're no longer a + // gated guest, or the video vanished. + 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) - // so we never leave the guest silently stuck (consistent with the - // deferred and "Stay in sync" paths). - 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. - hcmShowDesyncDialog(action, target); - } - - // --- In-page UI (dialog + persistent desync badge) --- - // 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 - // 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 - if (text != null) el.textContent = text; - return el; - } - - function hcmRemoveDialog() { - // Cancel any pending auto-stay timer so a replaced dialog's stale closure - // can't later remove its successor / snap to an outdated target (H-4). - if (hcmDialogTimer) { clearTimeout(hcmDialogTimer); hcmDialogTimer = null; } - if (hcmDialogHost) { hcmDialogHost.remove(); hcmDialogHost = null; } - } - - function hcmShowDesyncDialog(action, target) { - if (!document.body) { hcmSnapBackToHost(target); return; } - hcmRemoveDialog(); - const host = hcmEl('div', 'all:initial'); - const root = host.attachShadow({ mode: 'open' }); - - const wrap = hcmEl('div', 'position:fixed;z-index:2147483647;left:50%;bottom:32px;transform:translateX(-50%);background:#212a17;color:#f4f2ea;font:14px/1.4 system-ui,sans-serif;padding:16px 18px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.45);max-width:360px;border:1px solid #2f3625'); - wrap.setAttribute('role', 'dialog'); - const title = hcmEl('div', 'font-weight:600;margin-bottom:6px', hcmStrings.title); - const body = hcmEl('div', 'margin-bottom:12px;color:#bcb7a9', hcmStrings.body); - const btnRow = hcmEl('div', 'display:flex;gap:8px;justify-content:flex-end'); - const soloBtn = hcmEl('button', 'background:#2f3625;color:#f4f2ea;border:0;padding:8px 12px;border-radius:8px;cursor:pointer', hcmStrings.solo); - const stayBtn = hcmEl('button', 'background:#56ae6c;color:#0a1a0d;border:0;padding:8px 12px;border-radius:8px;cursor:pointer;font-weight:600', hcmStrings.stay); - btnRow.append(soloBtn, stayBtn); - wrap.append(title, body, btnRow); - root.appendChild(wrap); - document.body.appendChild(host); - hcmDialogHost = host; - - let settled = false; - // Re-query the host's current position on click instead of using the - // potentially stale target captured at HOST_BLOCKED time (M-1). - const stay = () => { - if (settled) return; settled = true; hcmRemoveDialog(); - hcmRequestHostSyncWithRetry(); - }; - const solo = () => { if (settled) return; settled = true; hcmRemoveDialog(); hcmEnterDesync(); }; - stayBtn.addEventListener('click', stay); - soloBtn.addEventListener('click', solo); - // EC-18: if the user ignores the prompt, default to staying in sync. - hcmDialogTimer = setTimeout(() => { if (!settled) stay(); }, 8000); - } - - function hcmEnterDesync() { - hcmDesynced = true; - reportLog('Host-only: you chose to watch on your own (desynced)', 'warn'); - // Notify background so it can relay our desynced state to the host via - // heartbeats — the host's UI then knows we're not following commands - // instead of appearing silently un-ACK'd. + }; + 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) + // so we never leave the guest silently stuck (consistent with the + // deferred and "Stay in sync" paths). + 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. + hcmShowDesyncDialog(action, target); + } + + // --- In-page UI (dialog + persistent desync badge) --- + // 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 + // 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 + if (text != null) el.textContent = text; + return el; + } + + function hcmRemoveDialog() { + // Cancel any pending auto-stay timer so a replaced dialog's stale closure + // can't later remove its successor / snap to an outdated target (H-4). + if (hcmDialogTimer) { clearTimeout(hcmDialogTimer); hcmDialogTimer = null; } + if (hcmDialogHost) { hcmDialogHost.remove(); hcmDialogHost = null; } + } + + function hcmShowDesyncDialog(action, target) { + if (!document.body) { hcmSnapBackToHost(target); return; } + hcmRemoveDialog(); + const host = hcmEl('div', 'all:initial'); + const root = host.attachShadow({ mode: 'open' }); + + const wrap = hcmEl('div', 'position:fixed;z-index:2147483647;left:50%;bottom:32px;transform:translateX(-50%);background:#212a17;color:#f4f2ea;font:14px/1.4 system-ui,sans-serif;padding:16px 18px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.45);max-width:360px;border:1px solid #2f3625'); + wrap.setAttribute('role', 'dialog'); + const title = hcmEl('div', 'font-weight:600;margin-bottom:6px', hcmStrings.title); + const body = hcmEl('div', 'margin-bottom:12px;color:#bcb7a9', hcmStrings.body); + const btnRow = hcmEl('div', 'display:flex;gap:8px;justify-content:flex-end'); + const soloBtn = hcmEl('button', 'background:#2f3625;color:#f4f2ea;border:0;padding:8px 12px;border-radius:8px;cursor:pointer', hcmStrings.solo); + const stayBtn = hcmEl('button', 'background:#56ae6c;color:#0a1a0d;border:0;padding:8px 12px;border-radius:8px;cursor:pointer;font-weight:600', hcmStrings.stay); + btnRow.append(soloBtn, stayBtn); + wrap.append(title, body, btnRow); + root.appendChild(wrap); + document.body.appendChild(host); + hcmDialogHost = host; + + let settled = false; + // Re-query the host's current position on click instead of using the + // potentially stale target captured at HOST_BLOCKED time (M-1). + const stay = () => { + if (settled) return; settled = true; hcmRemoveDialog(); + hcmRequestHostSyncWithRetry(); + }; + const solo = () => { if (settled) return; settled = true; hcmRemoveDialog(); hcmEnterDesync(); }; + stayBtn.addEventListener('click', stay); + soloBtn.addEventListener('click', solo); + // EC-18: if the user ignores the prompt, default to staying in sync. + hcmDialogTimer = setTimeout(() => { if (!settled) stay(); }, 8000); + } + + function hcmEnterDesync() { + hcmDesynced = true; + reportLog('Host-only: you chose to watch on your own (desynced)', 'warn'); + // Notify background so it can relay our desynced state to the host via + // heartbeats — the host's UI then knows we're not following commands + // instead of appearing silently un-ACK'd. runtimeMessage({ type: 'HCM_DESYNC_STATE', desynced: true }).catch(() => {}); - hcmShowBadge(); - } - - function hcmExitDesync() { - const wasDesynced = hcmDesynced; - hcmDesynced = false; - hcmRemoveBadge(); - if (wasDesynced) { + hcmShowBadge(); + } + + function hcmExitDesync() { + const wasDesynced = hcmDesynced; + hcmDesynced = false; + hcmRemoveBadge(); + if (wasDesynced) { runtimeMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {}); - } - // Resync to the host's current position (retries if host state not yet known). - hcmRequestHostSyncWithRetry(); - reportLog('Host-only: resynced with the host', 'info'); - } - - function hcmShowBadge() { - if (hcmBadgeHost) return; - if (!document.body) { - // Body not ready yet (very early injection). Defer until DOMReady, - // otherwise the desynced user silently never sees the badge (L-4). - if (!hcmBadgePending) { - hcmBadgePending = true; + } + // Resync to the host's current position (retries if host state not yet known). + hcmRequestHostSyncWithRetry(); + reportLog('Host-only: resynced with the host', 'info'); + } + + function hcmShowBadge() { + if (hcmBadgeHost) return; + if (!document.body) { + // Body not ready yet (very early injection). Defer until DOMReady, + // otherwise the desynced user silently never sees the badge (L-4). + if (!hcmBadgePending) { + hcmBadgePending = true; const retry = () => { hcmBadgeDomReadyHandler = null; hcmBadgePending = false; @@ -555,80 +555,80 @@ if (document.readyState === 'loading') { hcmBadgeDomReadyHandler = retry; document.addEventListener('DOMContentLoaded', retry, { once: true }); - } else { + } else { scheduleLifecycleTimeout(retry, 50); - } - } - return; - } - const host = hcmEl('div', 'all:initial'); - const root = host.attachShadow({ mode: 'open' }); - const b = hcmEl('div', 'position:fixed;z-index:2147483646;right:16px;bottom:16px;background:#c96736;color:#fff;font:13px/1.3 system-ui,sans-serif;padding:8px 12px;border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.4);cursor:pointer;display:flex;align-items:center;gap:8px'); - b.append(hcmEl('span', null, '● ' + hcmStrings.badge), hcmEl('span', 'text-decoration:underline', hcmStrings.resync)); - b.addEventListener('click', hcmExitDesync); - root.appendChild(b); - document.body.appendChild(host); - hcmBadgeHost = host; - } - - function hcmRemoveBadge() { - if (hcmBadgeHost) { hcmBadgeHost.remove(); hcmBadgeHost = null; } - } - - function hcmReset() { - const wasDesynced = hcmDesynced; - hcmDesynced = false; - hcmDeferredSnapPending = false; - hcmSnapBackCooldownUntil = 0; // don't let a stale cooldown swallow the next snap-back - hcmBufferingUntil = 0; - hcmRemoveDialog(); - hcmRemoveBadge(); - // If we were desynced, notify background so it stops reporting us as - // desynced in heartbeats (otherwise the host's UI keeps showing the - // stale Solo badge until the next state change). - if (wasDesynced) { + } + } + return; + } + const host = hcmEl('div', 'all:initial'); + const root = host.attachShadow({ mode: 'open' }); + const b = hcmEl('div', 'position:fixed;z-index:2147483646;right:16px;bottom:16px;background:#c96736;color:#fff;font:13px/1.3 system-ui,sans-serif;padding:8px 12px;border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.4);cursor:pointer;display:flex;align-items:center;gap:8px'); + b.append(hcmEl('span', null, '● ' + hcmStrings.badge), hcmEl('span', 'text-decoration:underline', hcmStrings.resync)); + b.addEventListener('click', hcmExitDesync); + root.appendChild(b); + document.body.appendChild(host); + hcmBadgeHost = host; + } + + function hcmRemoveBadge() { + if (hcmBadgeHost) { hcmBadgeHost.remove(); hcmBadgeHost = null; } + } + + function hcmReset() { + const wasDesynced = hcmDesynced; + hcmDesynced = false; + hcmDeferredSnapPending = false; + hcmSnapBackCooldownUntil = 0; // don't let a stale cooldown swallow the next snap-back + hcmBufferingUntil = 0; + hcmRemoveDialog(); + hcmRemoveBadge(); + // If we were desynced, notify background so it stops reporting us as + // desynced in heartbeats (otherwise the host's UI keeps showing the + // stale Solo badge until the next state change). + if (wasDesynced) { runtimeMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {}); - } - } - - function reportLog(message, level = 'info') { + } + } + + function reportLog(message, level = 'info') { runtimeMessage({ type: 'LOG', message, level }).catch(() => {}); - } - - // --- Helper: find the best video element on the page --- - // Ranks candidates by a fixed order of signals rather than a weighted sum, - // so a disqualifying trait (no source, not rendered) can never be outvoted - // by sheer size. See pickBestVideo for the order and the reasoning. - function findVideo(root = document, depth = 0) { - return pickBestVideo(collectVideoCandidates(root, depth)); - } - - function collectVideoCandidates(root = document, depth = 0, out = []) { - for (const video of root.querySelectorAll('video')) out.push(video); - - // Scan likely media hosts even when light-DOM videos exist; many players - // expose a tiny preview/ad video outside Shadow DOM and the real player inside. - const potentialHosts = root.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 el of potentialHosts) { - if (el.shadowRoot) collectVideoCandidates(el.shadowRoot, depth, out); - } - - // Same-origin player frames (jkanime.net and similar) keep the real - //