From 9cbeb661d6f53092d6c343b1fb9f0dbd893abbf2 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:00:41 +0200 Subject: [PATCH] Save baseline state with Disney+ seek adjustments and build identifier --- extension/content.js | 3351 +++++++++++++------------ extension/page-api-seek-overrides.js | 18 +- extension/popup.js | 46 +- scripts/test-content-video-finder.cjs | 108 +- website/assets/NewLogoIcon_64.webp | Bin 1282 -> 1282 bytes 5 files changed, 1974 insertions(+), 1549 deletions(-) diff --git a/extension/content.js b/extension/content.js index ef59983..70d7315 100644 --- a/extension/content.js +++ b/extension/content.js @@ -1,1538 +1,1813 @@ -/** - * 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 - let expectedSeekTime = null; // strictly track programmatic seeks - - const PAGE_API_SEEK_BRIDGE = 1; - - function shouldUsePageApiSeek() { - return window.KOALA_PAGE_API_SEEK_ENABLED === true && - typeof window.koalaFindPageApiSeekProvider === 'function' && - !!window.koalaFindPageApiSeekProvider(window.location.hostname); - } - - function seekVideo(video, targetTime) { - expectedSeekTime = targetTime; - if (shouldUsePageApiSeek()) { - window.postMessage({ __koalaPageApiSeek: PAGE_API_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 - // 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 - - // --- 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) => { - _autoSyncEnabled = data.autoSyncNextEpisode !== false; - _audioSettings = mergeAudioSettings(data.audioSettings); - const video = findVideo(); - if (video && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings); - }); - chrome.storage.onChanged.addListener((changes, area) => { - if (area === 'local' && changes.autoSyncNextEpisode) { - _autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false; - } - }); - - // --- 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) - // 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 (!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 = () => { - chrome.runtime.sendMessage({ type: 'REQUEST_HOST_SYNC' }, (res) => { - if (chrome.runtime.lastError || !res || !res.target) { - if (++attempts < 5) setTimeout(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; - } - setTimeout(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:#1f2937;color:#f9fafb;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 #374151'); - wrap.setAttribute('role', 'dialog'); - const title = hcmEl('div', 'font-weight:600;margin-bottom:6px', hcmStrings.title); - const body = hcmEl('div', 'margin-bottom:12px;color:#d1d5db', hcmStrings.body); - const btnRow = hcmEl('div', 'display:flex;gap:8px;justify-content:flex-end'); - const soloBtn = hcmEl('button', 'background:#374151;color:#f9fafb;border:0;padding:8px 12px;border-radius:8px;cursor:pointer', hcmStrings.solo); - const stayBtn = hcmEl('button', 'background:#10b981;color:#062a20;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. - chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: true }).catch(() => {}); - hcmShowBadge(); - } - - function hcmExitDesync() { - const wasDesynced = hcmDesynced; - hcmDesynced = false; - hcmRemoveBadge(); - if (wasDesynced) { - chrome.runtime.sendMessage({ 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; - const retry = () => { - hcmBadgePending = false; - if (hcmDesynced && !hcmBadgeHost) hcmShowBadge(); - }; - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', retry, { once: true }); - } else { - setTimeout(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:#b45309;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) { - chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {}); - } - } - - 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 candidates = Array.from(root.querySelectorAll('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) { - const found = findVideo(el.shadowRoot); - if (found) candidates.push(found); - } - } - - if (candidates.length === 0) return null; - - // Multiple videos found → pick the best one - if (candidates.length === 1) return candidates[0]; - - let best = null; - let bestScore = -1; - for (const v of candidates) { - 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; - } - - // --- Audio Processing Module --- - const AUDIO_PRESETS = { - recommended: { threshold: -24, ratio: 8, attack: 0.010, release: 0.300, knee: 15 }, - dynamicRange: { threshold: -18, ratio: 4, attack: 0.020, release: 0.200, knee: 10 }, - vocalEnhancement: { threshold: -12, ratio: 3, attack: 0.015, release: 0.150, knee: 5 }, - smooth: { threshold: -30, ratio: 1.5, attack: 0.030, release: 0.250, knee: 20 }, - custom: { threshold: -24, ratio: 12, attack: 0.003, release: 0.250, knee: 30 } - }; - const DEFAULT_AUDIO_SETTINGS = { - enabled: false, - compressor: { - enabled: false, - preset: 'recommended', - customParams: { ...AUDIO_PRESETS.custom } - } - }; - let audioCtx = null; - let audioChains = new WeakMap(); - let currentAudioVideo = null; - - function mergeAudioSettings(settings = {}) { - const safeSettings = settings && typeof settings === 'object' ? settings : {}; - return { - ...DEFAULT_AUDIO_SETTINGS, - ...safeSettings, - compressor: { - ...DEFAULT_AUDIO_SETTINGS.compressor, - ...(safeSettings.compressor || {}), - customParams: { - ...DEFAULT_AUDIO_SETTINGS.compressor.customParams, - ...(safeSettings.compressor?.customParams || {}) - } - } - }; - } - - function initAudioContext() { - if (!audioCtx) { - try { - const AudioContextClass = window.AudioContext || window.webkitAudioContext; - if (!AudioContextClass) return null; - audioCtx = new AudioContextClass({ latencyHint: 'interactive' }); - } catch (e) { - reportLog(`Audio Processing unavailable: ${e.message}`, 'warn'); - return null; - } - } - if (audioCtx.state === 'suspended') { - audioCtx.resume().catch(() => { reportLog('AudioContext resume failed - browser may need page interaction first', 'warn'); }); - } - return audioCtx; - } - - 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 ctx = initAudioContext(); - if (!ctx) return null; - - try { - const src = ctx.createMediaElementSource(videoEl); - const compressor = ctx.createDynamicsCompressor(); - const dryGain = ctx.createGain(); - const compGain = ctx.createGain(); - - src.connect(dryGain); - dryGain.connect(ctx.destination); - src.connect(compressor); - compressor.connect(compGain); - compGain.connect(ctx.destination); - - dryGain.gain.value = 1; - compGain.gain.value = 0; - - const chain = { compressor, dryGain, compGain, active: false }; - audioChains.set(videoEl, chain); - currentAudioVideo = videoEl; - return chain; - } catch (e) { - reportLog(`Audio Processing setup failed: ${e.message}`, 'warn'); - return null; - } - } - - function rampGain(node, value, t) { - const current = node.gain.value; - node.gain.cancelScheduledValues(t); - node.gain.setValueAtTime(current, t); - node.gain.linearRampToValueAtTime(value, t + 0.04); - } - - function applyAudioBypass(videoEl) { - const chain = audioChains.get(videoEl); - if (!chain || !chain.active) return; - const t = chain.dryGain.context.currentTime; - rampGain(chain.dryGain, 1, t); - rampGain(chain.compGain, 0, t); - chain.active = false; - reportLog('Audio compressor disabled', 'info'); - } - - function bypassCurrentAudioProcessing() { - if (currentAudioVideo) applyAudioBypass(currentAudioVideo); - } - - function applyAudioSettings(videoEl, settings) { - const mergedSettings = mergeAudioSettings(settings); - if (!mergedSettings.enabled || !mergedSettings.compressor?.enabled) { - applyAudioBypass(videoEl); - return; - } - - const chain = setupAudioChain(videoEl); - if (!chain) return; - - const cSettings = mergedSettings.compressor; - const params = cSettings.preset === 'custom' - ? cSettings.customParams - : AUDIO_PRESETS[cSettings.preset] || AUDIO_PRESETS.recommended; - - chain.compressor.threshold.value = params.threshold ?? -24; - chain.compressor.knee.value = params.knee ?? 15; - chain.compressor.ratio.value = params.ratio ?? 8; - chain.compressor.attack.value = params.attack ?? 0.010; - chain.compressor.release.value = params.release ?? 0.300; - - if (!chain.active) { - const t = chain.dryGain.context.currentTime; - rampGain(chain.dryGain, 0, t); - rampGain(chain.compGain, 1, t); - chain.active = true; - reportLog('Audio compressor enabled', 'info'); - } - } - - // --- 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. - // --- SHARED_EPISODE_UTILS_INJECT_START --- - // This block is automatically replaced by /scripts/build-extension.js - function extractEpisodeId(title) { - if (!title || typeof title !== 'string') return null; - const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i); - if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`; - const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i); - if (ep) return `EP${String(ep[1]).padStart(3, '0')}`; - return null; - } - - function sameEpisode(titleA, titleB) { - if (!titleA && !titleB) return true; - if (!titleA || !titleB) return false; - const idA = extractEpisodeId(titleA); - const idB = extractEpisodeId(titleB); - if (idA && idB) return idA === idB; - if (idA || idB) return false; - return titleA === titleB; - } - // --- SHARED_EPISODE_UTILS_INJECT_END --- - - // 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'); - - // EC-12: a new episode dissolves any solo/desync state — the guest rejoins - // the room for the fresh content rather than staying stuck on the old one. - if (hcmDesynced) hcmReset(); - - // 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 === 'youtube.com' || host.endsWith('.youtube.com'); - const isTwitch = host === 'twitch.tv' || host.endsWith('.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) { - seekVideo(video, 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) { - seekVideo(video, 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) { - seekVideo(video, 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) return; - if (message.action === 'get_current_time') { - const video = findVideo(); - sendResponse({ currentTime: video ? video.currentTime : null }); - return true; - } - - if (message.action === 'APPLY_AUDIO_SETTINGS') { - _audioProcessingAllowed = true; - _audioSettings = mergeAudioSettings(message.settings); - const video = findVideo(); - if (video) applyAudioSettings(video, _audioSettings); - sendResponse({ ok: true }); - return true; - } - - if (message.action === 'RESET_AUDIO_PROCESSING') { - _audioProcessingAllowed = false; - bypassCurrentAudioProcessing(); - sendResponse({ ok: true }); - return true; - } - - // Host Control Mode: room mode/role changed. - if (message.type === 'CONTROL_MODE') { - const wasGated = hcmIsGuestGated(); - const prevHostPeerId = hcmHostPeerId; - hcmControlMode = message.controlMode || 'everyone'; - hcmAmController = !!message.amController; - hcmHostPeerId = message.hostPeerId || null; - // Reset guest-side state when leaving the gated state, OR when the - // host identity changes (room switch, host-leave fallback, missed - // teardown broadcast) — clears stale desync so a rejoin starts clean (H-3). - const hostChanged = prevHostPeerId !== null && hcmHostPeerId !== prevHostPeerId; - if ((wasGated && !hcmIsGuestGated()) || hostChanged) hcmReset(); - sendResponse({ ok: true }); - return true; - } - - // Host Control Mode: background blocked our local action — handle UX locally. - if (message.type === 'HOST_BLOCKED') { - hcmHandleBlocked(message.action, message.target || null); - sendResponse({ ok: true }); - return true; - } - - // Background asks for an immediate state push (e.g. the first peer just - // joined while we were solo) so the newcomer syncs without waiting. - if (message.type === 'REQUEST_HEARTBEAT') { - sendHeartbeat(); - sendResponse({ ok: true }); - return true; - } - - if (message.type === 'SERVER_COMMAND') { - const { action, payload } = message; - let actionCompleted = false; - - // Host Control Mode: while watching on our own (desynced), don't apply - // host commands. Only ACK FORCE_SYNC_PREPARE — that's the one the host's - // force-sync flow actually waits on. Skipping CMD_ACKs for PLAY/PAUSE/SEEK - // is intentional so the host's UI honestly reflects that we didn't apply - // the command (M-2); sending them would make the host think we're synced. - if (hcmDesynced) { - const soloIgnored = [EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE]; - if (soloIgnored.includes(action)) { - if (action === EVENTS.FORCE_SYNC_PREPARE) { - chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {}); - } - return; - } - } - - // 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 }).catch(() => {}); - } - 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'); - video.pause(); - try { - seekVideo(video, payload.targetTime); - } catch (e) { - reportLog(`Force Sync Seek Error: ${e.message}`, 'error'); - } - 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') { - // Host Control Mode: a desynced guest is watching on their own and must - // not join the lobby flow. Otherwise they'd pause on title match, report - // ready, but then ignore the host's FORCE_SYNC_* (hcmDesynced skip in - // SERVER_COMMAND) and end up frozen in pause. They also can't be counted - // toward lobby completion (background filters them out). - if (hcmDesynced) { - sendResponse({ status: 'ignored_desynced' }); - return true; - } - 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 === 'youtube.com' || h.endsWith('.youtube.com')) return 'YouTube'; - if (h === 'twitch.tv' || h.endsWith('.twitch.tv')) return 'Twitch'; - if (h === 'netflix.com' || h.endsWith('.netflix.com')) return 'Netflix'; - if (h === 'primevideo.com' || h.endsWith('.primevideo.com') || /(^|\.)amazon\.(com\.[a-z]{2}|co\.[a-z]{2}|[a-z]{2,})$/.test(h)) return 'Prime Video'; - if (h === 'disneyplus.com' || h.endsWith('.disneyplus.com')) return 'Disney+'; - if (h === 'hulu.com' || h.endsWith('.hulu.com')) return 'Hulu'; - if (h === 'hbomax.com' || h.endsWith('.hbomax.com') || h === 'max.com' || h.endsWith('.max.com')) return 'Max/HBO'; - if (h === 'vimeo.com' || h.endsWith('.vimeo.com')) return 'Vimeo'; - if (h === 'dailymotion.com' || h.endsWith('.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 - // Build the relay payload from the *current* media state and send it to - // background.js. Re-reads the video each call so deferred (coalesced) emits - // carry an up-to-date position. Safe with no/invalid video — it no-ops. - function sendContentEvent(action) { - 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; - - chrome.runtime.sendMessage({ - type: 'CONTENT_EVENT', - action, - payload: { - currentTime: current, - targetTime: current, - mediaTitle: mediaTitle, - timestamp: Date.now() - } - }).catch(() => {}); - - // Trigger proactive heartbeat to push stabilized state - scheduleProactiveHeartbeat(); - } - - // Trailing-edge flush of a coalesced play/pause burst: emit the final settled - // state. No-ops only when no burst followed the leading edge. We do NOT skip - // when the state matches the leading send — a remote command may have changed - // the shared state mid-window, so re-sending is the safe (idempotent) choice. - function flushPlayPause() { - playPauseCoalesceTimer = null; - const finalAction = pendingPlayPauseAction; - pendingPlayPauseAction = null; - // No burst follow-up (only the leading edge fired), or invalid state. - if (finalAction !== EVENTS.PLAY && finalAction !== EVENTS.PAUSE) return; - sendContentEvent(finalAction); - } - - 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 eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null)); - - // Echo-suppression for remotely-applied commands. MUST stay synchronous - // on event arrival: the suppress timer is short-lived (300ms) and would - // expire if deferred, leaking an echo back to peers. - 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; - - // Coalesce play/pause bursts (source swaps, ABR, ads, teardown). The - // synchronous gates above have already run; only the network emit is - // governed here. Leading edge sends the first event instantly; further - // events within the window are collapsed to the final state by the - // trailing flush. A pending burst is REPLACED, never dropped — the last - // event in the window always wins. - if (action === EVENTS.PLAY || action === EVENTS.PAUSE) { - if (playPauseCoalesceTimer === null) { - // Window closed → fresh action. Emit now (zero added latency). - sendContentEvent(action); - pendingPlayPauseAction = null; - } else { - // Window open → part of a burst. Defer to the trailing flush. - pendingPlayPauseAction = action; - clearTimeout(playPauseCoalesceTimer); - } - playPauseCoalesceTimer = setTimeout(flushPlayPause, PLAY_PAUSE_COALESCE_MS); - return; - } - - // SEEK (and any non play/pause action): emit immediately. - sendContentEvent(action); - } - - // --- 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; - closeAudioContext(); - if (keepAlivePort) { try { keepAlivePort.disconnect(); } catch (_e) { /* ignore */ } keepAlivePort = null; } - if (lobbyPollTimer) { clearInterval(lobbyPollTimer); lobbyPollTimer = null; } - if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; } - if (proactiveHeartbeatTimeout) { clearTimeout(proactiveHeartbeatTimeout); proactiveHeartbeatTimeout = null; } - // Drop any pending coalesced play/pause: we are tearing down and will - // disconnect — peers learn we are gone via PEER_STATUS 'left'. This also - // intentionally suppresses the teardown play/pause burst at the source. - if (playPauseCoalesceTimer) { clearTimeout(playPauseCoalesceTimer); playPauseCoalesceTimer = null; pendingPlayPauseAction = null; } - observer.disconnect(); - }); - 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); - // Host Control Mode: a 'waiting' (buffering) event opens a grace window so the - // pause it may trigger isn't misread as a deliberate guest action (EC-1). - const handleWaiting = () => { hcmBufferingUntil = Date.now() + HCM_BUFFERING_GRACE_MS; }; - - // 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 expectedSeekTime (programmatic seek from remote peer) - if (expectedSeekTime !== null) { - if (Math.abs(current - expectedSeekTime) < 1.0) { - // Video arrived at expected time. Safely clear and ignore. - expectedSeekTime = null; - lastReportedSeekTime = current; - return; - } else { - // User manually scrubbed to a DIFFERENT time while we were buffering - expectedSeekTime = null; - } - } - - // 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) { - if (currentAudioVideo && currentAudioVideo !== video) { - bypassCurrentAudioProcessing(); - } - 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); - if (existing.waiting) video.removeEventListener('waiting', existing.waiting); - } - video._koalaHandlers = { play: handlePlay, pause: handlePause, seeked: handleSeeked, loadeddata: handleLoadedData, waiting: handleWaiting }; - - video.addEventListener('play', handlePlay); - video.addEventListener('pause', handlePause); - video.addEventListener('seeked', handleSeeked); - video.addEventListener('loadeddata', handleLoadedData); - video.addEventListener('waiting', handleWaiting); - video.dataset.koalaAttached = 'true'; - lastVideoSrc = video.currentSrc || video.src || null; - - if (!lastKnownMediaTitle) { - lastKnownMediaTitle = getMediaTitle(); - } - - if (_audioSettings && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings); - } - } - - // 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; - closeAudioContext(); - 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); - } - }); - - // Host Control Mode: fetch current room mode/role on injection (we may have - // been injected after ROOM_DATA already arrived, missing the broadcast). - chrome.runtime.sendMessage({ type: 'GET_CONTROL_MODE' }, (res) => { - if (chrome.runtime.lastError || !res) return; - hcmControlMode = res.controlMode || 'everyone'; - hcmAmController = !!res.amController; - hcmHostPeerId = res.hostPeerId || null; - // Re-adopt persisted desync after a page reload so we don't start synced - // while background still relays us as "Solo" to the host (split-brain). - // Only when we're actually a gated guest — never adopt a stale flag as a - // controller or in 'everyone' mode (would self-label "Solo" / ignore commands). - if (res.desynced && res.controlMode === 'host-only' && !res.amController && !hcmDesynced) { - hcmDesynced = true; - hcmShowBadge(); - } - }); - - // Pull localized strings for the in-page dialog/badge (English fallback above). - chrome.runtime.sendMessage({ type: 'GET_HCM_STRINGS' }, (res) => { - if (chrome.runtime.lastError || !res) return; - Object.keys(hcmStrings).forEach(k => { if (res[k]) hcmStrings[k] = res[k]; }); - // If the badge is already showing (early desync), refresh its text in place. - // Re-creating the host element nukes the click target mid-poll and can drop a - // click that landed between remove() and the re-create (L-4). - if (hcmBadgeHost) { - const span = hcmBadgeHost.shadowRoot && hcmBadgeHost.shadowRoot.querySelector('span'); - if (span) span.textContent = '● ' + hcmStrings.badge; - } - }); - -})(); +/** + * 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 + let expectedSeekTime = null; // strictly track programmatic seeks + + const PAGE_API_SEEK_BRIDGE = 1; + let lastDisneyPlusTimelineCandidates = []; + + 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 getSeekableRange(video) { + try { + const ranges = video.seekable; + if (!ranges || ranges.length === 0) return null; + const current = video.currentTime; + let index = ranges.length - 1; + if (Number.isFinite(current)) { + for (let i = 0; i < ranges.length; i++) { + if (current >= ranges.start(i) && current <= ranges.end(i)) { + index = i; + break; + } + } + } + const start = ranges.start(index); + const end = ranges.end(index); + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null; + return { start, end, duration: end - start }; + } catch (_e) { + return null; + } + } + + function parseClockTime(value) { + const parts = String(value || '').trim().split(':').map(Number); + if (parts.length < 2 || parts.length > 3 || parts.some(n => !Number.isFinite(n))) return null; + return parts.length === 2 + ? parts[0] * 60 + parts[1] + : parts[0] * 3600 + parts[1] * 60 + parts[2]; + } + + function parseTimelineText(text) { + const matches = String(text || '').match(/\b\d{1,3}:\d{2}(?::\d{2})?\b/g); + if (!matches || matches.length < 2) return null; + const current = parseClockTime(matches[0]); + const duration = parseClockTime(matches[matches.length - 1]); + if (!Number.isFinite(current) || !Number.isFinite(duration) || duration < 60 || current > duration + 5) return null; + return { current, duration }; + } + + function getDisneyPlusUiTimeline() { + if (typeof document === 'undefined') return null; + const selectors = [ + '[role="slider"]', + '[role="progressbar"]', + '[aria-valuenow][aria-valuemax]', + '[aria-valuetext]', + '[aria-label]', + '[data-testid]', + '[data-test]', + '[class*="time" i]', + '[class*="progress" i]', + 'time', + 'output', + 'button', + 'span', + 'p', + 'div' + ].join(','); + const nodes = Array.from(document.querySelectorAll(selectors)).slice(0, 2000); + const textParts = []; + const candidates = []; + let best = null; + let bestScore = -1; + + function considerTimeline(parsed, source, weight = 0) { + if (!parsed) return; + const score = weight + + (parsed.duration >= 20 * 60 ? 200 : 0) + + (parsed.current > 0 ? 50 : 0) - + Math.abs((parsed.duration / 2) - parsed.current) / 100; + candidates.push({ ...parsed, source, score: Math.round(score) }); + if (score > bestScore) { + bestScore = score; + best = parsed; + } + } + + for (const node of nodes) { + const now = Number(node.getAttribute?.('aria-valuenow') ?? node.value); + const max = Number(node.getAttribute?.('aria-valuemax') ?? node.max); + if (Number.isFinite(now) && Number.isFinite(max) && max >= 60 && now >= 0 && now <= max + 5) { + considerTimeline({ current: now, duration: max }, 'aria-value', 300); + } + + for (const attr of ['aria-valuetext', 'aria-label', 'title', 'data-testid', 'data-test']) { + const parsed = parseTimelineText(node.getAttribute?.(attr)); + considerTimeline(parsed, attr, 200); + } + + const text = String(node.textContent || '').trim(); + if (text && text.length < 200) { + const parsed = parseTimelineText(text); + considerTimeline(parsed, 'text', 100); + textParts.push(text); + } + } + + considerTimeline(parseTimelineText(textParts.join(' ')), 'combined-text', 25); + lastDisneyPlusTimelineCandidates = candidates + .sort((a, b) => b.score - a.score) + .slice(0, 8) + .map(({ source, current, duration }) => ({ source, current, duration })); + return best; + } + + function getElementLabel(el) { + return [ + el.getAttribute?.('aria-label'), + el.getAttribute?.('title'), + el.getAttribute?.('data-testid'), + el.getAttribute?.('data-test'), + el.textContent + ].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); + } + + function getDisneyPlusSeekButtonLabels() { + if (!isDisneyPlusHost() || typeof document === 'undefined') return []; + return Array.from(document.querySelectorAll('button,[role="button"]')) + .map(getElementLabel) + .filter(Boolean) + .slice(0, 20); + } + + function clickDisneyPlusRelativeSeek(delta) { + if (!isDisneyPlusHost() || !Number.isFinite(delta) || Math.abs(delta) < 1 || typeof document === 'undefined') return false; + const backward = delta < 0; + const candidates = Array.from(document.querySelectorAll('button,[role="button"]')); + const backRe = /rewind|backward|back\b|zurück|zurueck|rück|rueck|retour|recul|retroced|atrás|voltar|indietro/i; + const forwardRe = /forward|ahead|skip|vorwärts|vorwaerts|vorsp|weiter|avancer|adelant|avançar|avancar|avanti/i; + const timeRe = /\b(5|10|15|30)\b|sec|sek|second/i; + + const button = candidates.find(btn => { + const label = getElementLabel(btn); + const matchesDirection = backward ? backRe.test(label) : forwardRe.test(label); + return matchesDirection && timeRe.test(label); + }) || candidates.find(btn => { + const label = getElementLabel(btn); + return backward ? backRe.test(label) : forwardRe.test(label); + }); + + if (!button || typeof button.click !== 'function') return false; + const clicks = Math.max(1, Math.min(12, Math.round(Math.abs(delta) / 10))); + for (let i = 0; i < clicks; i++) { + setTimeout(() => button.click(), i * 60); + } + return true; + } + + function getDisneyPlusTimeline(video) { + if (!isDisneyPlusHost()) return null; + const range = getSeekableRange(video); + const current = video.currentTime; + const ui = getDisneyPlusUiTimeline(); + + if (ui) { + let nativeScale = 1; + if (range && range.duration > ui.duration * 1.2) { + nativeScale = range.duration / ui.duration; + } else if (Number.isFinite(current) && ui.current > 1 && current > ui.current * 1.5) { + nativeScale = current / ui.current; + } + const nativeStart = Number.isFinite(current) + ? current - ui.current * nativeScale + : (range ? range.start : 0); + return { + ...(range || {}), + current: ui.current, + duration: ui.duration, + nativeScale, + nativeStart + }; + } + + if (!range || range.start < 1 || range.duration < 60) return null; + if (!Number.isFinite(current) || current < range.start - 1 || current > range.end + 1) return null; + return { ...range, current: Math.max(0, current - range.start), nativeScale: 1, nativeStart: range.start }; + } + + // Site-specific player exceptions live here. The default HTML5 path stays below. + function getSiteQuirkAdapters() { + return [{ + name: 'disneyplus-timeline-and-buttons', + key: 'disneyPlus', + urls: ['disneyplus.com'], + matches() { return matchesPlayerUrls(this.urls); }, + getTimeline: getDisneyPlusTimeline, + clickRelativeSeek: clickDisneyPlusRelativeSeek, + getDebug(video) { + return { + name: this.name, + key: 'disneyPlus', + urls: this.urls, + timeline: getDisneyPlusTimeline(video), + timelineCandidates: lastDisneyPlusTimelineCandidates, + seekButtons: getDisneyPlusSeekButtonLabels() + }; + } + }]; + } + + 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 current = video.currentTime; + if (!Number.isFinite(current)) return null; + const siteTimeline = getSiteQuirkTimeline(video); + return siteTimeline ? siteTimeline.current : current; + } + + function getSyncDuration(video) { + const siteTimeline = getSiteQuirkTimeline(video); + if (siteTimeline) return siteTimeline.duration; + 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 seekVideo(video, targetTime, relativeDelta = null) { + const siteQuirk = getActiveSiteQuirk(); + if (siteQuirk && !siteQuirk.getTimeline(video) && siteQuirk.clickRelativeSeek(relativeDelta)) { + expectedSeekTime = null; + return; + } + const nativeTargetTime = toNativeSeekTime(video, targetTime); + expectedSeekTime = targetTime; + if (shouldUsePageApiSeek()) { + window.postMessage({ __koalaPageApiSeek: PAGE_API_SEEK_BRIDGE, kind: 'seek', time: targetTime }, '*'); + return; + } + video.currentTime = nativeTargetTime; + } + + // --- 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 + + // --- 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) => { + _autoSyncEnabled = data.autoSyncNextEpisode !== false; + _audioSettings = mergeAudioSettings(data.audioSettings); + const video = findVideo(); + if (video && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings); + }); + chrome.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes.autoSyncNextEpisode) { + _autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false; + } + }); + + // --- 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) + // 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 (!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 = () => { + chrome.runtime.sendMessage({ type: 'REQUEST_HOST_SYNC' }, (res) => { + if (chrome.runtime.lastError || !res || !res.target) { + if (++attempts < 5) setTimeout(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; + } + setTimeout(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:#1f2937;color:#f9fafb;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 #374151'); + wrap.setAttribute('role', 'dialog'); + const title = hcmEl('div', 'font-weight:600;margin-bottom:6px', hcmStrings.title); + const body = hcmEl('div', 'margin-bottom:12px;color:#d1d5db', hcmStrings.body); + const btnRow = hcmEl('div', 'display:flex;gap:8px;justify-content:flex-end'); + const soloBtn = hcmEl('button', 'background:#374151;color:#f9fafb;border:0;padding:8px 12px;border-radius:8px;cursor:pointer', hcmStrings.solo); + const stayBtn = hcmEl('button', 'background:#10b981;color:#062a20;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. + chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: true }).catch(() => {}); + hcmShowBadge(); + } + + function hcmExitDesync() { + const wasDesynced = hcmDesynced; + hcmDesynced = false; + hcmRemoveBadge(); + if (wasDesynced) { + chrome.runtime.sendMessage({ 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; + const retry = () => { + hcmBadgePending = false; + if (hcmDesynced && !hcmBadgeHost) hcmShowBadge(); + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', retry, { once: true }); + } else { + setTimeout(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:#b45309;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) { + chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {}); + } + } + + 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 candidates = Array.from(root.querySelectorAll('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) { + const found = findVideo(el.shadowRoot); + if (found) candidates.push(found); + } + } + + if (candidates.length === 0) return null; + + // Multiple videos found → pick the best one + if (candidates.length === 1) return candidates[0]; + + let best = null; + let bestScore = -1; + for (const v of candidates) { + 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; + } + + // --- Audio Processing Module --- + const AUDIO_PRESETS = { + recommended: { threshold: -24, ratio: 8, attack: 0.010, release: 0.300, knee: 15 }, + dynamicRange: { threshold: -18, ratio: 4, attack: 0.020, release: 0.200, knee: 10 }, + vocalEnhancement: { threshold: -12, ratio: 3, attack: 0.015, release: 0.150, knee: 5 }, + smooth: { threshold: -30, ratio: 1.5, attack: 0.030, release: 0.250, knee: 20 }, + custom: { threshold: -24, ratio: 12, attack: 0.003, release: 0.250, knee: 30 } + }; + const DEFAULT_AUDIO_SETTINGS = { + enabled: false, + compressor: { + enabled: false, + preset: 'recommended', + customParams: { ...AUDIO_PRESETS.custom } + } + }; + let audioCtx = null; + let audioChains = new WeakMap(); + let currentAudioVideo = null; + + function mergeAudioSettings(settings = {}) { + const safeSettings = settings && typeof settings === 'object' ? settings : {}; + return { + ...DEFAULT_AUDIO_SETTINGS, + ...safeSettings, + compressor: { + ...DEFAULT_AUDIO_SETTINGS.compressor, + ...(safeSettings.compressor || {}), + customParams: { + ...DEFAULT_AUDIO_SETTINGS.compressor.customParams, + ...(safeSettings.compressor?.customParams || {}) + } + } + }; + } + + function initAudioContext() { + if (!audioCtx) { + try { + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + if (!AudioContextClass) return null; + audioCtx = new AudioContextClass({ latencyHint: 'interactive' }); + } catch (e) { + reportLog(`Audio Processing unavailable: ${e.message}`, 'warn'); + return null; + } + } + if (audioCtx.state === 'suspended') { + audioCtx.resume().catch(() => { reportLog('AudioContext resume failed - browser may need page interaction first', 'warn'); }); + } + return audioCtx; + } + + 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 ctx = initAudioContext(); + if (!ctx) return null; + + try { + const src = ctx.createMediaElementSource(videoEl); + const compressor = ctx.createDynamicsCompressor(); + const dryGain = ctx.createGain(); + const compGain = ctx.createGain(); + + src.connect(dryGain); + dryGain.connect(ctx.destination); + src.connect(compressor); + compressor.connect(compGain); + compGain.connect(ctx.destination); + + dryGain.gain.value = 1; + compGain.gain.value = 0; + + const chain = { compressor, dryGain, compGain, active: false }; + audioChains.set(videoEl, chain); + currentAudioVideo = videoEl; + return chain; + } catch (e) { + reportLog(`Audio Processing setup failed: ${e.message}`, 'warn'); + return null; + } + } + + function rampGain(node, value, t) { + const current = node.gain.value; + node.gain.cancelScheduledValues(t); + node.gain.setValueAtTime(current, t); + node.gain.linearRampToValueAtTime(value, t + 0.04); + } + + function applyAudioBypass(videoEl) { + const chain = audioChains.get(videoEl); + if (!chain || !chain.active) return; + const t = chain.dryGain.context.currentTime; + rampGain(chain.dryGain, 1, t); + rampGain(chain.compGain, 0, t); + chain.active = false; + reportLog('Audio compressor disabled', 'info'); + } + + function bypassCurrentAudioProcessing() { + if (currentAudioVideo) applyAudioBypass(currentAudioVideo); + } + + function applyAudioSettings(videoEl, settings) { + const mergedSettings = mergeAudioSettings(settings); + if (!mergedSettings.enabled || !mergedSettings.compressor?.enabled) { + applyAudioBypass(videoEl); + return; + } + + const chain = setupAudioChain(videoEl); + if (!chain) return; + + const cSettings = mergedSettings.compressor; + const params = cSettings.preset === 'custom' + ? cSettings.customParams + : AUDIO_PRESETS[cSettings.preset] || AUDIO_PRESETS.recommended; + + chain.compressor.threshold.value = params.threshold ?? -24; + chain.compressor.knee.value = params.knee ?? 15; + chain.compressor.ratio.value = params.ratio ?? 8; + chain.compressor.attack.value = params.attack ?? 0.010; + chain.compressor.release.value = params.release ?? 0.300; + + if (!chain.active) { + const t = chain.dryGain.context.currentTime; + rampGain(chain.dryGain, 0, t); + rampGain(chain.compGain, 1, t); + chain.active = true; + reportLog('Audio compressor enabled', 'info'); + } + } + + // --- 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. + // --- SHARED_EPISODE_UTILS_INJECT_START --- + // This block is automatically replaced by /scripts/build-extension.js + function extractEpisodeId(title) { + if (!title || typeof title !== 'string') return null; + const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i); + if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`; + const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i); + if (ep) return `EP${String(ep[1]).padStart(3, '0')}`; + return null; + } + + function sameEpisode(titleA, titleB) { + if (!titleA && !titleB) return true; + if (!titleA || !titleB) return false; + const idA = extractEpisodeId(titleA); + const idB = extractEpisodeId(titleB); + if (idA && idB) return idA === idB; + if (idA || idB) return false; + return titleA === titleB; + } + // --- SHARED_EPISODE_UTILS_INJECT_END --- + + // 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'); + + // EC-12: a new episode dissolves any solo/desync state — the guest rejoins + // the room for the fresh content rather than staying stuck on the old one. + if (hcmDesynced) hcmReset(); + + // 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; + } + } + + function getPlayerActionFixes() { + return [ + { + name: 'youtube-player-buttons', + urls: ['youtube.com'], + playPauseButtonSelector: '.ytp-play-button' + }, + { + name: 'twitch-player-buttons', + urls: ['twitch.tv'], + playPauseButtonSelector: '[data-a-target="player-play-pause-button"]' + } + ]; + } + + function getActivePlayerActionFix() { + return getPlayerActionFixes().find(fix => matchesPlayerUrls(fix.urls)) || null; + } + + function tryPlayerActionFix(fix, action, video, data) { + if (!fix) return false; + const button = document.querySelector(fix.playPauseButtonSelector); + if (!button) return false; + + const isCurrentlyPlaying = !video.paused; + if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) { + _setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused'); + button.click(); + } + if (action === EVENTS.SEEK) { + seekVideo(video, data.targetTime, data.delta); + } + return true; + } + + // --- Helper: site-specific player actions, then native HTML5 fallback --- + 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 actionFix = getActivePlayerActionFix(); + if (tryPlayerActionFix(actionFix, action, video, data)) { + 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) { + seekVideo(video, data.targetTime, data.delta); + } + } 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 current = getSyncCurrentTime(video); + const timeDiff = current !== null ? Math.abs(current - targetTime) : Infinity; + 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) return; + if (message.action === 'get_current_time') { + const video = findVideo(); + sendResponse({ currentTime: video ? video.currentTime : null }); + return true; + } + + if (message.action === 'APPLY_AUDIO_SETTINGS') { + _audioProcessingAllowed = true; + _audioSettings = mergeAudioSettings(message.settings); + const video = findVideo(); + if (video) applyAudioSettings(video, _audioSettings); + sendResponse({ ok: true }); + return true; + } + + if (message.action === 'RESET_AUDIO_PROCESSING') { + _audioProcessingAllowed = false; + bypassCurrentAudioProcessing(); + sendResponse({ ok: true }); + return true; + } + + // Host Control Mode: room mode/role changed. + if (message.type === 'CONTROL_MODE') { + const wasGated = hcmIsGuestGated(); + const prevHostPeerId = hcmHostPeerId; + hcmControlMode = message.controlMode || 'everyone'; + hcmAmController = !!message.amController; + hcmHostPeerId = message.hostPeerId || null; + // Reset guest-side state when leaving the gated state, OR when the + // host identity changes (room switch, host-leave fallback, missed + // teardown broadcast) — clears stale desync so a rejoin starts clean (H-3). + const hostChanged = prevHostPeerId !== null && hcmHostPeerId !== prevHostPeerId; + if ((wasGated && !hcmIsGuestGated()) || hostChanged) hcmReset(); + sendResponse({ ok: true }); + return true; + } + + // Host Control Mode: background blocked our local action — handle UX locally. + if (message.type === 'HOST_BLOCKED') { + hcmHandleBlocked(message.action, message.target || null); + sendResponse({ ok: true }); + return true; + } + + // Background asks for an immediate state push (e.g. the first peer just + // joined while we were solo) so the newcomer syncs without waiting. + if (message.type === 'REQUEST_HEARTBEAT') { + sendHeartbeat(); + sendResponse({ ok: true }); + return true; + } + + if (message.type === 'SERVER_COMMAND') { + const { action, payload } = message; + let actionCompleted = false; + + // Host Control Mode: while watching on our own (desynced), don't apply + // host commands. Only ACK FORCE_SYNC_PREPARE — that's the one the host's + // force-sync flow actually waits on. Skipping CMD_ACKs for PLAY/PAUSE/SEEK + // is intentional so the host's UI honestly reflects that we didn't apply + // the command (M-2); sending them would make the host think we're synced. + if (hcmDesynced) { + const soloIgnored = [EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE]; + if (soloIgnored.includes(action)) { + if (action === EVENTS.FORCE_SYNC_PREPARE) { + chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {}); + } + return; + } + } + + // 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 }).catch(() => {}); + } + 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'); + video.pause(); + try { + seekVideo(video, payload.targetTime); + } catch (e) { + reportLog(`Force Sync Seek Error: ${e.message}`, 'error'); + } + 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') { + // Host Control Mode: a desynced guest is watching on their own and must + // not join the lobby flow. Otherwise they'd pause on title match, report + // ready, but then ignore the host's FORCE_SYNC_* (hcmDesynced skip in + // SERVER_COMMAND) and end up frozen in pause. They also can't be counted + // toward lobby completion (background filters them out). + if (hcmDesynced) { + sendResponse({ status: 'ignored_desynced' }); + return true; + } + 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 === 'youtube.com' || h.endsWith('.youtube.com')) return 'YouTube'; + if (h === 'twitch.tv' || h.endsWith('.twitch.tv')) return 'Twitch'; + if (h === 'netflix.com' || h.endsWith('.netflix.com')) return 'Netflix'; + if (h === 'primevideo.com' || h.endsWith('.primevideo.com') || /(^|\.)amazon\.(com\.[a-z]{2}|co\.[a-z]{2}|[a-z]{2,})$/.test(h)) return 'Prime Video'; + if (h === 'disneyplus.com' || h.endsWith('.disneyplus.com')) return 'Disney+'; + if (h === 'hulu.com' || h.endsWith('.hulu.com')) return 'Hulu'; + if (h === 'hbomax.com' || h.endsWith('.hbomax.com') || h === 'max.com' || h.endsWith('.max.com')) return 'Max/HBO'; + if (h === 'vimeo.com' || h.endsWith('.vimeo.com')) return 'Vimeo'; + if (h === 'dailymotion.com' || h.endsWith('.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: getSyncCurrentTime(video), + duration: getSyncDuration(video), + nativeCurrentTime: video.currentTime, + nativeDuration: Number.isFinite(video.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, + siteQuirk: getSiteQuirkDebug(video), + 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 + // Build the relay payload from the *current* media state and send it to + // background.js. Re-reads the video each call so deferred (coalesced) emits + // carry an up-to-date position. Safe with no/invalid video — it no-ops. + function sendContentEvent(action) { + const video = findVideo(); + if (!video) return; + + const current = getSyncCurrentTime(video); + if (current === null) return; + + const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null; + + chrome.runtime.sendMessage({ + type: 'CONTENT_EVENT', + action, + payload: { + currentTime: current, + targetTime: current, + mediaTitle: mediaTitle, + timestamp: Date.now() + } + }).catch(() => {}); + + // Trigger proactive heartbeat to push stabilized state + scheduleProactiveHeartbeat(); + } + + // Trailing-edge flush of a coalesced play/pause burst: emit the final settled + // state. No-ops only when no burst followed the leading edge. We do NOT skip + // when the state matches the leading send — a remote command may have changed + // the shared state mid-window, so re-sending is the safe (idempotent) choice. + function flushPlayPause() { + playPauseCoalesceTimer = null; + const finalAction = pendingPlayPauseAction; + pendingPlayPauseAction = null; + // No burst follow-up (only the leading edge fired), or invalid state. + if (finalAction !== EVENTS.PLAY && finalAction !== EVENTS.PAUSE) return; + sendContentEvent(finalAction); + } + + function reportEvent(action) { + if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) { + clearTimeout(seekDebounceTimer); + seekDebounceTimer = null; + const v = findVideo(); + if (v) { + const syncTime = getSyncCurrentTime(v); + if (syncTime === null) return; + lastReportedSeekTime = syncTime; + reportLog(`[Seek] Debounce flushed immediately due to ${action.toUpperCase()}`, 'info'); + reportEvent(EVENTS.SEEK); + } + } + + const video = findVideo(); + if (!video) return; + + const current = getSyncCurrentTime(video); + if (current === null) return; + + const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null)); + + // Echo-suppression for remotely-applied commands. MUST stay synchronous + // on event arrival: the suppress timer is short-lived (300ms) and would + // expire if deferred, leaking an echo back to peers. + 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; + + // Coalesce play/pause bursts (source swaps, ABR, ads, teardown). The + // synchronous gates above have already run; only the network emit is + // governed here. Leading edge sends the first event instantly; further + // events within the window are collapsed to the final state by the + // trailing flush. A pending burst is REPLACED, never dropped — the last + // event in the window always wins. + if (action === EVENTS.PLAY || action === EVENTS.PAUSE) { + if (playPauseCoalesceTimer === null) { + // Window closed → fresh action. Emit now (zero added latency). + sendContentEvent(action); + pendingPlayPauseAction = null; + } else { + // Window open → part of a burst. Defer to the trailing flush. + pendingPlayPauseAction = action; + clearTimeout(playPauseCoalesceTimer); + } + playPauseCoalesceTimer = setTimeout(flushPlayPause, PLAY_PAUSE_COALESCE_MS); + return; + } + + // SEEK (and any non play/pause action): emit immediately. + sendContentEvent(action); + } + + // --- 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; + closeAudioContext(); + if (keepAlivePort) { try { keepAlivePort.disconnect(); } catch (_e) { /* ignore */ } keepAlivePort = null; } + if (lobbyPollTimer) { clearInterval(lobbyPollTimer); lobbyPollTimer = null; } + if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; } + if (proactiveHeartbeatTimeout) { clearTimeout(proactiveHeartbeatTimeout); proactiveHeartbeatTimeout = null; } + // Drop any pending coalesced play/pause: we are tearing down and will + // disconnect — peers learn we are gone via PEER_STATUS 'left'. This also + // intentionally suppresses the teardown play/pause burst at the source. + if (playPauseCoalesceTimer) { clearTimeout(playPauseCoalesceTimer); playPauseCoalesceTimer = null; pendingPlayPauseAction = null; } + observer.disconnect(); + }); + 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); + // Host Control Mode: a 'waiting' (buffering) event opens a grace window so the + // pause it may trigger isn't misread as a deliberate guest action (EC-1). + const handleWaiting = () => { hcmBufferingUntil = Date.now() + HCM_BUFFERING_GRACE_MS; }; + + // 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 = getSyncCurrentTime(video); + if (current === null) return; + + // Step 1: Check expectedSeekTime (programmatic seek from remote peer) + if (expectedSeekTime !== null) { + if (Math.abs(current - expectedSeekTime) < 1.0) { + // Video arrived at expected time. Safely clear and ignore. + expectedSeekTime = null; + lastReportedSeekTime = current; + return; + } else { + // User manually scrubbed to a DIFFERENT time while we were buffering + expectedSeekTime = null; + } + } + + // 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 = getSyncCurrentTime(v); + if (settled === null) return; + 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) { + if (currentAudioVideo && currentAudioVideo !== video) { + bypassCurrentAudioProcessing(); + } + 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); + if (existing.waiting) video.removeEventListener('waiting', existing.waiting); + } + video._koalaHandlers = { play: handlePlay, pause: handlePause, seeked: handleSeeked, loadeddata: handleLoadedData, waiting: handleWaiting }; + + video.addEventListener('play', handlePlay); + video.addEventListener('pause', handlePause); + video.addEventListener('seeked', handleSeeked); + video.addEventListener('loadeddata', handleLoadedData); + video.addEventListener('waiting', handleWaiting); + video.dataset.koalaAttached = 'true'; + lastVideoSrc = video.currentSrc || video.src || null; + + if (!lastKnownMediaTitle) { + lastKnownMediaTitle = getMediaTitle(); + } + + if (_audioSettings && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings); + } + } + + // 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; + closeAudioContext(); + 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: getSyncCurrentTime(video), + 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); + } + }); + + // Host Control Mode: fetch current room mode/role on injection (we may have + // been injected after ROOM_DATA already arrived, missing the broadcast). + chrome.runtime.sendMessage({ type: 'GET_CONTROL_MODE' }, (res) => { + if (chrome.runtime.lastError || !res) return; + hcmControlMode = res.controlMode || 'everyone'; + hcmAmController = !!res.amController; + hcmHostPeerId = res.hostPeerId || null; + // Re-adopt persisted desync after a page reload so we don't start synced + // while background still relays us as "Solo" to the host (split-brain). + // Only when we're actually a gated guest — never adopt a stale flag as a + // controller or in 'everyone' mode (would self-label "Solo" / ignore commands). + if (res.desynced && res.controlMode === 'host-only' && !res.amController && !hcmDesynced) { + hcmDesynced = true; + hcmShowBadge(); + } + }); + + // Pull localized strings for the in-page dialog/badge (English fallback above). + chrome.runtime.sendMessage({ type: 'GET_HCM_STRINGS' }, (res) => { + if (chrome.runtime.lastError || !res) return; + Object.keys(hcmStrings).forEach(k => { if (res[k]) hcmStrings[k] = res[k]; }); + // If the badge is already showing (early desync), refresh its text in place. + // Re-creating the host element nukes the click target mid-poll and can drop a + // click that landed between remove() and the re-create (L-4). + if (hcmBadgeHost) { + const span = hcmBadgeHost.shadowRoot && hcmBadgeHost.shadowRoot.querySelector('span'); + if (span) span.textContent = '● ' + hcmStrings.badge; + } + }); + +})(); diff --git a/extension/page-api-seek-overrides.js b/extension/page-api-seek-overrides.js index 58532ed..60c9041 100644 --- a/extension/page-api-seek-overrides.js +++ b/extension/page-api-seek-overrides.js @@ -1,6 +1,10 @@ (function(root) { - const PAGE_API_SEEK_PROVIDERS = [ - { domain: 'netflix.com', provider: 'netflix' } // Avoids M7375 when seeking via video.currentTime. + const PAGE_API_SEEK_FIXES = [ + { + name: 'netflix-page-api-seek', + urls: ['netflix.com'], + provider: 'netflix' + } ]; function normalizeHost(input) { @@ -12,12 +16,16 @@ } function matchesDomain(host, domain) { - return host === domain || host.endsWith(`.${domain}`); + const normalizedDomain = normalizeHost(domain); + return normalizedDomain && (host === normalizedDomain || host.endsWith(`.${normalizedDomain}`)); } - root.KOALA_PAGE_API_SEEK_PROVIDERS = PAGE_API_SEEK_PROVIDERS; + root.KOALA_PAGE_API_SEEK_FIXES = PAGE_API_SEEK_FIXES; + root.KOALA_PAGE_API_SEEK_PROVIDERS = PAGE_API_SEEK_FIXES; root.koalaFindPageApiSeekProvider = (input) => { const host = normalizeHost(input); - return PAGE_API_SEEK_PROVIDERS.find(entry => matchesDomain(host, entry.domain)) || null; + return PAGE_API_SEEK_FIXES.find(entry => + Array.isArray(entry.urls) && entry.urls.some(url => matchesDomain(host, url)) + ) || null; }; })(globalThis); diff --git a/extension/popup.js b/extension/popup.js index 294dcb6..6cbb31e 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -74,7 +74,8 @@ const elements = { syncTabCopyInvite: document.getElementById('syncTabCopyInvite'), devToolsTabBtn: document.getElementById('devToolsTabBtn'), remoteSeekBack: document.getElementById('remoteSeekBack'), - remoteSeekForward: document.getElementById('remoteSeekForward') + remoteSeekForward: document.getElementById('remoteSeekForward'), + remoteSeekFiveMin: document.getElementById('remoteSeekFiveMin') }; let localPeerId = null; @@ -1708,8 +1709,8 @@ elements.pauseBtn.addEventListener('click', () => { }, 2500); }); -function simulateRemoteSeek(delta) { - chrome.runtime.sendMessage({ type: 'DEV_SIMULATE_REMOTE_SEEK', delta }, (res) => { +function simulateRemoteSeek({ delta = null, targetTime = null }) { + chrome.runtime.sendMessage({ type: 'DEV_SIMULATE_REMOTE_SEEK', delta, targetTime }, (res) => { if (chrome.runtime.lastError || !res || res.status !== 'ok') { showToast(res?.message || 'Remote seek failed', 'error'); return; @@ -1720,10 +1721,13 @@ function simulateRemoteSeek(delta) { } if (elements.remoteSeekBack) { - elements.remoteSeekBack.addEventListener('click', () => simulateRemoteSeek(-30)); + elements.remoteSeekBack.addEventListener('click', () => simulateRemoteSeek({ delta: -30 })); } if (elements.remoteSeekForward) { - elements.remoteSeekForward.addEventListener('click', () => simulateRemoteSeek(30)); + elements.remoteSeekForward.addEventListener('click', () => simulateRemoteSeek({ delta: 30 })); +} +if (elements.remoteSeekFiveMin) { + elements.remoteSeekFiveMin.addEventListener('click', () => simulateRemoteSeek({ targetTime: 300 })); } elements.clearLogs.addEventListener('click', () => { @@ -2083,6 +2087,26 @@ elements.copyLogs.addEventListener('click', () => { lines.push(`- **ReadyState:** ${readyOk ? '\u2705' : '\u26A0\uFE0F'} ${safe(vs.readyState, '?')} (${readyLabel})`); lines.push(`- **Network:** ${safe(vs.networkState, '?')} (${netLabel})`); lines.push(`- **Buffered:** ${safe(vs.buffered, '?')}`); + if (vs.nativeCurrentTime != null || vs.nativeDuration != null) { + lines.push(`- **Native Time:** ${safe(vs.nativeCurrentTime, '?')}s / ${safe(vs.nativeDuration, '?')}s`); + } + if (vs.siteQuirk) { + const quirk = vs.siteQuirk; + const label = quirk.name || quirk.key || 'site'; + if (quirk.timeline) { + lines.push(`- **${label} Timeline:** ${safe(quirk.timeline.current, '?')}s / ${safe(quirk.timeline.duration, '?')}s`); + } + const candidates = Array.isArray(quirk.timelineCandidates) ? quirk.timelineCandidates : []; + if (candidates.length > 0) { + lines.push(`- **${label} Timeline Candidates:**`); + candidates.forEach(c => lines.push(` - ${safe(c.source, '?')}: ${safe(c.current, '?')}s / ${safe(c.duration, '?')}s`)); + } + const buttons = Array.isArray(quirk.seekButtons) ? quirk.seekButtons : []; + if (buttons.length > 0) { + lines.push(`- **${label} Buttons:**`); + buttons.forEach(label => lines.push(` - ${label}`)); + } + } lines.push(`- **Dimensions:** ${safe(vs.videoWidth, '?')}x${safe(vs.videoHeight, '?')}${dimOk ? '' : ' \u26A0\uFE0F 0x0'}`); lines.push(`- **Muted:** ${safe(vs.muted, '?')} | **Volume:** ${safe(vs.volume, '?')} | **Speed:** ${safe(vs.playbackRate, '?')}x`); lines.push(`- **Seeking:** ${safe(vs.seeking, '?')} | **Ended:** ${safe(vs.ended, '?')} | **Loop:** ${safe(vs.loop, '?')}`); @@ -2256,6 +2280,18 @@ function refreshDebugInfo() { addField('Network', `${state.networkState} (${state.networkStateLabel || '?'})`, state.networkState === 1 ? '#22c55e' : state.networkState === 3 ? '#ef4444' : 'var(--text-muted)'); addField('Buffered', state.buffered || '?'); + if (state.nativeCurrentTime != null || state.nativeDuration != null) { + addField('Native Time', `${state.nativeCurrentTime ?? '?'}s / ${state.nativeDuration ?? '?'}s`); + } + if (state.siteQuirk) { + const quirk = state.siteQuirk; + const label = quirk.name || quirk.key || 'Site'; + if (quirk.timeline) { + addField(`${label} Timeline`, `${quirk.timeline.current ?? '?'}s / ${quirk.timeline.duration ?? '?'}s`); + } + const buttons = Array.isArray(quirk.seekButtons) ? quirk.seekButtons.slice(0, 4).join(' | ') : ''; + if (buttons) addField(`${label} Buttons`, buttons); + } addSection('Properties'); addField('Seeking', String(state.seeking)); diff --git a/scripts/test-content-video-finder.cjs b/scripts/test-content-video-finder.cjs index a80443b..b9c5e3c 100644 --- a/scripts/test-content-video-finder.cjs +++ b/scripts/test-content-video-finder.cjs @@ -19,6 +19,14 @@ function extractFunction(name, text) { throw new Error(`${name} body did not terminate`); } +function makeSeekable(ranges = []) { + return { + length: ranges.length, + start(i) { return ranges[i][0]; }, + end(i) { return ranges[i][1]; } + }; +} + function makeVideo(name, width, height, options = {}) { return { name, @@ -28,7 +36,9 @@ function makeVideo(name, width, height, options = {}) { offsetWidth: width, offsetHeight: height, muted: options.muted ?? true, - duration: options.duration ?? 0 + duration: options.duration ?? 0, + currentTime: options.currentTime ?? 0, + seekable: options.seekable ?? makeSeekable() }; } @@ -61,4 +71,100 @@ assert.strictEqual( 'findVideo should score Shadow DOM videos together with light DOM videos' ); +function makeDocument(nodes = []) { + return { + querySelectorAll() { return nodes; } + }; +} + +function makeNode(attrs = {}, textContent = '') { + return { + textContent, + value: attrs.value, + max: attrs.max, + clicked: 0, + click() { this.clicked += 1; }, + getAttribute(name) { return attrs[name] ?? null; } + }; +} + +function loadTimelineFns(hostname, document = makeDocument()) { + return Function('window', 'document', [ + 'let lastDisneyPlusTimelineCandidates = [];', + extractFunction('hostMatchesUrl', source), + extractFunction('matchesPlayerUrls', source), + extractFunction('isDisneyPlusHost', source), + extractFunction('getSeekableRange', source), + extractFunction('parseClockTime', source), + extractFunction('parseTimelineText', source), + extractFunction('getDisneyPlusUiTimeline', source), + extractFunction('getElementLabel', source), + extractFunction('getDisneyPlusSeekButtonLabels', source), + extractFunction('clickDisneyPlusRelativeSeek', source), + extractFunction('getDisneyPlusTimeline', source), + extractFunction('getSiteQuirkAdapters', source), + extractFunction('getActiveSiteQuirk', source), + extractFunction('getSiteQuirkTimeline', source), + extractFunction('getSiteQuirkDebug', source), + extractFunction('getSyncCurrentTime', source), + extractFunction('getSyncDuration', source), + extractFunction('toNativeSeekTime', source), + 'return { getActiveSiteQuirk, getSyncCurrentTime, getSyncDuration, toNativeSeekTime, clickDisneyPlusRelativeSeek };' + ].join('\n'))({ location: { hostname } }, document); +} + +function loadPlayerFixFns(hostname) { + return Function('window', [ + extractFunction('hostMatchesUrl', source), + extractFunction('matchesPlayerUrls', source), + extractFunction('getPlayerActionFixes', source), + extractFunction('getActivePlayerActionFix', source), + 'return { getPlayerActionFixes, getActivePlayerActionFix };' + ].join('\n'))({ location: { hostname } }); +} + +const disneyUiDocument = makeDocument([ + makeNode({ 'aria-valuetext': '0:09 / 180:00' }) +]); +const disneyFns = loadTimelineFns('www.disneyplus.com', disneyUiDocument); +assert.equal(disneyFns.getActiveSiteQuirk().name, 'disneyplus-timeline-and-buttons'); +assert.deepEqual(disneyFns.getActiveSiteQuirk().urls, ['disneyplus.com']); +const disneyVideo = makeVideo('disney-offset', 1920, 1080, { + currentTime: 29, + duration: 0, + seekable: makeSeekable([[0, 32400]]) +}); +assert.equal(disneyFns.getSyncCurrentTime(disneyVideo), 9); +assert.equal(disneyFns.getSyncDuration(disneyVideo), 10800); +assert.equal(disneyFns.toNativeSeekTime(disneyVideo, 39), 119); + +const disneySeekableFallbackFns = loadTimelineFns('www.disneyplus.com'); +const disneyOffsetVideo = makeVideo('disney-offset', 1920, 1080, { + currentTime: 29, + duration: 0, + seekable: makeSeekable([[20, 10820]]) +}); +assert.equal(disneySeekableFallbackFns.getSyncCurrentTime(disneyOffsetVideo), 9); +assert.equal(disneySeekableFallbackFns.getSyncDuration(disneyOffsetVideo), 10800); +assert.equal(disneySeekableFallbackFns.toNativeSeekTime(disneyOffsetVideo, 39), 59); + +const genericFns = loadTimelineFns('example.com'); +assert.equal(genericFns.getActiveSiteQuirk(), null); +assert.equal(genericFns.getSyncCurrentTime(disneyVideo), 29); +assert.equal(genericFns.getSyncDuration(disneyVideo), 0); +assert.equal(genericFns.toNativeSeekTime(disneyVideo, 39), 39); + +const backButton = makeNode({ 'aria-label': '10 Sekunden zurück' }); +const forwardButton = makeNode({ 'aria-label': '10 Sekunden vorspulen' }); +const disneyButtonFns = loadTimelineFns('www.disneyplus.com', makeDocument([backButton, forwardButton])); +assert.equal(disneyButtonFns.clickDisneyPlusRelativeSeek(-30), true); +assert.equal(disneyButtonFns.clickDisneyPlusRelativeSeek(30), true); + +const twitchFixFns = loadPlayerFixFns('player.twitch.tv'); +assert.equal(twitchFixFns.getActivePlayerActionFix().name, 'twitch-player-buttons'); +assert.deepEqual(twitchFixFns.getActivePlayerActionFix().urls, ['twitch.tv']); + +const genericFixFns = loadPlayerFixFns('example.com'); +assert.equal(genericFixFns.getActivePlayerActionFix(), null); + console.log('content video finder tests passed'); diff --git a/website/assets/NewLogoIcon_64.webp b/website/assets/NewLogoIcon_64.webp index 660ffe28987821948744dafb8543121d3c9924ee..b1f4e43ede2c8f7304360593b419303eb296c0d2 100644 GIT binary patch delta 175 zcmV;g08sye3W5r-j{*uP3wTE6hMStHV6&M5(*b{}TcN{oo+V6*L_a%Yrc!p2D?t#c zOKs7r7~J4m$Dv1=mJaDq77GQQQ>rw7XYD@K>HoHNx9RHcyI5?-@+?sg|AU>&-G4md zD$=Kt1fjo2eO@GCUGUCe6T6+N5TM#$rGCu%%0C_&{K4-ZrketIuEE5-AGFN%Jt!V_ dA-)%z8o>$A#3xK?o>SG_`v#;TD2(v9006+KRrvq_ delta 175 zcmV;g08sye3W5r-j{*uRbh!h)p{C}lShJY|(*b{`0tbHB-=W1lvs8JmTg?5P$6NU| zO#PNrF}c9Ck3x?#EFIFIEEWqqr&MVE&)R*f)BkMkZ`0M?cCguv