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 - //