diff --git a/extension/background.js b/extension/background.js index 2b5f601..dac9678 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1592,6 +1592,68 @@ async function routeToContent(action, payload) { _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, 0); } +function isNetflixUrl(url) { + try { + const host = new URL(url).hostname.toLowerCase(); + return host === 'netflix.com' || host.endsWith('.netflix.com'); + } catch (_e) { + return false; + } +} + +function installNetflixSeekBridge() { + if (window.__koalaNetflixSeekBridgeInstalled) return; + window.__koalaNetflixSeekBridgeInstalled = true; + + function getNetflixPlayer() { + try { + const videoPlayer = window.netflix?.appContext?.state?.playerApp?.getAPI?.().videoPlayer; + const sessionId = videoPlayer?.getAllPlayerSessionIds?.()[0]; + return sessionId ? videoPlayer.getVideoPlayerBySessionId(sessionId) : null; + } catch (_e) { + return null; + } + } + + window.addEventListener('message', (event) => { + if (event.source !== window) return; + const data = event.data; + if (!data || data.__koalaNetflixSeek !== 1 || data.kind !== 'seek' || typeof data.time !== 'number') return; + try { + getNetflixPlayer()?.seek(Math.round(data.time * 1000)); + } catch (_e) { + // Netflix player not ready or private API changed; the next sync tick can retry. + } + }); +} + +async function injectContentScript(tabId) { + let isNetflix = false; + try { + const tab = await chrome.tabs.get(tabId); + isNetflix = isNetflixUrl(tab?.url || ''); + } catch (_e) { + // Fall through to the generic content script injection. + } + + if (isNetflix) { + try { + await chrome.scripting.executeScript({ + target: { tabId }, + world: 'MAIN', + func: installNetflixSeekBridge + }); + } catch (err) { + addLog(`Netflix seek bridge injection failed: ${err.message}`, 'warn'); + } + } + + return chrome.scripting.executeScript({ + target: { tabId }, + files: ['content.js'] + }); +} + function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries) { chrome.tabs.sendMessage(tabId, { type: 'SERVER_COMMAND', @@ -1606,10 +1668,7 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman return; } if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) { - chrome.scripting.executeScript({ - target: { tabId }, - files: ['content.js'] - }).then(() => { + injectContentScript(tabId).then(() => { setTimeout(() => _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries + 1), 500); }).catch(_err => { addLog(`Auto-reinject failed for tab ${tabId}`, 'warn'); @@ -2196,6 +2255,20 @@ async function handleAsyncMessage(message, sender, sendResponse) { addLog('Heartbeat settings error: ' + err.message, 'error'); sendResponse({ status: 'ok' }); }); + } else if (message.type === 'INJECT_CONTENT_SCRIPT') { + const tabId = Number(message.tabId); + if (!Number.isInteger(tabId)) { + sendResponse({ status: 'invalid_tab' }); + return true; + } + + injectContentScript(tabId).then(() => { + sendResponse({ status: 'ok' }); + }).catch(err => { + addLog(`Failed to inject into tab: ${err.message}`, 'warn'); + sendResponse({ status: 'error', message: err.message }); + }); + return true; } else if (message.type === 'SET_TARGET_TAB') { const previousTabId = currentTabId; currentTabId = message.tabId; @@ -2213,10 +2286,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (currentTabId) { const selectedTabId = currentTabId; - chrome.scripting.executeScript({ - target: { tabId: selectedTabId }, - files: ['content.js'] - }) + injectContentScript(selectedTabId) .then(() => applyAudioSettingsToTab(selectedTabId)) .catch(err => { addLog(`Failed to inject into tab: ${err.message}`, 'warn'); diff --git a/extension/content.js b/extension/content.js index a7ade14..01a491e 100644 --- a/extension/content.js +++ b/extension/content.js @@ -57,6 +57,22 @@ let seekDebounceTimer = null; // debounce timer for rapid seek events let expectedSeekTime = null; // strictly track programmatic seeks + const NETFLIX_SEEK_BRIDGE = 1; + + function isNetflixHost() { + const host = window.location.hostname.toLowerCase(); + return host === 'netflix.com' || host.endsWith('.netflix.com'); + } + + function seekVideo(video, targetTime) { + expectedSeekTime = targetTime; + if (isNetflixHost()) { + window.postMessage({ __koalaNetflixSeek: NETFLIX_SEEK_BRIDGE, kind: 'seek', time: targetTime }, '*'); + return; + } + video.currentTime = 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 @@ -748,8 +764,7 @@ ytButton.click(); } if (action === EVENTS.SEEK) { - expectedSeekTime = data.targetTime; - video.currentTime = data.targetTime; + seekVideo(video, data.targetTime); } return; } @@ -764,8 +779,7 @@ twitchButton.click(); } if (action === EVENTS.SEEK) { - expectedSeekTime = data.targetTime; - video.currentTime = data.targetTime; + seekVideo(video, data.targetTime); } return; } @@ -782,8 +796,7 @@ _setSuppress('paused'); video.pause(); } else if (action === EVENTS.SEEK) { - expectedSeekTime = data.targetTime; - video.currentTime = data.targetTime; + seekVideo(video, data.targetTime); } } catch (e) { reportLog(`Media Action Error: ${e.message}`, 'error'); @@ -931,10 +944,9 @@ return; } _setSuppress('paused'); - expectedSeekTime = payload.targetTime; video.pause(); try { - video.currentTime = payload.targetTime; + seekVideo(video, payload.targetTime); } catch (e) { reportLog(`Force Sync Seek Error: ${e.message}`, 'error'); } diff --git a/extension/popup.js b/extension/popup.js index a9e2952..7ecead6 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -1609,10 +1609,14 @@ elements.forceSyncBtn.addEventListener('click', async () => { if (mode === 'jump-to-me') { chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (response) => { if (chrome.runtime.lastError || !response || response.currentTime === undefined) { - chrome.scripting.executeScript({ - target: { tabId }, - files: ['content.js'] - }).then(() => { + chrome.runtime.sendMessage({ type: 'INJECT_CONTENT_SCRIPT', tabId }, (injectResponse) => { + if (chrome.runtime.lastError || !injectResponse || injectResponse.status !== 'ok') { + showError(getMessage('ERR_NO_VIDEO_TAB')); + forceSyncDone = true; + elements.forceSyncBtn.disabled = false; + elements.forceSyncBtn.textContent = originalText; + return; + } setTimeout(() => { chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => { if (chrome.runtime.lastError) return; @@ -1621,11 +1625,6 @@ elements.forceSyncBtn.addEventListener('click', async () => { } }); }, 500); - }).catch(() => { - showError(getMessage('ERR_NO_VIDEO_TAB')); - forceSyncDone = true; - elements.forceSyncBtn.disabled = false; - elements.forceSyncBtn.textContent = originalText; }); return; }