From 04694d4439fdc711d927fa4ef90a67e30fd612af Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:53:10 +0200 Subject: [PATCH 01/17] revert(extension): drop post-3.1.2 frame-targeting band-aids Restores extension/ to the state directly after webNavigation was removed (4d78970). The six follow-up commits layered heuristics on an unverified premise (frame-ID sweeps, multi-phase probes, retry loops) without fixing the underlying resolver. They are removed so the real fix can be built on a known state. Co-Authored-By: Claude Opus 5 --- extension/background.js | 491 +++++++---------------- extension/chat-overlay-contract.test.mjs | 12 - extension/chat-overlay.js | 18 +- extension/content.js | 46 +-- extension/locales/de.json | 1 - extension/locales/en.json | 1 - extension/locales/es.json | 1 - extension/locales/fr.json | 1 - extension/locales/it.json | 1 - extension/locales/ja.json | 1 - extension/locales/ko.json | 1 - extension/locales/nl.json | 1 - extension/locales/pl.json | 1 - extension/locales/pt-BR.json | 1 - extension/locales/pt.json | 1 - extension/locales/ru.json | 1 - extension/locales/tr.json | 1 - extension/locales/uk.json | 1 - extension/locales/zh.json | 1 - extension/media-frame-target.js | 157 ++------ extension/media-frame-target.test.mjs | 126 +----- extension/popup.js | 62 +-- extension/target-tab-lifecycle.test.mjs | 62 +-- 23 files changed, 245 insertions(+), 744 deletions(-) diff --git a/extension/background.js b/extension/background.js index d5ad3fc..179908f 100644 --- a/extension/background.js +++ b/extension/background.js @@ -11,6 +11,7 @@ import { createChatActivityStore } from './chat-activity.js'; import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js'; import { MEDIA_FRAME_ACCESS_REQUIRED, + MEDIA_FRAME_AMBIGUOUS, listMediaFrameScriptTargets, resolveMediaContentTarget } from './media-frame-target.js'; @@ -74,15 +75,6 @@ let currentTabTitle = null; // New: for Smart Matching let currentTargetFrameId = 0; let currentTargetDocumentId = null; let currentTargetHasVideo = false; -// The user's selection is kept separately from the currently injected media -// target. Dynamic player pages can be between frame documents while the -// popup is closed; losing the selection in that window makes reopening the -// popup look as if the user never selected a tab. -let requestedTargetTabId = null; -let requestedTargetTitle = null; -let requestedTargetRetryBlockedTabId = null; -let requestedTargetRetryBlockedMessage = null; -let pendingRequestedActivationCount = 0; let targetActivationGeneration = 0; let activeTargetActivation = null; let mediaTargetRefreshTask = null; @@ -225,8 +217,6 @@ function ensureState() { 'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks', 'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle', 'currentTargetFrameId', 'currentTargetDocumentId', 'currentTargetHasVideo', - 'requestedTargetTabId', 'requestedTargetTitle', - 'requestedTargetRetryBlockedTabId', 'requestedTargetRetryBlockedMessage', 'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt', 'hcmDesynced', 'chatActivityTimeline' ], (data) => { @@ -244,16 +234,6 @@ function ensureState() { ? data.currentTargetDocumentId : null; currentTargetHasVideo = currentTabId !== null && data.currentTargetHasVideo === true; - requestedTargetTabId = normalizeTabId(data.requestedTargetTabId); - requestedTargetTitle = requestedTargetTabId !== null - && typeof data.requestedTargetTitle === 'string' - ? data.requestedTargetTitle - : null; - requestedTargetRetryBlockedTabId = normalizeTabId(data.requestedTargetRetryBlockedTabId); - requestedTargetRetryBlockedMessage = requestedTargetRetryBlockedTabId !== null - && typeof data.requestedTargetRetryBlockedMessage === 'string' - ? data.requestedTargetRetryBlockedMessage - : null; if (data.currentTabTitle !== undefined) { currentTabTitle = currentTabId !== null && typeof data.currentTabTitle === 'string' ? data.currentTabTitle @@ -683,14 +663,6 @@ function sendMessageToContentTab(tabId, message, callback = null) { return chrome.tabs.sendMessage(tabId, message); } -function isMissingContentReceiverError(error) { - const message = String(error?.message || error || ''); - return message.includes('Receiving end does not exist') - || message.includes('Could not establish connection') - || message.includes('No document with id') - || message.includes('No document with ID'); -} - function isCurrentContentSender(sender) { if (!sender?.tab) return false; const senderTabId = normalizeTabId(sender.tab.id); @@ -721,6 +693,38 @@ function clearCurrentContentTarget() { currentTargetHasVideo = false; } +function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) { + if (expectedTabId !== null && normalizeTabId(currentTabId) !== normalizeTabId(expectedTabId)) { + return false; + } + if (expectedGeneration !== null && targetActivationGeneration !== expectedGeneration) { + return false; + } + + completeForceSyncBeforeTargetChange(null); + invalidateTargetActivations(); + clearPendingTarget().catch(() => {}); + if (currentTabId) deactivateTargetTab(currentTabId).catch(() => {}); + currentTabId = null; + currentTabTitle = null; + clearCurrentContentTarget(); + lastContentHeartbeatAt = null; + if (currentRoom) { + roomIdleSince = Date.now(); + } + chrome.storage.session.set({ + currentTabId, + currentTabTitle, + currentTargetFrameId, + currentTargetDocumentId, + currentTargetHasVideo, + roomIdleSince, + lastContentHeartbeatAt + }).catch(() => {}); + updateBadgeStatus(); + return true; +} + async function leaveRoomAfterIdleGrace(reason) { if (!currentRoom) return; connectIntent = false; @@ -742,7 +746,6 @@ async function leaveRoomAfterIdleGrace(reason) { broadcastControlMode(); if (currentTabId) await deactivateTargetTab(currentTabId); invalidateTargetActivations(); - await clearRequestedTarget(); currentTabId = null; currentTabTitle = null; clearCurrentContentTarget(); @@ -1125,48 +1128,6 @@ function chatActivityDisplayName(senderId) { return typeof peer === 'object' ? peer.username || senderId : senderId; } -async function deliverChatActivity(entry) { - const tabId = normalizeTabId(currentTabId); - if (tabId === null) return; - - const generation = targetActivationGeneration; - try { - await sendMessageToCurrentContent({ - type: 'CHAT_EVENT', - event: entry - }); - return; - } catch (error) { - if (!isMissingContentReceiverError(error)) { - addLog(`Chat activity delivery failed: ${error.message}`, 'warn'); - return; - } - if (!isCurrentTargetIdentity(tabId, generation)) return; - } - - for (let attempt = 0; attempt < 3; attempt++) { - try { - const activation = await refreshCurrentMediaTarget(tabId, { queueIfRunning: true }); - if (activation?.status === 'activation_in_progress') { - await new Promise(resolve => setTimeout(resolve, 150)); - continue; - } - if (activation?.status !== 'ok') return; - if (!isCurrentTargetIdentity(tabId, activation.generation)) return; - await sendMessageToCurrentContent({ - type: 'CHAT_EVENT', - event: entry - }); - return; - } catch (error) { - if (!isMissingContentReceiverError(error) || isCurrentTargetIdentity(tabId, generation)) { - addLog(`Chat activity delivery failed after target recovery: ${error.message}`, 'warn'); - } - return; - } - } -} - function sendChatActivity(action, senderId, timestamp = Date.now()) { if (!currentRoom || !serverSupportsChat()) return; if (![EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE, 'joined', 'left'].includes(action)) return; @@ -1179,9 +1140,10 @@ function sendChatActivity(action, senderId, timestamp = Date.now()) { if (!entry) return; if (storageInitialized) chrome.storage.session.set({ chatActivityTimeline: chatActivityStore.snapshot() }).catch(() => {}); if (!currentTabId) return; - deliverChatActivity(entry).catch(error => { - addLog(`Chat activity delivery failed: ${error.message}`, 'warn'); - }); + sendMessageToCurrentContent({ + type: 'CHAT_EVENT', + event: entry + }).catch(error => addLog(`Chat activity delivery failed: ${error.message}`, 'warn')); } function scheduleReconnect() { @@ -2067,32 +2029,20 @@ async function getReadyTabVideoState(tabId, expectedGeneration = targetActivatio if (!isCurrentTargetIdentity(tabId, expectedGeneration)) { return { error: 'Target tab changed before video state could be read' }; } - let state = null; - let targetGeneration = expectedGeneration; - for (let attempt = 0; attempt < 3; attempt++) { - state = await getTabVideoState(tabId); - if (state && !state.error && state.found !== false) break; - if (!isCurrentTargetIdentity(tabId, targetGeneration)) { - return { error: 'Target tab changed before video state could be read' }; - } - - const activation = await refreshCurrentMediaTarget(tabId, { queueIfRunning: true }); - if (activation?.status === 'activation_in_progress') { - await new Promise(resolve => setTimeout(resolve, 150)); - if (normalizeTabId(currentTabId) !== tabId) { - return { error: 'Target tab changed before video state could be read' }; - } - targetGeneration = targetActivationGeneration; - continue; - } + let state = await getTabVideoState(tabId); + if (!state || state.error || state.found === false) { + const activation = await refreshCurrentMediaTarget(tabId); if (activation?.status !== 'ok') { return { error: 'Target tab changed before content script recovery completed' }; } - targetGeneration = activation.generation; - if (!isCurrentTargetIdentity(tabId, targetGeneration)) { + await new Promise(resolve => setTimeout(resolve, 250)); + if (!isCurrentTargetIdentity(tabId, activation.generation)) { return { error: 'Target tab changed before video state could be read' }; } - await new Promise(resolve => setTimeout(resolve, 150)); + state = await getTabVideoState(tabId); + if (!isCurrentTargetIdentity(tabId, activation.generation)) { + return { error: 'Target tab changed while video state was being read' }; + } } return decorateVideoState(tabId, state); } @@ -2239,44 +2189,38 @@ function setPageApiSeekEnabled(enabled) { window.KOALA_PAGE_API_SEEK_ENABLED = enabled === true; } -function uniqueScriptTargets(targets) { - const seen = new Set(); - return targets.filter(target => { - const key = JSON.stringify(target); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -} - -function deactivateMediaFrameMonitor() { - try { window.__koalaMediaFrameMonitorCleanup?.(); } catch { /* detached frame */ } -} - -async function deactivateMediaFrameMonitors(tabId, contentTarget = null) { - const targets = uniqueScriptTargets([ - contentTarget?.scriptTarget, - ...(contentTarget?.monitorTargets || []), - { tabId }, - ...listMediaFrameScriptTargets(tabId) - ].filter(Boolean)); - for (const target of targets) { +async function deactivateMediaFrameMonitors(tabId) { + const targets = listMediaFrameScriptTargets(tabId); + await Promise.all(targets.map(async target => { + const documentId = target.documentIds?.[0]; + const frameId = target.frameIds?.[0]; try { - await chrome.scripting.executeScript({ - target, - func: deactivateMediaFrameMonitor - }); + if (typeof documentId === 'string') { + await chrome.tabs.sendMessage( + tabId, + { type: 'MEDIA_MONITOR_DEACTIVATE' }, + { documentId } + ); + } else if (Number.isInteger(frameId)) { + await chrome.tabs.sendMessage( + tabId, + { type: 'MEDIA_MONITOR_DEACTIVATE' }, + { frameId } + ); + } else { + await chrome.tabs.sendMessage(tabId, { type: 'MEDIA_MONITOR_DEACTIVATE' }); + } } catch { // Denied or already-navigated frames have no installed monitor. } - } + })); } async function deactivateTargetTab(tabId, contentTarget = null, { deactivateMonitor = true } = {}) { const normalizedTabId = normalizeTabId(tabId); if (normalizedTabId === null) return; if (deactivateMonitor) { - await deactivateMediaFrameMonitors(normalizedTabId, contentTarget); + await deactivateMediaFrameMonitors(normalizedTabId); } const target = contentTarget || (normalizedTabId === normalizeTabId(currentTabId) ? currentContentTarget() : null) @@ -2287,7 +2231,7 @@ async function deactivateTargetTab(tabId, contentTarget = null, { deactivateMoni } : null) || { frameId: 0, documentId: null }; - await resetAudioProcessingInTab(normalizedTabId, target); + resetAudioProcessingInTab(normalizedTabId, target); await sendMessageToFrame( normalizedTabId, target.frameId, @@ -2348,57 +2292,38 @@ function createTargetActivationSupersededError() { return error; } -const SCRIPT_INJECTION_TIMEOUT_MS = 5000; - -function executeScriptWithTimeout(options, timeoutMs = SCRIPT_INJECTION_TIMEOUT_MS) { - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - return chrome.scripting.executeScript(options); - } - let timeoutId = null; - const label = Array.isArray(options?.files) && options.files.length > 0 - ? options.files.join(', ') - : 'function injection'; - const timeout = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - const error = new Error(`Script injection timed out after ${timeoutMs}ms (${label})`); - error.code = 'script_injection_timeout'; - reject(error); - }, timeoutMs); - }); - return Promise.race([ - chrome.scripting.executeScript(options), - timeout - ]).finally(() => { - if (timeoutId !== null) clearTimeout(timeoutId); - }); -} - async function injectMediaFrameMonitors(tabId, contentTarget) { - // The all-frames target is only a best-effort sweep: one inaccessible - // frame can make Chromium reject the entire sweep. Always include the - // selected document explicitly so its lifecycle monitor is guaranteed. - const targets = uniqueScriptTargets([ - contentTarget?.scriptTarget, - ...(contentTarget?.monitorTargets || []), - { tabId }, - ...listMediaFrameScriptTargets(tabId) - ].filter(Boolean)); + const targets = listMediaFrameScriptTargets(tabId); let injectedCount = 0; - for (const target of targets) { + await Promise.all(targets.map(async target => { try { - await executeScriptWithTimeout({ + await chrome.scripting.executeScript({ target, files: ['media-frame-monitor.js'] - }, 2000); + }); injectedCount++; - } catch (error) { - if (error?.code === 'script_injection_timeout') { - addLog(`Media-frame monitor injection timed out for ${JSON.stringify(target)}`, 'warn'); - } + } catch { // One denied widget frame must not block the selected player. } + })); + if (injectedCount > 0) return; + + const fallbackTargets = [{ tabId }, contentTarget.scriptTarget]; + const seen = new Set(); + for (const target of fallbackTargets) { + const key = JSON.stringify(target); + if (seen.has(key)) continue; + seen.add(key); + try { + await chrome.scripting.executeScript({ + target, + files: ['media-frame-monitor.js'] + }); + injectedCount++; + } catch { + // Main injection below reports a real selected-target failure. + } } - return injectedCount; } async function injectContentScript(tabId, { @@ -2440,7 +2365,8 @@ async function injectContentScript(tabId, { originPattern: error.originPattern }, requestAdded, error); } - throw error; + if (error?.code === MEDIA_FRAME_AMBIGUOUS) throw error; + addLog(`Media frame probe fell back to the top frame: ${error.message}`, 'warn'); } const scriptTarget = contentTarget.scriptTarget; @@ -2460,12 +2386,12 @@ async function injectContentScript(tabId, { } if (needsPageApiSeek) { try { - await executeScriptWithTimeout({ + await chrome.scripting.executeScript({ target: scriptTarget, world: 'MAIN', files: ['page-api-seek-overrides.js'] }); - await executeScriptWithTimeout({ + await chrome.scripting.executeScript({ target: scriptTarget, world: 'MAIN', func: installPageApiSeekBridge @@ -2476,16 +2402,16 @@ async function injectContentScript(tabId, { } } - await executeScriptWithTimeout({ + await chrome.scripting.executeScript({ target: scriptTarget, files: ['page-api-seek-overrides.js'] }); - await executeScriptWithTimeout({ + await chrome.scripting.executeScript({ target: scriptTarget, func: setPageApiSeekEnabled, args: [pageApiSeekReady] }); - const injectionResults = await executeScriptWithTimeout({ + const injectionResults = await chrome.scripting.executeScript({ target: scriptTarget, files: ['chat-format.js', 'chat-overlay.js', 'content.js'] }); @@ -2683,83 +2609,6 @@ async function clearPendingTarget({ expectedRequestId = null, expectedTabId = nu }); } -async function rememberRequestedTarget(tabId, tabTitle) { - const normalizedTabId = normalizeTabId(tabId); - if (normalizedTabId === null) return false; - requestedTargetTabId = normalizedTabId; - requestedTargetTitle = typeof tabTitle === 'string' ? tabTitle : null; - requestedTargetRetryBlockedTabId = null; - requestedTargetRetryBlockedMessage = null; - await chrome.storage.session.set({ - requestedTargetTabId, - requestedTargetTitle, - requestedTargetRetryBlockedTabId: null, - requestedTargetRetryBlockedMessage: null - }); - return true; -} - -async function clearRequestedTarget(expectedTabId = null) { - if (expectedTabId !== null - && normalizeTabId(requestedTargetTabId) !== normalizeTabId(expectedTabId)) { - return false; - } - requestedTargetTabId = null; - requestedTargetTitle = null; - requestedTargetRetryBlockedTabId = null; - requestedTargetRetryBlockedMessage = null; - await chrome.storage.session.set({ - requestedTargetTabId: null, - requestedTargetTitle: null, - requestedTargetRetryBlockedTabId: null, - requestedTargetRetryBlockedMessage: null - }); - return true; -} - -async function retryRequestedTarget() { - const selectedTabId = normalizeTabId(requestedTargetTabId); - if (selectedTabId === null - || normalizeTabId(currentTabId) === selectedTabId - || pendingRequestedActivationCount > 0 - || activeTargetActivation - || normalizeTabId(requestedTargetRetryBlockedTabId) === selectedTabId) { - return null; - } - - const pending = await readPendingTarget(); - if (pending) return null; - - try { - await chrome.tabs.get(selectedTabId); - } catch { - await clearRequestedTarget(selectedTabId); - return { status: 'target_closed' }; - } - - try { - const response = await activateTargetTab(selectedTabId, requestedTargetTitle, { - requestHostAccess: true, - expectedGeneration: targetActivationGeneration - }); - if (response?.status === 'ok') { - await clearRequestedTarget(selectedTabId); - } - return response; - } catch (error) { - if (error?.code !== HOST_ACCESS_REQUIRED_STATUS) { - requestedTargetRetryBlockedTabId = selectedTabId; - requestedTargetRetryBlockedMessage = error?.message || 'Script injection failed'; - await chrome.storage.session.set({ - requestedTargetRetryBlockedTabId, - requestedTargetRetryBlockedMessage - }); - } - addLog(`Requested target retry failed: ${error.message}`, 'warn'); - return injectionFailureResponse(error); - } -} - async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true, expectedGeneration = null, @@ -2785,6 +2634,9 @@ async function activateTargetTab(tabId, tabTitle, { let injectedContentTarget = { frameId: 0, documentId: null, hasVideo: false }; try { + if (previousTabId && previousTabId !== selectedTabId) { + await deactivateTargetTab(previousTabId); + } if (activationGeneration !== targetActivationGeneration) { return { status: 'superseded' }; } @@ -2809,37 +2661,32 @@ async function activateTargetTab(tabId, tabTitle, { } return { status: 'superseded' }; } - const isCurrentTargetRefresh = previousTabId === selectedTabId - && expectedCurrentTabId === selectedTabId; - if (isCurrentTargetRefresh) { - addLog( - isMediaTargetNavigationError(error) - ? 'Media document changed during refresh; keeping the previous target until navigation completes' - : `Media target refresh failed (${error.message}); keeping the selected target for recovery`, - 'warn' - ); + if (previousTabId === selectedTabId + && expectedCurrentTabId === selectedTabId + && isMediaTargetNavigationError(error)) { + addLog('Media document changed during refresh; keeping the previous target until navigation completes', 'warn'); throw error; } + currentTabId = null; + currentTabTitle = null; + clearCurrentContentTarget(); + lastContentHeartbeatAt = null; + if (currentRoom) roomIdleSince = Date.now(); const failedContentTarget = error?.contentTarget || injectedContentTarget; await deactivateTargetTab(selectedTabId, failedContentTarget); - if (previousTabId === null) { - currentTabId = null; - currentTabTitle = null; - clearCurrentContentTarget(); - lastContentHeartbeatAt = null; - if (currentRoom) roomIdleSince = Date.now(); - await chrome.storage.session.set({ - currentTabId: null, - currentTabTitle: null, - currentTargetFrameId: 0, - currentTargetDocumentId: null, - currentTargetHasVideo: false, - roomIdleSince, - lastContentHeartbeatAt: null - }); - } else { - addLog(`Target switch to tab ${selectedTabId} failed; keeping tab ${previousTabId} selected`, 'warn'); + if (previousTabId && (previousTabId !== selectedTabId + || !sameContentTarget(previousContentTarget, failedContentTarget))) { + await deactivateTargetTab(previousTabId, previousContentTarget); } + await chrome.storage.session.set({ + currentTabId: null, + currentTabTitle: null, + currentTargetFrameId: 0, + currentTargetDocumentId: null, + currentTargetHasVideo: false, + roomIdleSince, + lastContentHeartbeatAt: null + }); if (activationGeneration !== targetActivationGeneration) { return { status: 'superseded' }; } @@ -2889,9 +2736,7 @@ async function activateTargetTab(tabId, tabTitle, { if (currentTabId !== selectedTabId) await deactivateTargetTab(selectedTabId, injectedContentTarget); return { status: 'superseded' }; } - if (previousTabId && previousTabId !== selectedTabId) { - await deactivateTargetTab(previousTabId, previousContentTarget); - } else if (previousTabId === selectedTabId + if (previousTabId === selectedTabId && !sameContentTarget(previousContentTarget, injectedContentTarget)) { await deactivateTargetTab(previousTabId, previousContentTarget, { deactivateMonitor: false }); } @@ -2913,7 +2758,6 @@ async function activateTargetTab(tabId, tabTitle, { roomIdleSince, lastContentHeartbeatAt }); - await clearRequestedTarget(selectedTabId); if (activationGeneration !== targetActivationGeneration) { return { status: 'superseded' }; } @@ -3088,8 +2932,7 @@ if (chrome.tabs?.onRemoved?.addListener) { const isCurrent = normalizeTabId(currentTabId) === tabId; const isPending = pending?.tabId === tabId; const isActivating = activeTargetActivation?.tabId === tabId; - const isRequested = normalizeTabId(requestedTargetTabId) === tabId; - if (!isCurrent && !isPending && !isActivating && !isRequested) return; + if (!isCurrent && !isPending && !isActivating) return; const hasReplacementActivation = activeTargetActivation && activeTargetActivation.tabId !== tabId; @@ -3110,7 +2953,6 @@ if (chrome.tabs?.onRemoved?.addListener) { expectedTabId: tabId }); } - if (isRequested) await clearRequestedTarget(tabId); await chrome.storage.session.set({ currentTabId, currentTabTitle, @@ -3189,12 +3031,16 @@ async function _routeToContentInternal(tabId, action, payload, actionTimestamp, return; } if (retries >= 3) { - addLog(`Content Script not responding in tab ${tabId} after ${retries} retries; keeping the selected target for recovery`, 'warn'); + addLog(`Content Script not responding in tab ${tabId} after ${retries} retries`, 'warn'); + clearTargetTabForIdle(tabId, targetGeneration); return; } const message = String(error?.message || ''); - if (isMissingContentReceiverError(error) || message.includes('Extension context invalidated')) { + if (message.includes('Receiving end does not exist') + || message.includes('Extension context invalidated') + || message.includes('No document with id') + || message.includes('No document with ID')) { try { const response = await refreshCurrentMediaTarget(tabId); if (response?.status !== 'ok' && response?.status !== 'activation_in_progress') return; @@ -3214,6 +3060,7 @@ async function _routeToContentInternal(tabId, action, payload, actionTimestamp, } addLog(`Content Script not responding in tab ${tabId}`, 'warn'); + clearTargetTabForIdle(tabId, targetGeneration); } } @@ -3291,27 +3138,27 @@ function leaveOldRoomIfSwitching(newRoomId) { } } -async function resetAudioProcessingInTab(tabId, contentTarget = null) { - const normalizedTabId = normalizeTabId(tabId); - if (normalizedTabId === null) return null; +function resetAudioProcessingInTab(tabId, contentTarget = null) { + if (!tabId) return; if (contentTarget) { - return sendMessageToFrame( - normalizedTabId, + sendMessageToFrame( + tabId, contentTarget.frameId, { action: 'RESET_AUDIO_PROCESSING' }, null, contentTarget.documentId - ).catch(() => null); + ).catch(() => {}); + return; } - if (normalizedTabId === normalizeTabId(currentTabId)) { - return sendMessageToCurrentContent({ action: 'RESET_AUDIO_PROCESSING' }).catch(() => null); + if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) { + sendMessageToCurrentContent({ action: 'RESET_AUDIO_PROCESSING' }).catch(() => {}); + return; } - return chrome.tabs.sendMessage(normalizedTabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => null); + chrome.tabs.sendMessage(tabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => {}); } async function applyAudioSettingsToTab(tabId, contentTarget = null) { - const normalizedTabId = normalizeTabId(tabId); - if (normalizedTabId === null) return; + if (!tabId) return; // Local-only: audioSettings are never read from storage.sync. const data = await chrome.storage.local.get(['audioSettings']); const message = { @@ -3319,20 +3166,20 @@ async function applyAudioSettingsToTab(tabId, contentTarget = null) { settings: data.audioSettings }; if (contentTarget) { - await sendMessageToFrame( - normalizedTabId, + sendMessageToFrame( + tabId, contentTarget.frameId, message, null, contentTarget.documentId - ).catch(() => null); + ).catch(() => {}); return; } - if (normalizedTabId === normalizeTabId(currentTabId)) { - await sendMessageToCurrentContent(message).catch(() => null); + if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) { + sendMessageToCurrentContent(message).catch(() => {}); return; } - await chrome.tabs.sendMessage(normalizedTabId, message).catch(() => null); + chrome.tabs.sendMessage(tabId, message).catch(() => {}); } // --- Extension Message Listeners --- @@ -3424,7 +3271,6 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (message.retryPendingTarget === true) { await retryPendingTarget(); } - await retryRequestedTarget(); const pendingTarget = await readPendingTarget(); const settings = await getSettings(); const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined; @@ -3432,36 +3278,15 @@ 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'; - const targetTabId = normalizeTabId(requestedTargetTabId) - ?? normalizeTabId(activeTargetActivation?.tabId) - ?? normalizeTabId(currentTabId); - const targetReady = targetTabId !== null - && normalizeTabId(currentTabId) === targetTabId - && !activeTargetActivation; - const targetActivationState = targetTabId === null - ? 'none' - : targetReady - ? 'ready' - : pendingTarget?.tabId === targetTabId - ? 'access_required' - : normalizeTabId(requestedTargetRetryBlockedTabId) === targetTabId - ? 'error' - : 'activating'; - const targetActivationError = normalizeTabId(requestedTargetRetryBlockedTabId) === targetTabId - ? requestedTargetRetryBlockedMessage - : null; sendResponse({ status, peerId, peers: currentRoom ? currentRoom.peers : [], lastActionState, - targetTabId, + targetTabId: currentTabId, targetFrameId: currentTargetFrameId, targetDocumentId: currentTargetDocumentId, targetHasVideo: currentTargetHasVideo, - targetReady, - targetActivationState, - targetActivationError, pendingTargetTabId: pendingTarget?.tabId ?? null, pendingTargetHost: pendingTarget?.host ?? null, pendingTargetOriginPattern: pendingTarget?.originPattern ?? null, @@ -3692,7 +3517,6 @@ async function handleAsyncMessage(message, sender, sendResponse) { broadcastControlMode(); if (currentTabId) await deactivateTargetTab(currentTabId, currentContentTarget()); invalidateTargetActivations(); - await clearRequestedTarget(); currentTabId = null; currentTabTitle = null; clearCurrentContentTarget(); @@ -4134,7 +3958,6 @@ async function handleAsyncMessage(message, sender, sendResponse) { const previousContentTarget = currentContentTarget(); completeForceSyncBeforeTargetChange(null); invalidateTargetActivations(); - await clearRequestedTarget(); currentTabId = null; currentTabTitle = null; clearCurrentContentTarget(); @@ -4159,19 +3982,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { } try { - const selectedTabId = normalizeTabId(message.tabId); - if (selectedTabId === null) { - sendResponse({ status: 'invalid_tab' }); - return; - } - pendingRequestedActivationCount++; - let response; - try { - await rememberRequestedTarget(selectedTabId, message.tabTitle); - response = await activateTargetTab(selectedTabId, message.tabTitle); - } finally { - pendingRequestedActivationCount = Math.max(0, pendingRequestedActivationCount - 1); - } + const response = await activateTargetTab(message.tabId, message.tabTitle); if (response?.status === 'ok') { chrome.runtime.sendMessage({ type: 'TARGET_TAB_READY', diff --git a/extension/chat-overlay-contract.test.mjs b/extension/chat-overlay-contract.test.mjs index 3eca8be..5989f3b 100644 --- a/extension/chat-overlay-contract.test.mjs +++ b/extension/chat-overlay-contract.test.mjs @@ -173,18 +173,6 @@ describe('chat overlay contract', () => { expect(overlaySource).toContain("if (destroyed || area !== 'local') return"); }); - it('persists the last manual open/closed state across context refreshes', () => { - expect(overlaySource).toContain('const openStateKey = `chatOverlayOpen:${location.origin}`'); - expect(overlaySource).toContain('let lastUserOpenState = null'); - expect(overlaySource).toContain('function setOpened(next, persistPreference = true)'); - expect(overlaySource).toContain('setLocalStorage({ [openStateKey]: opened })'); - expect(overlaySource).toContain('setOpened(false, false)'); - expect(overlaySource).toContain('setOpened(lastUserOpenState ?? (chatStartMode === \'open\'), false)'); - expect(overlaySource).toContain('typeof data[openStateKey] === \'boolean\''); - expect(overlaySource).toContain('const previousEnabled = context?.enabled === true'); - expect(overlaySource).toContain('(!startStateApplied || !previousEnabled)'); - }); - it('keeps chat hidden by default without discarding the room chat key', () => { expect(popupSource).toContain('localData.chatEnabled === true'); expect(backgroundSource).toContain('chatEnabled: data.chatEnabled === true'); diff --git a/extension/chat-overlay.js b/extension/chat-overlay.js index 48eb69b..22a4273 100644 --- a/extension/chat-overlay.js +++ b/extension/chat-overlay.js @@ -28,7 +28,6 @@ large: Object.freeze({ width: 440, height: 640 }) }); const storageKey = `chatOverlayLayout:${location.origin}`; - const openStateKey = `chatOverlayOpen:${location.origin}`; const systemTheme = window.matchMedia('(prefers-color-scheme: light)'); let context = null; let opened = false; @@ -58,7 +57,6 @@ let chatSize = 'standard'; let chatStartMode = 'bubble'; let chatReactionDisplay = 'chat'; - let lastUserOpenState = null; let themeMode = 'system'; let themePalette = 'eucalyptus'; let pageDockTarget = null; @@ -578,12 +576,8 @@ if (persistPreference) setLocalStorage({ chatSize }); } - function setOpened(next, persistPreference = true) { + function setOpened(next) { opened = !!next && !!context?.enabled; - if (persistPreference) { - lastUserOpenState = opened; - setLocalStorage({ [openStateKey]: opened }); - } panel.classList.toggle('open', opened); launcher.style.display = opened ? 'none' : ''; if (opened) { @@ -596,7 +590,6 @@ function applyContext(next) { const previousRoomId = context?.roomId; - const previousEnabled = context?.enabled === true; context = next || null; const supported = !!context?.supported; const optedIn = !!context?.enabled; @@ -611,10 +604,10 @@ launcher.setAttribute('aria-disabled', String(!context?.enabled)); if (!optedIn) startStateApplied = false; if (!context?.enabled) { - setOpened(false, false); - } else if (preferencesLoaded && (!startStateApplied || !previousEnabled)) { + setOpened(false); + } else if (preferencesLoaded && !startStateApplied) { startStateApplied = true; - setOpened(lastUserOpenState ?? (chatStartMode === 'open'), false); + setOpened(chatStartMode === 'open'); } applyStrings(); applyLayout(); @@ -974,7 +967,7 @@ systemTheme.addEventListener('change', handleSystemTheme); chrome.storage.onChanged.addListener(handleStorage); chrome.runtime.onMessage.addListener(handleRuntime); - chrome.storage.local.get([storageKey, openStateKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay'], data => { + chrome.storage.local.get([storageKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay'], data => { if (destroyed) return; const storedLayout = data[storageKey]; if (storedLayout && typeof storedLayout === 'object') { @@ -985,7 +978,6 @@ chatPosition = normalizePosition(data.chatPosition); chatSize = normalizeSize(data.chatSize); chatStartMode = data.chatStartMode === 'open' ? 'open' : 'bubble'; - lastUserOpenState = typeof data[openStateKey] === 'boolean' ? data[openStateKey] : null; chatReactionDisplay = data.chatReactionDisplay === 'video' ? 'video' : 'chat'; layout.mode = chatPosition; if (layout.mode === 'detached') layout.detachedInitialized = true; diff --git a/extension/content.js b/extension/content.js index ad0c412..9a41c01 100644 --- a/extension/content.js +++ b/extension/content.js @@ -867,30 +867,19 @@ return audioCtx; } - function closeAudioContext() { - const closingContext = audioCtx; - if (audioCtx) { - audioCtx.close().catch(() => {}); - audioCtx = null; - } - if (window.__koalaSyncAudioRoute?.audioCtx === closingContext) { - delete window.__koalaSyncAudioRoute; - } - audioChains = new WeakMap(); - currentAudioVideo = null; - } + function closeAudioContext() { + if (audioCtx) { + audioCtx.close().catch(() => {}); + audioCtx = null; + } + audioChains = new WeakMap(); + currentAudioVideo = null; + } - function setupAudioChain(videoEl) { - if (audioChains.has(videoEl)) return audioChains.get(videoEl); - const retainedRoute = window.__koalaSyncAudioRoute; - if (retainedRoute?.video === videoEl && retainedRoute.audioCtx && retainedRoute.chain) { - audioCtx = retainedRoute.audioCtx; - audioChains.set(videoEl, retainedRoute.chain); - currentAudioVideo = videoEl; - return retainedRoute.chain; - } - const ctx = initAudioContext(); - if (!ctx) return null; + function setupAudioChain(videoEl) { + if (audioChains.has(videoEl)) return audioChains.get(videoEl); + const ctx = initAudioContext(); + if (!ctx) return null; try { const src = ctx.createMediaElementSource(videoEl); @@ -918,9 +907,8 @@ limiter.release.value = 0.1; const chain = { compressor, dryGain, compGain, outputGain, limiter, active: false, signature: '' }; - audioChains.set(videoEl, chain); - currentAudioVideo = videoEl; - window.__koalaSyncAudioRoute = { video: videoEl, audioCtx: ctx, chain }; + audioChains.set(videoEl, chain); + currentAudioVideo = videoEl; return chain; } catch (e) { reportLog(`Audio Processing setup failed: ${e.message}`, 'warn'); @@ -1250,7 +1238,7 @@ function handleRuntimeMessage(message, sender, sendResponse) { if (!message) return; if (message.type === 'TARGET_DEACTIVATE') { - destroyContentScript({ preserveAudioRoute: true }); + destroyContentScript(); sendResponse({ ok: true }); return true; } @@ -2074,7 +2062,7 @@ } } - function destroyContentScript({ preserveAudioRoute = false } = {}) { + function destroyContentScript() { if (destroyed) return; destroyed = true; @@ -2129,7 +2117,7 @@ hcmRemoveDialog(); hcmRemoveBadge(); bypassCurrentAudioProcessing(); - if (!preserveAudioRoute) closeAudioContext(); + closeAudioContext(); try { window.koalaSyncChatOverlay?.destroy?.(); } catch (_e) { /* invalidated chat context */ } try { chrome.storage.onChanged.removeListener(handleStorageChanged); } catch (_e) { /* invalidated context */ } diff --git a/extension/locales/de.json b/extension/locales/de.json index 135d6e1..d9e218a 100644 --- a/extension/locales/de.json +++ b/extension/locales/de.json @@ -190,7 +190,6 @@ "NOTIF_FORCE_PREPARE": "eine erzwungene Synchronisation gestartet", "NOTIF_FORCE_EXECUTE": "alle Teilnehmer synchronisiert", "DEBUG_NO_TAB": "Kein Ziel-Tab ausgewählt.", - "DEBUG_TARGET_ACTIVATING": "Ziel-Tab ausgewählt; Video-Injection wird vorbereitet.", "DEBUG_COMM_FAIL": "Kommunikation mit dem Tab-Video fehlgeschlagen.", "EMPTY_PEERS_TITLE": "Noch keine Teilnehmer", "EMPTY_PEERS_HINT": "Teile deinen Einladungslink, um loszulegen", diff --git a/extension/locales/en.json b/extension/locales/en.json index 8babf44..d9bc1ec 100644 --- a/extension/locales/en.json +++ b/extension/locales/en.json @@ -190,7 +190,6 @@ "NOTIF_FORCE_PREPARE": "started force sync", "NOTIF_FORCE_EXECUTE": "synchronized everyone", "DEBUG_NO_TAB": "No target tab selected.", - "DEBUG_TARGET_ACTIVATING": "Target tab selected; preparing video injection.", "DEBUG_COMM_FAIL": "Could not communicate with tab video.", "EMPTY_PEERS_TITLE": "No peers yet", "EMPTY_PEERS_HINT": "Share your invite link to get started", diff --git a/extension/locales/es.json b/extension/locales/es.json index 99d9b86..f512f00 100644 --- a/extension/locales/es.json +++ b/extension/locales/es.json @@ -190,7 +190,6 @@ "NOTIF_FORCE_PREPARE": "ha iniciado una sincronización forzada", "NOTIF_FORCE_EXECUTE": "ha sincronizado a todos", "DEBUG_NO_TAB": "No hay pestaña objetivo seleccionada.", - "DEBUG_TARGET_ACTIVATING": "Pestaña objetivo seleccionada; preparando la inyección de vídeo.", "DEBUG_COMM_FAIL": "No se pudo comunicar con el video de la pestaña.", "EMPTY_PEERS_TITLE": "Sin participantes aún", "EMPTY_PEERS_HINT": "Comparte tu enlace de invitación para comenzar", diff --git a/extension/locales/fr.json b/extension/locales/fr.json index 985fdb0..da60a9c 100644 --- a/extension/locales/fr.json +++ b/extension/locales/fr.json @@ -190,7 +190,6 @@ "NOTIF_FORCE_PREPARE": "lancé une synchronisation forcée", "NOTIF_FORCE_EXECUTE": "synchronisé tout le monde", "DEBUG_NO_TAB": "Aucun onglet cible sélectionné.", - "DEBUG_TARGET_ACTIVATING": "Onglet cible sélectionné ; préparation de l’injection vidéo.", "DEBUG_COMM_FAIL": "Impossible de communiquer avec l'onglet vidéo.", "EMPTY_PEERS_TITLE": "Aucun membre pour l'instant", "EMPTY_PEERS_HINT": "Partagez votre lien d'invitation pour commencer", diff --git a/extension/locales/it.json b/extension/locales/it.json index fafdd6a..ca5cb8d 100644 --- a/extension/locales/it.json +++ b/extension/locales/it.json @@ -190,7 +190,6 @@ "NOTIF_FORCE_PREPARE": "ha avviato una sincronizzazione forzata", "NOTIF_FORCE_EXECUTE": "ha sincronizzato tutti", "DEBUG_NO_TAB": "Nessuna scheda selezionata.", - "DEBUG_TARGET_ACTIVATING": "Scheda target selezionata; preparazione dell’iniezione video.", "DEBUG_COMM_FAIL": "Errore di comunicazione con il video.", "EMPTY_PEERS_TITLE": "Nessun partecipante", "EMPTY_PEERS_HINT": "Condividi il tuo link per iniziare", diff --git a/extension/locales/ja.json b/extension/locales/ja.json index 48ba1e4..68ad760 100644 --- a/extension/locales/ja.json +++ b/extension/locales/ja.json @@ -190,7 +190,6 @@ "NOTIF_FORCE_PREPARE": "強制同期を開始しました", "NOTIF_FORCE_EXECUTE": "全員を同期しました", "DEBUG_NO_TAB": "対象のタブが選択されていません。", - "DEBUG_TARGET_ACTIVATING": "対象のタブを選択しました。動画スクリプトを準備しています。", "DEBUG_COMM_FAIL": "タブのビデオと通信できませんでした。", "EMPTY_PEERS_TITLE": "メンバーはまだいません", "EMPTY_PEERS_HINT": "招待リンクを共有して始めましょう", diff --git a/extension/locales/ko.json b/extension/locales/ko.json index ede88bd..2354f2f 100644 --- a/extension/locales/ko.json +++ b/extension/locales/ko.json @@ -190,7 +190,6 @@ "NOTIF_FORCE_PREPARE": "강제 동기화를 시작했습니다", "NOTIF_FORCE_EXECUTE": "모든 사용자를 동기화했습니다", "DEBUG_NO_TAB": "대상 탭이 선택되지 않았습니다.", - "DEBUG_TARGET_ACTIVATING": "대상 탭이 선택되었습니다. 동영상 주입을 준비하는 중입니다.", "DEBUG_COMM_FAIL": "탭 비디오와 통신할 수 없습니다.", "EMPTY_PEERS_TITLE": "참여자 없음", "EMPTY_PEERS_HINT": "시작하려면 초대 링크를 공유하세요", diff --git a/extension/locales/nl.json b/extension/locales/nl.json index dc9a581..4cac4c9 100644 --- a/extension/locales/nl.json +++ b/extension/locales/nl.json @@ -190,7 +190,6 @@ "NOTIF_FORCE_PREPARE": "is een geforceerde sync gestart", "NOTIF_FORCE_EXECUTE": "heeft iedereen gesynchroniseerd", "DEBUG_NO_TAB": "Geen doeltabblad geselecteerd.", - "DEBUG_TARGET_ACTIVATING": "Doeltabblad geselecteerd; video-injectie wordt voorbereid.", "DEBUG_COMM_FAIL": "Kon niet communiceren met de videotab.", "EMPTY_PEERS_TITLE": "Nog geen deelnemers", "EMPTY_PEERS_HINT": "Deel uw uitnodigingslink om te beginnen", diff --git a/extension/locales/pl.json b/extension/locales/pl.json index 6479e06..abf7622 100644 --- a/extension/locales/pl.json +++ b/extension/locales/pl.json @@ -190,7 +190,6 @@ "NOTIF_FORCE_PREPARE": "wymusił synchronizację", "NOTIF_FORCE_EXECUTE": "zsynchronizował wszystkich", "DEBUG_NO_TAB": "Nie wybrano karty docelowej.", - "DEBUG_TARGET_ACTIVATING": "Wybrano kartę docelową; przygotowywanie wstrzyknięcia wideo.", "DEBUG_COMM_FAIL": "Nie można skomunikować się z wideo w karcie.", "EMPTY_PEERS_TITLE": "Brak uczestników", "EMPTY_PEERS_HINT": "Udostępnij link zaproszenia, aby rozpocząć", diff --git a/extension/locales/pt-BR.json b/extension/locales/pt-BR.json index be76600..31645e1 100644 --- a/extension/locales/pt-BR.json +++ b/extension/locales/pt-BR.json @@ -190,7 +190,6 @@ "NOTIF_FORCE_PREPARE": "iniciou uma sincronização forçada", "NOTIF_FORCE_EXECUTE": "sincronizou todos", "DEBUG_NO_TAB": "Nenhuma aba selecionada.", - "DEBUG_TARGET_ACTIVATING": "Aba de destino selecionada; preparando a injeção de vídeo.", "DEBUG_COMM_FAIL": "Erro ao se comunicar com o vídeo.", "EMPTY_PEERS_TITLE": "Nenhum participante", "EMPTY_PEERS_HINT": "Compartilhe seu link de convite para começar", diff --git a/extension/locales/pt.json b/extension/locales/pt.json index 6b127dd..e5b5eff 100644 --- a/extension/locales/pt.json +++ b/extension/locales/pt.json @@ -190,7 +190,6 @@ "NOTIF_FORCE_PREPARE": "iniciou uma sincronização forçada", "NOTIF_FORCE_EXECUTE": "sincronizou todos", "DEBUG_NO_TAB": "Nenhum separador selecionado.", - "DEBUG_TARGET_ACTIVATING": "Separador de destino selecionado; a preparar a injeção de vídeo.", "DEBUG_COMM_FAIL": "Erro ao comunicar com o vídeo.", "EMPTY_PEERS_TITLE": "Nenhum participante", "EMPTY_PEERS_HINT": "Partilhe o seu link de convite para começar", diff --git a/extension/locales/ru.json b/extension/locales/ru.json index a2d4c44..80db8cb 100644 --- a/extension/locales/ru.json +++ b/extension/locales/ru.json @@ -190,7 +190,6 @@ "NOTIF_FORCE_PREPARE": "запустил принудительный синхрон", "NOTIF_FORCE_EXECUTE": "синхронизировал воспроизведение у всех", "DEBUG_NO_TAB": "Целевая вкладка не выбрана.", - "DEBUG_TARGET_ACTIVATING": "Целевая вкладка выбрана; подготовка внедрения видео.", "DEBUG_COMM_FAIL": "Не удалось связаться с плеером на вкладке.", "EMPTY_PEERS_TITLE": "Участников пока нет", "EMPTY_PEERS_HINT": "Поделитесь ссылкой-приглашением, чтобы начать", diff --git a/extension/locales/tr.json b/extension/locales/tr.json index afadf12..d9bc14b 100644 --- a/extension/locales/tr.json +++ b/extension/locales/tr.json @@ -190,7 +190,6 @@ "NOTIF_FORCE_PREPARE": "zorunlu eşitleme başlattı", "NOTIF_FORCE_EXECUTE": "herkesi eşitledi", "DEBUG_NO_TAB": "Hedef sekme seçilmedi.", - "DEBUG_TARGET_ACTIVATING": "Hedef sekme seçildi; video enjeksiyonu hazırlanıyor.", "DEBUG_COMM_FAIL": "Sekme videosuyla iletişim kurulamadı.", "EMPTY_PEERS_TITLE": "Henüz kimse yok", "EMPTY_PEERS_HINT": "Başlamak için davet bağlantınızı paylaşın", diff --git a/extension/locales/uk.json b/extension/locales/uk.json index 0357dc9..12b14d7 100644 --- a/extension/locales/uk.json +++ b/extension/locales/uk.json @@ -190,7 +190,6 @@ "NOTIF_FORCE_PREPARE": "почав примусову синхронізацію", "NOTIF_FORCE_EXECUTE": "синхронізував усіх", "DEBUG_NO_TAB": "Цільова вкладка не вибрана.", - "DEBUG_TARGET_ACTIVATING": "Цільову вкладку вибрано; готується впровадження відео.", "DEBUG_COMM_FAIL": "Не вдалося зв’язатися з відео вкладки.", "EMPTY_PEERS_TITLE": "Учасників ще немає", "EMPTY_PEERS_HINT": "Поділіться своїм запрошенням, щоб почати", diff --git a/extension/locales/zh.json b/extension/locales/zh.json index b87c784..b735cae 100644 --- a/extension/locales/zh.json +++ b/extension/locales/zh.json @@ -190,7 +190,6 @@ "NOTIF_FORCE_PREPARE": "开始强制同步", "NOTIF_FORCE_EXECUTE": "同步所有人", "DEBUG_NO_TAB": "未选择目标选项卡。", - "DEBUG_TARGET_ACTIVATING": "已选择目标标签页;正在准备注入视频脚本。", "DEBUG_COMM_FAIL": "无法与标签视频通信。", "EMPTY_PEERS_TITLE": "还没有同行", "EMPTY_PEERS_HINT": "分享您的邀请链接以开始使用", diff --git a/extension/media-frame-target.js b/extension/media-frame-target.js index 7661b82..1af799e 100644 --- a/extension/media-frame-target.js +++ b/extension/media-frame-target.js @@ -1,8 +1,9 @@ export const MEDIA_FRAME_ACCESS_REQUIRED = 'media_frame_access_required'; +export const MEDIA_FRAME_AMBIGUOUS = 'media_frame_ambiguous'; + const MIN_PLAYER_FRAME_AREA = 320 * 180; const MIN_PLAYER_ASPECT_RATIO = 1.15; const MAX_PLAYER_ASPECT_RATIO = 2.6; -const DEFAULT_PROBE_TIMEOUT_MS = 1500; function normalizeFrameId(value) { return Number.isInteger(value) && value >= 0 ? value : 0; @@ -406,10 +407,16 @@ function accessRequiredError(access) { return error; } -function contentTarget(tabId, selected, monitorTargets = null) { +function ambiguousFrameError() { + const error = new Error('The active embedded video frame could not be identified safely'); + error.code = MEDIA_FRAME_AMBIGUOUS; + return error; +} + +function contentTarget(tabId, selected) { const frameId = normalizeFrameId(selected?.frameId); const documentId = typeof selected?.documentId === 'string' ? selected.documentId : null; - const target = { + return { frameId, documentId, frameUrl: typeof selected?.result?.href === 'string' ? selected.result.href : null, @@ -418,76 +425,16 @@ function contentTarget(tabId, selected, monitorTargets = null) { ? { tabId, documentIds: [documentId] } : (frameId === 0 ? { tabId } : { tabId, frameIds: [frameId] }) }; - if (Array.isArray(monitorTargets) && monitorTargets.length > 0) { - target.monitorTargets = monitorTargets; - } - return target; } export function listMediaFrameScriptTargets(tabId) { return [{ tabId, allFrames: true }]; } -function listFrameProbeTargets(tabId, embeddedFrameCount = 0) { - // Chromium can reject one all-frames executeScript call when a single - // child frame is browser-owned or temporarily unavailable. Frame IDs are - // not exposed without webNavigation, so probe a bounded range individually - // after the top frame tells us that embedded frames exist. Each rejected - // probe is isolated and cannot hide the other frames. - const maxFrameId = Math.min(64, Math.max(8, (embeddedFrameCount * 4) + 4)); - return Array.from({ length: maxFrameId }, (_, frameId) => ({ - tabId, - frameIds: [frameId] - })); -} - -function frameScriptTarget(tabId, entry) { - const frameId = normalizeFrameId(entry?.frameId); - return typeof entry?.documentId === 'string' && entry.documentId - ? { tabId, documentIds: [entry.documentId] } - : (frameId === 0 ? { tabId, frameIds: [0] } : { tabId, frameIds: [frameId] }); -} - -function mergeFrameResults(...groups) { - const merged = new Map(); - for (const group of groups) { - for (const entry of Array.isArray(group) ? group : []) { - if (!Number.isInteger(entry?.frameId)) continue; - // A frame ID identifies the current slot. If its document changed - // between the broad probe and the exact probe, the exact result - // must replace the stale document rather than create a duplicate - // candidate that can trigger a false ambiguity. - const key = `frame:${entry.frameId}`; - merged.set(key, entry); - } - } - return Array.from(merged.values()); -} - -function executeWithTimeout(task, timeoutMs, label) { - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return task(); - let timeoutId = null; - const timeout = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - const error = new Error(`${label} timed out after ${timeoutMs}ms`); - error.code = 'media_frame_probe_timeout'; - reject(error); - }, timeoutMs); - }); - return Promise.race([task(), timeout]).finally(() => { - if (timeoutId !== null) clearTimeout(timeoutId); - }); -} - -async function executeInAccessibleFrames(chromeApi, targets, func, args, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) { +async function executeInAccessibleFrames(chromeApi, targets, func, args) { const settled = await Promise.all(targets.map(async target => { try { - const result = await executeWithTimeout( - () => chromeApi.scripting.executeScript({ target, func, args }), - timeoutMs, - `Frame probe for ${JSON.stringify(target)}` - ); - return Array.isArray(result) ? result : []; + return await chromeApi.scripting.executeScript({ target, func, args }); } catch { return []; } @@ -498,93 +445,76 @@ async function executeInAccessibleFrames(chromeApi, targets, func, args, timeout export async function resolveMediaContentTarget(chromeApi, tabId, { attempts = 8, retryDelayMs = 200, - probeDelayMs = 60, - probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS + probeDelayMs = 60 } = {}) { let fallback = null; let missingAccess = null; - let monitorTargets = []; + let ambiguous = false; for (let attempt = 0; attempt < attempts; attempt++) { - const topResults = await executeInAccessibleFrames( + const scriptTargets = listMediaFrameScriptTargets(tabId); + let results = await executeInAccessibleFrames( chromeApi, - [{ tabId, frameIds: [0] }], + scriptTargets, inspectMediaFrame, - [null], - probeTimeoutMs + [null] ); - const allFrameResults = await executeInAccessibleFrames( - chromeApi, - listMediaFrameScriptTargets(tabId), - inspectMediaFrame, - [null], - probeTimeoutMs - ); - let results = mergeFrameResults(topResults, allFrameResults); - const embeddedFrameCount = topResults.reduce( - (count, entry) => Math.max(count, entry?.result?.embeddedFrames?.length || 0), - 0 - ); - if (embeddedFrameCount > 0) { - const individuallyProbed = await executeInAccessibleFrames( - chromeApi, - listFrameProbeTargets(tabId, embeddedFrameCount), - inspectMediaFrame, - [null], - probeTimeoutMs - ); - results = mergeFrameResults(results, individuallyProbed); + if (results.length === 0) { + try { + results = await chromeApi.scripting.executeScript({ + target: { tabId }, + func: inspectMediaFrame, + args: [null] + }); + } catch { + return contentTarget(tabId, null); + } } - if (results.length === 0) return contentTarget(tabId, null); if (results.length > 1) { const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`; - const frameTargets = results.map(entry => frameScriptTarget(tabId, entry)); try { await executeInAccessibleFrames( chromeApi, - frameTargets, + scriptTargets, installParentFrameVisibilityProbe, - [token], - probeTimeoutMs + [token] ); // Four passes match the maximum same-origin recursion depth. for (let pass = 0; pass < 4; pass++) { await executeInAccessibleFrames( chromeApi, - frameTargets, + scriptTargets, dispatchParentFrameVisibilityProbe, - [token], - probeTimeoutMs + [token] ); await new Promise(resolve => setTimeout(resolve, probeDelayMs)); } const inspected = await executeInAccessibleFrames( chromeApi, - frameTargets, + scriptTargets, inspectMediaFrame, - [token], - probeTimeoutMs + [token] ); - if (inspected.length > 0) results = mergeFrameResults(results, inspected); + if (inspected.length > 0) results = inspected; } catch { // Initial results remain usable, but equally-ranked unknown // frames will be rejected below rather than guessed. } } - // Rebuild these after the visibility refresh so a frame navigation that - // replaced its document ID cannot leave a stale monitor target behind. - monitorTargets = results.map(entry => frameScriptTarget(tabId, entry)); const selected = selectMediaFrame(results); + const videoCandidates = results.filter(entry => entry?.result?.bestVideo?.rendered === true + && entry.result.parentFrameVisible !== false); const currentMissingAccess = findMissingPlayerAccess(results); missingAccess = currentMissingAccess; fallback = selected; + ambiguous = !selected && videoCandidates.length > 1; if (selected) { if (selected.result.bestVideo.hasSource && selected.result.bestVideo.rendered && !shouldPreferMissingAccess(currentMissingAccess, selected)) { - return contentTarget(tabId, selected, monitorTargets); + return contentTarget(tabId, selected); } } @@ -594,10 +524,7 @@ export async function resolveMediaContentTarget(chromeApi, tabId, { } if (missingAccess) throw accessRequiredError(missingAccess); - if (fallback) return contentTarget(tabId, fallback, monitorTargets); - // Selecting a tab must not depend on video detection. A page can be a - // valid target before its player exists, and an ambiguous frame layout is - // recoverable through the injected lifecycle monitor. Keep the top-frame - // target active instead of discarding the user's selection. - return contentTarget(tabId, null, monitorTargets); + if (fallback) return contentTarget(tabId, fallback); + if (ambiguous) throw ambiguousFrameError(); + return contentTarget(tabId, null); } diff --git a/extension/media-frame-target.test.mjs b/extension/media-frame-target.test.mjs index 0f91c2d..fd534a9 100644 --- a/extension/media-frame-target.test.mjs +++ b/extension/media-frame-target.test.mjs @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { MEDIA_FRAME_ACCESS_REQUIRED, + MEDIA_FRAME_AMBIGUOUS, inspectMediaFrame, resolveMediaContentTarget, selectMediaFrame @@ -127,16 +128,12 @@ describe('cross-origin media-frame targeting', () => { documentId: 'document-8', frameUrl: 'https://player-8.example/embed', hasVideo: true, - scriptTarget: { tabId: 42, documentIds: ['document-8'] }, - monitorTargets: [ - { tabId: 42, documentIds: ['document-0'] }, - { tabId: 42, documentIds: ['document-8'] } - ] + scriptTarget: { tabId: 42, documentIds: ['document-8'] } }); const visibilityDispatches = executeScript.mock.calls.filter(([options]) => ( options.func?.name === 'dispatchParentFrameVisibilityProbe' )); - expect(visibilityDispatches.length).toBeGreaterThanOrEqual(4); + expect(visibilityDispatches).toHaveLength(4); }); it('keeps the top target inactive when the only discovered video is hidden', async () => { @@ -154,11 +151,7 @@ describe('cross-origin media-frame targeting', () => { documentId: null, frameUrl: null, hasVideo: false, - scriptTarget: { tabId: 42 }, - monitorTargets: [ - { tabId: 42, documentIds: ['document-0'] }, - { tabId: 42, documentIds: ['document-6'] } - ] + scriptTarget: { tabId: 42 } }); }); @@ -172,108 +165,9 @@ describe('cross-origin media-frame targeting', () => { )).resolves.toMatchObject({ frameId: 8, hasVideo: true, - scriptTarget: { tabId: 42, documentIds: ['document-8'] }, - monitorTargets: expect.arrayContaining([ - { tabId: 42, documentIds: ['document-8'] } - ]) - }); - expect(executeScript.mock.calls.some(([options]) => ( - options.target?.tabId === 42 && options.target?.allFrames === true - ))).toBe(true); - }); - - it('isolates a rejected all-frame sweep and still finds an embedded player', async () => { - const top = frame(0, { - href: 'https://anime.example/watch', - origin: 'https://anime.example', - bestVideo: null, - videoCount: 0, - embeddedFrames: [{ - href: 'https://player.example/embed', - origin: 'https://player.example', - area: 860 * 490, - width: 860, - height: 490, - visible: true, - mediaHint: true - }] - }); - const player = frame(1, { - href: 'https://player.example/embed' - }); - const executeScript = vi.fn(async options => { - if (options.target?.allFrames === true) throw new Error('one frame rejected'); - const frameId = options.target?.frameIds?.[0]; - if (options.func?.name === 'inspectMediaFrame') { - if (frameId === 0) return [top]; - if (frameId === 1) return [player]; - return []; - } - return []; - }); - - await expect(resolveMediaContentTarget( - { scripting: { executeScript } }, - 47, - { attempts: 1, probeDelayMs: 0 } - )).resolves.toMatchObject({ - frameId: 1, - documentId: 'document-1', - hasVideo: true, - scriptTarget: { tabId: 47, documentIds: ['document-1'] }, - monitorTargets: expect.arrayContaining([ - { tabId: 47, documentIds: ['document-0'] }, - { tabId: 47, documentIds: ['document-1'] } - ]) - }); - }); - - it('sweeps individual frame IDs when an all-frame result is partial', async () => { - const top = frame(0, { - href: 'https://anime.example/watch', - origin: 'https://anime.example', - bestVideo: null, - videoCount: 0, - embeddedFrames: [{ - href: 'https://player.example/embed', - origin: 'https://player.example', - area: 860 * 490, - width: 860, - height: 490, - visible: true, - mediaHint: true - }] - }); - const partialFrame = frame(2, { bestVideo: null, videoCount: 0 }); - const player = frame(3, { href: 'https://player.example/embed' }); - const executeScript = vi.fn(async options => { - if (options.target?.allFrames === true) { - return options.func?.name === 'inspectMediaFrame' ? [top, partialFrame] : []; - } - const frameId = options.target?.frameIds?.[0]; - if (options.func?.name === 'inspectMediaFrame') { - if (frameId === 0) return [top]; - if (frameId === 3) return [player]; - return []; - } - return []; - }); - - await expect(resolveMediaContentTarget( - { scripting: { executeScript } }, - 48, - { attempts: 1, probeDelayMs: 0 } - )).resolves.toMatchObject({ - frameId: 3, - documentId: 'document-3', - hasVideo: true, - scriptTarget: { tabId: 48, documentIds: ['document-3'] }, - monitorTargets: expect.arrayContaining([ - { tabId: 48, documentIds: ['document-0'] }, - { tabId: 48, documentIds: ['document-2'] }, - { tabId: 48, documentIds: ['document-3'] } - ]) + scriptTarget: { tabId: 42, documentIds: ['document-8'] } }); + expect(executeScript.mock.calls[0][0].target).toEqual({ tabId: 42, allFrames: true }); }); it('does not trust parent visibility from an older probe token', () => { @@ -538,7 +432,7 @@ describe('cross-origin media-frame targeting', () => { )).resolves.toMatchObject({ frameId: 0, scriptTarget: { tabId: 44 } }); }); - it('keeps the tab target active when equal player frames are ambiguous', async () => { + it('reports ambiguity rather than controlling an arbitrary equal player', async () => { const results = [ frame(3, { parentFrameVisible: null }), frame(4, { parentFrameVisible: null }) @@ -548,10 +442,6 @@ describe('cross-origin media-frame targeting', () => { { scripting: { executeScript } }, 45, { attempts: 1, probeDelayMs: 0 } - )).resolves.toMatchObject({ - frameId: 0, - hasVideo: false, - scriptTarget: { tabId: 45 } - }); + )).rejects.toMatchObject({ code: MEDIA_FRAME_AMBIGUOUS }); }); }); diff --git a/extension/popup.js b/extension/popup.js index f44bc11..df1076e 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -468,12 +468,12 @@ async function init() { // Keep a denied selection visible while Chrome waits for the user // to grant access; it becomes active automatically after approval. - await populateTabs(res.peers, res.targetTabId); + await populateTabs(res.peers, res.targetTabId || res.pendingTargetTabId); // Render lobby status if active if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers); - if (res.status === 'connected' && normalizeTabId(res.targetTabId) === null && localData.roomId) { + if (res.status === 'connected' && !res.targetTabId && !res.pendingTargetTabId && localData.roomId) { const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]'); if (syncTabBtn) syncTabBtn.click(); showSelectVideoHint(); @@ -682,7 +682,7 @@ async function refreshTargetAccessState() { } await populateTabs( status.peers, - status.targetTabId + status.targetTabId ?? status.pendingTargetTabId ?? null ); } @@ -1222,14 +1222,14 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) { const blacklistDomains = getEffectiveBlacklistDomains(await readBlacklistOverrides()); const isFilterActive = data.filterNoise !== false; - let currentTargetTabId = normalizeTabId(providedTargetTabId); + let currentTargetTabId = providedTargetTabId; if (currentTargetTabId === null) { const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r)); if (chrome.runtime.lastError) { if (populateTabsToken !== token) return; currentTargetTabId = null; } else { - currentTargetTabId = status?.targetTabId ?? null; + currentTargetTabId = status?.targetTabId || status?.pendingTargetTabId; } } @@ -1260,7 +1260,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) { const filteredTabs = tabs.filter(tab => { if (!tab.url || tab.url.startsWith('chrome://')) return false; - if (isFilterActive && currentTargetTabId !== null && tab.id !== currentTargetTabId) { + if (isFilterActive && tab.id !== parseInt(currentTargetTabId)) { if (isUrlBlacklisted(tab.url, blacklistDomains)) return false; } return true; @@ -1311,7 +1311,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) { // Sort: 1. Current tab first, 2. Matches, 3. Rest alphabetically const options = Array.from(elements.targetTab.options); const placeholder = options.shift(); - const currentTabId = currentTargetTabId; + const currentTabId = providedTargetTabId ? parseInt(providedTargetTabId) : null; options.sort((a, b) => { const aId = parseInt(a.value); @@ -1331,7 +1331,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) { elements.targetTab.appendChild(placeholder); options.forEach(opt => elements.targetTab.appendChild(opt)); - if (currentTargetTabId !== null) { + if (currentTargetTabId) { elements.targetTab.value = currentTargetTabId; } else { const matchOpt = options.find(o => o.textContent.includes('⭐ MATCH:')); @@ -1745,7 +1745,7 @@ if (elements.langSelector) { } else { hideSiteAccessNotice(); } - await populateTabs(res.peers, res.targetTabId); + await populateTabs(res.peers, res.targetTabId || res.pendingTargetTabId); if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers); } else { applyConnectionStatus('disconnected'); @@ -2094,7 +2094,7 @@ elements.forceSyncBtn.addEventListener('click', async () => { elements.forceSyncBtn.disabled = true; const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r)); - if (chrome.runtime.lastError || !status || status.targetReady !== true || normalizeTabId(status.targetTabId) === null) { + if (chrome.runtime.lastError || !status || !status.targetTabId) { elements.forceSyncBtn.disabled = false; return; } @@ -2160,8 +2160,7 @@ elements.forceSyncBtn.addEventListener('click', async () => { resolve(false); return; } - resolve(currentStatus?.targetReady === true - && normalizeTabId(currentStatus?.targetTabId) === tabId); + resolve(normalizeTabId(currentStatus?.targetTabId) === tabId); }); }); @@ -2580,9 +2579,8 @@ elements.copyLogs.addEventListener('click', () => { logs = logs || []; history = history || []; - const targetTabId = normalizeTabId(status.targetTabId); - const videoPromise = (targetTabId !== null && status.targetReady === true) - ? new Promise(resolve => chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: targetTabId }, resolve)) + const videoPromise = (status && status.targetTabId) + ? new Promise(resolve => chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: status.targetTabId }, resolve)) : Promise.resolve(null); videoPromise.then(rawVideo => { @@ -2602,11 +2600,6 @@ elements.copyLogs.addEventListener('click', () => { lines.push(`- **User Agent:** ${userAgent}`); lines.push(''); - lines.push('## Target'); - lines.push(`- **Target Tab ID:** ${targetTabId ?? 'none'}`); - lines.push(`- **Activation:** ${safe(status.targetActivationState, 'unknown')}`); - lines.push(''); - // ── Tab ── if (rawVideo) { lines.push('## Tab'); @@ -2659,9 +2652,7 @@ elements.copyLogs.addEventListener('click', () => { // ── Video ── lines.push('## Video'); if (!rawVideo) { - lines.push(targetTabId !== null - ? '- *Target tab selected; video communication is not ready yet*' - : '- *No tab selected / communication failed*'); + lines.push('- *No tab selected / communication failed*'); } else if (!vs.found) { lines.push('- **Found:** \u274C NO VIDEO ELEMENT'); if (vs.videoCount != null) lines.push(`- **Video Tags:** ${vs.videoCount}`); @@ -2801,34 +2792,11 @@ function refreshDebugInfo() { if (!devTab || devTab.style.display === 'none') return; chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => { - if (!res || normalizeTabId(res.targetTabId) === null) { - if (res?.targetActivationState === 'activating' || res?.targetActivationState === 'access_required') { - if (elements.videoDebug) { - elements.videoDebug.textContent = getMessage('DEBUG_TARGET_ACTIVATING'); - } - return; - } - if (res?.targetActivationState === 'error') { - if (elements.videoDebug) { - elements.videoDebug.textContent = res.targetActivationError - ? `Injection fehlgeschlagen: ${res.targetActivationError}` - : 'Video-Injection fehlgeschlagen.'; - } - return; - } + if (!res || !res.targetTabId) { if (elements.videoDebug) elements.videoDebug.textContent = getMessage('DEBUG_NO_TAB'); return; } - if (res.targetReady !== true) { - if (elements.videoDebug) { - elements.videoDebug.textContent = res.targetActivationState === 'error' - ? `Injection fehlgeschlagen: ${res.targetActivationError || 'unbekannter Fehler'}` - : getMessage('DEBUG_TARGET_ACTIVATING'); - } - return; - } - // Request direct state from the content script via background chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: res.targetTabId }, (state) => { if (!state || (!state.found && state.error)) { diff --git a/extension/target-tab-lifecycle.test.mjs b/extension/target-tab-lifecycle.test.mjs index 4794389..123e0c2 100644 --- a/extension/target-tab-lifecycle.test.mjs +++ b/extension/target-tab-lifecycle.test.mjs @@ -8,7 +8,6 @@ const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js' const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8'); const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8'); const monitorSource = fs.readFileSync(path.join(extensionDir, 'media-frame-monitor.js'), 'utf8'); -const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8'); const manifest = JSON.parse(fs.readFileSync(path.join(extensionDir, 'manifest.base.json'), 'utf8')); describe('target tab lifecycle', () => { @@ -23,10 +22,9 @@ describe('target tab lifecycle', () => { const activationStart = backgroundSource.indexOf('async function activateTargetTab'); const activationEnd = backgroundSource.indexOf('async function reactivateCurrentTarget', activationStart); const activationSource = backgroundSource.slice(activationStart, activationEnd); - expect(activationSource.indexOf('await injectContentScript(selectedTabId')) - .toBeLessThan(activationSource.indexOf('await deactivateTargetTab(previousTabId, previousContentTarget)')); + expect(activationSource.indexOf('await deactivateTargetTab(previousTabId)')) + .toBeLessThan(activationSource.indexOf('await injectContentScript(selectedTabId')); expect(activationSource).toContain('previousTabId !== selectedTabId'); - expect(activationSource).toContain('keeping tab ${previousTabId} selected'); expect(contentSource).toContain('if (window.koalaSyncInjected && chrome.runtime.id)'); expect(overlaySource).toContain('if (window.koalaSyncChatOverlay?.refresh)'); }); @@ -34,12 +32,8 @@ describe('target tab lifecycle', () => { it('fully deactivates old and superseded target injections', () => { expect(backgroundSource).toContain("{ type: 'TARGET_DEACTIVATE' }"); expect(backgroundSource).toContain('target.documentId'); - expect(backgroundSource).toContain('await resetAudioProcessingInTab(normalizedTabId, target);'); - expect(backgroundSource).toContain("{ action: 'RESET_AUDIO_PROCESSING' }"); expect(backgroundSource.match(/await deactivateTargetTab\(selectedTabId,/g)?.length).toBeGreaterThanOrEqual(6); expect(contentSource).toContain("if (message.type === 'TARGET_DEACTIVATE')"); - expect(contentSource).toContain('destroyContentScript({ preserveAudioRoute: true });'); - expect(contentSource).toContain('window.__koalaSyncAudioRoute'); expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'"); }); @@ -52,11 +46,12 @@ describe('target tab lifecycle', () => { it('uses all-frame probing for cross-origin targets without navigation permissions', () => { expect(backgroundSource).toContain("files: ['media-frame-monitor.js']"); - expect(backgroundSource).toContain('...listMediaFrameScriptTargets(tabId)'); + expect(backgroundSource).toContain('const targets = listMediaFrameScriptTargets(tabId)'); expect(backgroundSource).toContain('One denied widget frame must not block the selected player'); expect(backgroundSource).toContain("navigationError.code = 'media_target_navigated'"); - expect(backgroundSource).toContain('async function deactivateMediaFrameMonitors(tabId, contentTarget'); - expect(backgroundSource).toContain('func: deactivateMediaFrameMonitor'); + expect(backgroundSource).toContain("{ type: 'MEDIA_MONITOR_DEACTIVATE' }"); + expect(backgroundSource).toContain('async function deactivateMediaFrameMonitors(tabId)'); + expect(backgroundSource).toContain('{ documentId }'); expect(monitorSource).toContain("type: 'MEDIA_FRAME_CANDIDATE_CHANGED'"); expect(monitorSource).toContain("attributeFilter: ['class', 'style', 'hidden', 'src', 'controls']"); expect(monitorSource).toContain('if (!force && nextSignature === lastCandidateSignature) return'); @@ -73,49 +68,6 @@ describe('target tab lifecycle', () => { expect(backgroundSource).not.toMatch(/chrome\.(?:web)?Navigation/); }); - it('keeps the selected frame recoverable when an all-frame sweep is rejected', () => { - expect(backgroundSource).toContain('contentTarget?.scriptTarget'); - expect(backgroundSource).toContain('...(contentTarget?.monitorTargets || [])'); - expect(backgroundSource).toContain('function uniqueScriptTargets(targets)'); - expect(backgroundSource).toContain('function deactivateMediaFrameMonitor()'); - expect(backgroundSource).toContain('func: deactivateMediaFrameMonitor'); - expect(backgroundSource).toContain('isMissingContentReceiverError(error)'); - expect(backgroundSource).toContain('await refreshCurrentMediaTarget(tabId, { queueIfRunning: true })'); - expect(backgroundSource).toContain("activation?.status === 'activation_in_progress'"); - expect(backgroundSource).not.toContain('Media frame probe fell back to the top frame'); - }); - - it('does not discard a selected tab when its media frame refresh is transiently unavailable', () => { - const refreshFailureGuard = backgroundSource.slice( - backgroundSource.indexOf('const isCurrentTargetRefresh'), - backgroundSource.indexOf('currentTabId = null', backgroundSource.indexOf('const isCurrentTargetRefresh')) - ); - expect(refreshFailureGuard).toContain('keeping the selected target for recovery'); - expect(refreshFailureGuard).not.toContain('currentTabId = null'); - - const routeSource = backgroundSource.slice( - backgroundSource.indexOf('async function _routeToContentInternal'), - backgroundSource.indexOf('// --- Keep-Alive Mechanism ---') - ); - expect(routeSource).toContain('keeping the selected target for recovery'); - expect(routeSource).not.toContain('clearTargetTabForIdle(tabId, targetGeneration)'); - }); - - it('persists the user target while dynamic-frame activation is still retrying', () => { - expect(backgroundSource).toContain('let requestedTargetTabId = null;'); - expect(backgroundSource).toContain('let pendingRequestedActivationCount = 0;'); - expect(backgroundSource).toContain('await rememberRequestedTarget(selectedTabId, message.tabTitle);'); - expect(backgroundSource).toContain('pendingRequestedActivationCount > 0'); - expect(backgroundSource).toContain('await retryRequestedTarget();'); - expect(backgroundSource).toContain('targetTabId,'); - expect(backgroundSource).toContain('targetReady'); - expect(backgroundSource).toContain("targetActivationState"); - expect(backgroundSource).toContain('await clearRequestedTarget(selectedTabId);'); - expect(popupSource).not.toContain('getSelectedTargetTabId'); - expect(popupSource).toContain('await populateTabs(res.peers, res.targetTabId);'); - expect(popupSource).toContain('res.targetReady !== true'); - }); - it('serializes content commands and coalesces target refreshes', () => { expect(backgroundSource).toContain('contentCommandQueue.catch(() => {}).then(deliver)'); expect(backgroundSource).toContain('if (mediaTargetRefreshTask && mediaTargetRefreshTabId === selectedTabId)'); @@ -127,7 +79,7 @@ describe('target tab lifecycle', () => { }); it('tears down every persistent content-script resource', () => { - expect(contentSource).toContain('function destroyContentScript({ preserveAudioRoute = false } = {})'); + expect(contentSource).toContain('function destroyContentScript()'); expect(contentSource).toContain('observer.disconnect()'); expect(contentSource).toContain('keepAlivePort.disconnect()'); expect(contentSource).toContain('for (const video of [...attachedVideos]) detachVideoListeners(video);'); 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 02/17] 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 - //