/** * 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; // --- SHARED_EVENTS_INJECT_START --- // This block is automatically updated by /scripts/build-extension.js 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 // --- 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 // Cache the autoSyncNextEpisode setting chrome.storage.sync.get(['autoSyncNextEpisode'], (data) => { _autoSyncEnabled = data.autoSyncNextEpisode !== false; // default: enabled }); chrome.storage.onChanged.addListener((changes, area) => { if (area === 'sync' && changes.autoSyncNextEpisode) { _autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false; } }); function reportLog(message, level = 'info') { chrome.runtime.sendMessage({ type: 'LOG', message, level }).catch(() => {}); } // --- Helper: find the best video element on the page --- // Prefers larger, visible videos over tiny preview/trailer elements. function findVideo(root = document) { const allVideos = root.querySelectorAll('video'); if (allVideos.length === 0) { // Optimize: scan only potential player, video, media, and stream hosts by matching typical keywords (case-insensitive) // or common custom element tags. This prevents recursive scanning of thousands of standard DOM nodes (div, span, a, etc.) // while guaranteeing 100% airtight compatibility with all video web components in the wild. 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) { const found = findVideo(el.shadowRoot); if (found) return found; } } return null; } // Multiple videos found → pick the best one if (allVideos.length === 1) return allVideos[0]; let best = null; let bestScore = -1; for (const v of allVideos) { if (v.tagName !== 'VIDEO') continue; // Score: visible area + bonus for unmuted + bonus for longer duration const area = (v.videoWidth || v.offsetWidth || 0) * (v.videoHeight || v.offsetHeight || 0); const unmutedBonus = v.muted ? 0 : 100000; const durationBonus = (v.duration && isFinite(v.duration) ? v.duration : 0) * 100; const score = area + unmutedBonus + durationBonus; if (score > bestScore) { bestScore = score; best = v; } } return best; } // --- Episode Auto-Sync: Detection --- function getMediaTitle() { return (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null; } // Extract a canonical episode identifier from a title string. // Handles: S01E01, S1E1, S01 - E01, Season 1 Episode 1, "Folge 5", "Episode 5", "Ep. 5", "#5" // Returns null if no episode pattern found. function extractEpisodeId(title) { if (!title || typeof title !== 'string') return null; // S01E01 patterns (with optional spaces, dashes, dots between S and E) const se = title.match(/S(?:eason\s*)?(\d+)[\s\-\.]*E(?:pisode\s*)?(\d+)/i); if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`; // "Episode X", "Folge X", "Ep. X", "#X" const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i); if (ep) return `EP${String(ep[1]).padStart(3, '0')}`; return null; } // Returns true if two titles likely refer to the same episode. // Strict: both must have IDs and match, OR neither has IDs and exact match. function sameEpisode(titleA, titleB) { if (!titleA && !titleB) return true; // Both unknown → assume same (backward compat) if (!titleA || !titleB) return false; // One unknown, one known → different const idA = extractEpisodeId(titleA); const idB = extractEpisodeId(titleB); if (idA && idB) return idA === idB; // Both have parseable IDs → compare IDs if (idA || idB) return false; // One has ID, other doesn't → different return titleA === titleB; // Neither has ID → exact string match } // Returns true only when we are CERTAIN the episodes differ. // Permissive: only blocks if BOTH titles have parseable IDs AND they differ. // Films, music, unparseable titles always pass through. function isDifferentEpisode(titleA, titleB) { if (!titleA || !titleB) return false; // Unknown → allow const idA = extractEpisodeId(titleA); const idB = extractEpisodeId(titleB); if (!idA || !idB) return false; // At least one unparseable → allow return idA !== idB; // Both parseable → only block if different } function checkEpisodeTransition() { const currentTitle = getMediaTitle(); const video = findVideo(); // Only trigger if: we had a previous title, the title changed, // a video exists, and we're near the start of new content. if (lastKnownMediaTitle && currentTitle && !sameEpisode(currentTitle, lastKnownMediaTitle) && video && video.currentTime < 5 && video.readyState >= 1) { onEpisodeTransition(currentTitle); } // Always track the latest known title if (currentTitle) lastKnownMediaTitle = currentTitle; } function onEpisodeTransition(newTitle) { // Debounce: prevent duplicate fires from multiple signals if (episodeTransitionDebounce) return; episodeTransitionDebounce = setTimeout(() => { episodeTransitionDebounce = null; }, 2000); reportLog(`Episode transition detected: "${newTitle}"`, 'info'); // Do NOT pause here. We notify background.js first. // Background checks the setting; if enabled it creates a lobby // and sends back PAUSE_FOR_LOBBY so we only freeze if the feature is on. chrome.runtime.sendMessage({ type: 'EPISODE_CHANGED', payload: { newTitle } }).catch(() => {}); } function checkAndReportLobbyReady(expectedTitle) { const video = findVideo(); const currentTitle = getMediaTitle(); if (video && currentTitle && sameEpisode(currentTitle, expectedTitle) && video.currentTime < 5 && video.readyState >= 1) { // Match! Pause at start and report ready. if (!video.paused) { _setSuppress('paused'); video.pause(); } stopLobbyPoll(); chrome.runtime.sendMessage({ type: 'EPISODE_READY_LOCAL', payload: { title: currentTitle } }).catch(() => {}); reportLog(`Episode lobby: Ready for "${currentTitle}"`, 'success'); return true; } return false; } function startLobbyPoll(expectedTitle) { stopLobbyPoll(); _pendingLobbyTitle = expectedTitle; // NOTE: Do NOT pause here. Three callers reach this function: // 1. PAUSE_FOR_LOBBY (initiator): already paused by that handler before calling us. // 2. EPISODE_LOBBY (non-initiator): peer may still be on the PREVIOUS episode — pausing // would freeze them mid-episode. The pause happens inside checkAndReportLobbyReady() // only once their title actually matches. // 3. CONTENT_BOOT recovery: same reasoning as (2). // Check immediately if (checkAndReportLobbyReady(expectedTitle)) return; // Poll every 2 seconds — no log spam, internal only lobbyPollTimer = setInterval(() => { checkAndReportLobbyReady(expectedTitle); }, 2000); } function stopLobbyPoll() { _pendingLobbyTitle = null; if (lobbyPollTimer) { clearInterval(lobbyPollTimer); lobbyPollTimer = null; } } // --- Helper: YouTube/Twitch specific actions --- function tryMediaAction(action, data) { const video = findVideo(); if (!video) return; if (action === EVENTS.SEEK) { const target = data ? (data.targetTime !== undefined ? data.targetTime : data.currentTime) : undefined; if (!Number.isFinite(target)) { reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error'); return; } data = { ...data, targetTime: target }; } try { const host = window.location.hostname.toLowerCase(); const isYouTube = host.includes('youtube.com'); const isTwitch = host.includes('twitch.tv'); if (isYouTube) { const ytButton = document.querySelector('.ytp-play-button'); if (ytButton) { const isCurrentlyPlaying = !video.paused; if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) { _setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused'); ytButton.click(); } if (action === EVENTS.SEEK) { _setSuppress('seek'); video.currentTime = data.targetTime; } return; } } if (isTwitch) { const twitchButton = document.querySelector('[data-a-target="player-play-pause-button"]'); if (twitchButton) { const isCurrentlyPlaying = !video.paused; if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) { _setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused'); twitchButton.click(); } if (action === EVENTS.SEEK) { _setSuppress('seek'); video.currentTime = data.targetTime; } return; } } // Fallback for native HTML5 if (action === EVENTS.PLAY) { _setSuppress('playing'); video.play().catch((e) => { reportLog(`Playback prevented: ${e.message}`, 'warn'); _clearSuppress('playing'); }); } else if (action === EVENTS.PAUSE) { _setSuppress('paused'); video.pause(); } else if (action === EVENTS.SEEK) { _setSuppress('seek'); video.currentTime = data.targetTime; } } catch (e) { reportLog(`Media Action Error: ${e.message}`, 'error'); } } // --- Helper: Wait until video is ready for playback (buffered & seeked) --- function pollSeekReady(targetTime, timeoutMs = 8000) { return new Promise((resolve) => { const interval = 150; let elapsed = 0; const timer = setInterval(() => { const video = findVideo(); // Re-query DOM on every iteration if (!video) { clearInterval(timer); resolve(false); return; } elapsed += interval; const timeDiff = Math.abs(video.currentTime - targetTime); const ready = video.readyState >= 3 && timeDiff < 2.0; if (ready) { clearInterval(timer); resolve(true); } else if (elapsed >= timeoutMs) { clearInterval(timer); resolve(false); } }, interval); }); } // Listen for commands from background.js chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === 'get_current_time') { const video = findVideo(); sendResponse({ currentTime: video ? video.currentTime : null }); return true; } if (message.type === 'SERVER_COMMAND') { const { action, payload } = message; let actionCompleted = false; // Guard: Don't execute sync commands if peers are on different episodes. // Only active when autoSyncNextEpisode setting is enabled (default: on). // Only blocks when BOTH sides have parseable S01E01-style IDs that differ. // Films and unparseable titles always pass through. const syncActions = [EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE]; if (_autoSyncEnabled && syncActions.includes(action)) { const senderTitle = payload?.mediaTitle; const myTitle = getMediaTitle(); if (isDifferentEpisode(senderTitle, myTitle)) { reportLog(`Episode mismatch: sender="${senderTitle || '?'}" vs mine="${myTitle || '?'}" — skipping ${action}. Disable "Auto-Sync next Episode" in settings if this causes issues.`, 'warn'); if (action !== EVENTS.FORCE_SYNC_PREPARE && action !== EVENTS.FORCE_SYNC_EXECUTE) { chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId }); } return; } } if (action === EVENTS.PLAY) { tryMediaAction(EVENTS.PLAY); chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId }); actionCompleted = true; } else if (action === EVENTS.PAUSE) { tryMediaAction(EVENTS.PAUSE); chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId }); actionCompleted = true; } else if (action === EVENTS.SEEK) { tryMediaAction(EVENTS.SEEK, payload); chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId }); actionCompleted = true; } else if (action === EVENTS.FORCE_SYNC_PREPARE) { if (!payload || payload.targetTime === undefined) return; const video = findVideo(); if (video) { if (!Number.isFinite(payload.targetTime)) { reportLog(`Media Action Error: Invalid force sync payload - ${JSON.stringify(payload)}`, 'error'); return; } _setSuppress('paused'); _setSuppress('seek'); video.pause(); video.currentTime = payload.targetTime; pollSeekReady(payload.targetTime).then((ready) => { chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {}); if (ready) { scheduleProactiveHeartbeat(); } else { reportLog('Force Sync: Seek ready timeout, proceeding anyway', 'warn'); } }).catch(() => {}); } } else if (action === EVENTS.FORCE_SYNC_EXECUTE) { stopLobbyPoll(); tryMediaAction(EVENTS.PLAY); chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId }); actionCompleted = true; } if (actionCompleted) { scheduleProactiveHeartbeat(); } } // Episode Auto-Sync: Lobby notification from background if (message.type === 'EPISODE_LOBBY') { const expectedTitle = message.expectedTitle; if (expectedTitle) { reportLog(`Episode lobby received: waiting for "${expectedTitle}"`, 'info'); startLobbyPoll(expectedTitle); } sendResponse({ status: 'ok' }); return true; } // Episode Auto-Sync: Lobby cancelled by background if (message.type === 'EPISODE_LOBBY_CANCEL') { stopLobbyPoll(); sendResponse({ status: 'ok' }); return true; } // Episode Auto-Sync: Background confirmed lobby created, pause the video if (message.type === 'PAUSE_FOR_LOBBY') { const video = findVideo(); if (video && !video.paused) { _setSuppress('paused'); video.pause(); } // Start lobby poll now that we know the feature is enabled if (message.expectedTitle) { startLobbyPoll(message.expectedTitle); } sendResponse({ status: 'ok' }); return true; } if (message.type === 'GET_VIDEO_STATE') { const video = findVideo(); const platform = (() => { const h = window.location.hostname.toLowerCase(); if (h.includes('youtube.com')) return 'YouTube'; if (h.includes('twitch.tv')) return 'Twitch'; if (h.includes('netflix.com')) return 'Netflix'; if (h.includes('primevideo.com') || h.includes('amazon.')) return 'Prime Video'; if (h.includes('disneyplus.com')) return 'Disney+'; if (h.includes('hulu.com')) return 'Hulu'; if (h.includes('max.com') || h.includes('hbomax.com')) return 'Max/HBO'; if (h.includes('vimeo.com')) return 'Vimeo'; if (h.includes('dailymotion.com')) return 'Dailymotion'; return 'Generic'; })(); const networkStates = ['NETWORK_EMPTY', 'NETWORK_IDLE', 'NETWORK_LOADING', 'NETWORK_NO_SOURCE']; const readyStates = ['HAVE_NOTHING', 'HAVE_METADATA', 'HAVE_CURRENT_DATA', 'HAVE_FUTURE_DATA', 'HAVE_ENOUGH_DATA']; const videoCount = document.querySelectorAll('video').length; const inShadowDom = (() => { let el = video; while (el) { if (el.toString() === '[object ShadowRoot]') return true; el = el.parentNode; } // Also check if any potential host has shadow root (even if no video found) if (!video) { const hosts = document.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i]'); for (const host of hosts) { if (host.shadowRoot) return true; } } return false; })(); // Build multi-video summary for debug reports const allVideos = []; const allVideoEls = document.querySelectorAll('video'); for (let i = 0; i < allVideoEls.length; i++) { const v = allVideoEls[i]; allVideos.push({ index: i, width: v.videoWidth || v.offsetWidth || 0, height: v.videoHeight || v.offsetHeight || 0, muted: v.muted, paused: v.paused, duration: (v.duration && isFinite(v.duration)) ? Math.round(v.duration) : 0, readyState: v.readyState, src: (v.currentSrc || v.src || '').substring(0, 80), selected: v === video }); } if (video) { const dataAttributes = {}; if (video.attributes) { for (const attr of video.attributes) { if (attr.name.startsWith('data-')) { dataAttributes[attr.name] = attr.value; } } } const metadata = (navigator.mediaSession && navigator.mediaSession.metadata) ? { title: navigator.mediaSession.metadata.title, artist: navigator.mediaSession.metadata.artist, album: navigator.mediaSession.metadata.album, artwork: Array.from(navigator.mediaSession.metadata.artwork || []).map(a => a.src) } : null; const errorInfo = video.error ? { code: video.error.code, message: video.error.message } : null; sendResponse({ found: true, paused: video.paused, currentTime: video.currentTime, duration: video.duration || 0, readyState: video.readyState, readyStateLabel: readyStates[video.readyState] || 'UNKNOWN', networkState: video.networkState, networkStateLabel: networkStates[video.networkState] || 'UNKNOWN', muted: video.muted, volume: video.volume, playbackRate: video.playbackRate, videoWidth: video.videoWidth, videoHeight: video.videoHeight, seeking: video.seeking, ended: video.ended, error: errorInfo, buffered: video.buffered && video.buffered.length > 0 ? Array.from({ length: video.buffered.length }, (_, i) => `${video.buffered.start(i).toFixed(1)}-${video.buffered.end(i).toFixed(1)}s`).join(', ') : 'none', loop: video.loop, url: window.location.href, pageTitle: document.title, id: video.id || 'none', className: video.className || 'none', src: video.src || 'none', currentSrc: video.currentSrc || 'none', dataAttributes, metadata, videoCount, inShadowDom, platform, allVideos }); } else { sendResponse({ found: false, videoCount, inShadowDom, platform, allVideos, url: window.location.href, pageTitle: document.title, metadata: (navigator.mediaSession && navigator.mediaSession.metadata) ? { title: navigator.mediaSession.metadata.title, artist: navigator.mediaSession.metadata.artist, album: navigator.mediaSession.metadata.album } : null }); } } }); // Detect native events function reportEvent(action) { if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) { clearTimeout(seekDebounceTimer); seekDebounceTimer = null; const v = findVideo(); if (v && Number.isFinite(v.currentTime)) { lastReportedSeekTime = v.currentTime; reportLog(`[Seek] Debounce flushed immediately due to ${action.toUpperCase()}`, 'info'); reportEvent(EVENTS.SEEK); } } const video = findVideo(); if (!video) return; const current = video.currentTime; if (!Number.isFinite(current)) return; const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null; const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null)); if (_suppressTimers[eventState]) { _clearSuppress(eventState); return; } // Suppress only SEEK during visibility grace period (tab re-focus ghost jump). // Play/Pause pass through — user may want to immediately pause after tabbing back. if (Date.now() < visibilityGraceUntil && action === EVENTS.SEEK) return; chrome.runtime.sendMessage({ type: 'CONTENT_EVENT', action, payload: { currentTime: current, targetTime: current, mediaTitle: mediaTitle, timestamp: Date.now() } }); // Trigger proactive heartbeat to push stabilized state scheduleProactiveHeartbeat(); } // --- Tab Visibility Handling --- // Browsers (especially Firefox) aggressively throttle background tabs. // When the user returns to a video tab, the video element may have lost // time-sync and fires spurious seek events as it recovers (jumping back). // We suppress only SEEK for a short grace period after tab re-focus. // Play/Pause are NOT suppressed — the user may legitimately want to // pause immediately after switching back. let pageVisible = !document.hidden; let visibilityGraceUntil = 0; const VISIBILITY_GRACE_MS = 300; document.addEventListener('visibilitychange', () => { if (document.hidden) { pageVisible = false; } else if (!pageVisible) { pageVisible = true; visibilityGraceUntil = Date.now() + VISIBILITY_GRACE_MS; reportLog(`Tab re-focused — suppressing seeks for ${VISIBILITY_GRACE_MS / 1000}s to prevent ghost relay`, 'warn'); } }); // Reset on page hide/show (bfcache, tab discard) window.addEventListener('pagehide', () => { pageVisible = false; }); window.addEventListener('pageshow', (event) => { // event.persisted is true ONLY when restored from bfcache, not on initial load if (event.persisted && !pageVisible) { pageVisible = true; visibilityGraceUntil = Date.now() + VISIBILITY_GRACE_MS; reportLog(`Page restored from cache — suppressing seeks for ${VISIBILITY_GRACE_MS / 1000}s`, 'warn'); } }); const handlePlay = () => reportEvent(EVENTS.PLAY); const handlePause = () => reportEvent(EVENTS.PAUSE); // Seek filtering: ignore HLS/DASH buffering micro-seeks. // Only relay if delta >= MIN_SEEK_DELTA AND not already debouncing. const handleSeeked = () => { const video = findVideo(); if (!video) return; const current = video.currentTime; if (!Number.isFinite(current)) return; // Step 1: Check _suppressTimers (programmatic seek from remote peer) if (_suppressTimers['seek']) { _clearSuppress('seek'); lastReportedSeekTime = current; return; } // Step 2: Suppress during visibility grace period (tab re-focus ghost events) if (Date.now() < visibilityGraceUntil) return; const delta = lastReportedSeekTime !== null ? Math.abs(current - lastReportedSeekTime) : null; const deltaStr = delta !== null ? `Δ${delta.toFixed(2)}s` : 'Δ?'; // Step 3: Delta check — skip micro-seeks (buffering, chapter markers, etc.) if (lastReportedSeekTime !== null && delta < MIN_SEEK_DELTA) { reportLog(`[Seek] Filtered (${deltaStr} < ${MIN_SEEK_DELTA}s threshold) @ ${current.toFixed(2)}s — not relayed`, 'warn'); return; } // Step 4: Debounce rapid consecutive seeks (e.g. scrubbing) // — wait 300ms for the user to settle before relaying if (seekDebounceTimer) clearTimeout(seekDebounceTimer); seekDebounceTimer = setTimeout(() => { seekDebounceTimer = null; const v = findVideo(); if (!v) return; const settled = v.currentTime; const finalDelta = lastReportedSeekTime !== null ? Math.abs(settled - lastReportedSeekTime) : null; const finalDeltaStr = finalDelta !== null ? `Δ${finalDelta.toFixed(2)}s` : 'Δ?'; lastReportedSeekTime = settled; reportLog(`[Seek] Relayed @ ${settled.toFixed(2)}s (${finalDeltaStr})`, 'info'); reportEvent(EVENTS.SEEK); }, 300); }; let lastVideoSrc = undefined; // Episode detection handler for loadeddata event const handleLoadedData = () => { checkEpisodeTransition(); }; function setupListeners() { const video = findVideo(); if (video) { const existing = video._koalaHandlers; if (existing) { video.removeEventListener('play', existing.play); video.removeEventListener('pause', existing.pause); video.removeEventListener('seeked', existing.seeked); video.removeEventListener('loadeddata', existing.loadeddata); } video._koalaHandlers = { play: handlePlay, pause: handlePause, seeked: handleSeeked, loadeddata: handleLoadedData }; video.addEventListener('play', handlePlay); video.addEventListener('pause', handlePause); video.addEventListener('seeked', handleSeeked); video.addEventListener('loadeddata', handleLoadedData); video.dataset.koalaAttached = 'true'; lastVideoSrc = video.currentSrc || video.src || null; if (!lastKnownMediaTitle) { lastKnownMediaTitle = getMediaTitle(); } } } // SPA Navigation Handler (MutationObserver) let lastMutate = 0; let observerTimeout = null; function checkVideo() { lastMutate = Date.now(); const video = findVideo(); if (!video && lastVideoSrc !== undefined) { reportLog('Video element removed from page', 'warn'); lastVideoSrc = undefined; return; } if (!video) return; const currentSrc = video.currentSrc || video.src || null; if (!video.dataset.koalaAttached || (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc)) { if (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc) { checkEpisodeTransition(); } setupListeners(); } } const observer = new MutationObserver(() => { const now = Date.now(); if (now - lastMutate >= 1000) { checkVideo(); } else { if (observerTimeout) clearTimeout(observerTimeout); observerTimeout = setTimeout(checkVideo, 1000 - (now - lastMutate)); } }); observer.observe(document.documentElement, { childList: true, subtree: true }); // --- SHARED_HEARTBEAT_INJECT_START --- const HEARTBEAT_INTERVAL_VAL = 15000; // --- SHARED_HEARTBEAT_INJECT_END --- // Heartbeat Refactoring (Self-scheduling setTimeout with proactive heartbeat scheduling) let heartbeatTimeout = null; let proactiveHeartbeatTimeout = null; let heartbeatErrorCount = 0; function sendHeartbeat() { const video = findVideo(); if (!video) return; const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null; chrome.runtime.sendMessage({ type: 'HEARTBEAT', payload: { playbackState: video.paused ? 'paused' : 'playing', currentTime: video.currentTime, mediaTitle: mediaTitle, volume: video.volume, muted: video.muted } }).catch(err => { if (err.message.includes('Extension context invalidated')) { heartbeatErrorCount++; if (heartbeatErrorCount === 1) { reportLog('Extension reloaded. Please refresh the page if sync stops working.', 'warn'); } if (heartbeatTimeout) clearTimeout(heartbeatTimeout); if (proactiveHeartbeatTimeout) clearTimeout(proactiveHeartbeatTimeout); observer.disconnect(); } }); } function schedulePeriodicHeartbeat() { if (heartbeatTimeout) clearTimeout(heartbeatTimeout); heartbeatTimeout = setTimeout(() => { sendHeartbeat(); schedulePeriodicHeartbeat(); }, HEARTBEAT_INTERVAL_VAL); } function scheduleProactiveHeartbeat() { if (proactiveHeartbeatTimeout) clearTimeout(proactiveHeartbeatTimeout); proactiveHeartbeatTimeout = setTimeout(() => { sendHeartbeat(); schedulePeriodicHeartbeat(); // Reschedules the next periodic check to be exactly 15s from now }, 500); // 500ms stabilization delay } // Initial Setup setupListeners(); // Maintain a persistent keep-alive port connection to prevent background SW suspension let keepAlivePort = null; function connectKeepAlivePort() { try { if (chrome.runtime.id) { keepAlivePort = chrome.runtime.connect({ name: 'keepAlive' }); keepAlivePort.onDisconnect.addListener(() => { keepAlivePort = null; setTimeout(connectKeepAlivePort, 1000); }); } } catch (_e) { // Extension context invalidated or disabled } } connectKeepAlivePort(); schedulePeriodicHeartbeat(); // Immediate heartbeat on injection — populate peer data without waiting 15s setTimeout(() => sendHeartbeat(), 300); // Episode Auto-Sync: Boot recovery — check if background has an active lobby chrome.runtime.sendMessage({ type: 'CONTENT_BOOT' }, (res) => { if (chrome.runtime.lastError) return; if (res && res.lobbyActive && res.expectedTitle) { reportLog(`Boot: Active lobby detected for "${res.expectedTitle}"`, 'info'); startLobbyPoll(res.expectedTitle); } }); })();