/** * KoalaSync Content Script * Injected into video tabs to control playback and detect events. */ (function() { // Injection Guard: Check if already injected AND context is valid try { if (window.koalaSyncInjected && chrome.runtime.id) { return; } } catch (_e) { // Context invalidated, proceed with re-injection } window.koalaSyncInjected = true; let destroyed = false; const lifecycleTimeouts = new Set(); const seekPollTimers = new Set(); const attachedVideos = new Set(); let activeVideo = null; function scheduleLifecycleTimeout(callback, delay) { const timer = setTimeout(() => { lifecycleTimeouts.delete(timer); if (!destroyed) callback(); }, delay); lifecycleTimeouts.add(timer); return timer; } function runtimeMessage(message, callback) { if (destroyed) return Promise.resolve(undefined); try { if (!chrome.runtime?.id) { destroyContentScript(); return Promise.resolve(undefined); } return chrome.runtime.sendMessage(message, callback) || Promise.resolve(undefined); } catch (_e) { destroyContentScript(); return Promise.resolve(undefined); } } const isEmbeddedContentFrame = (() => { try { return window.top !== window; } catch (_e) { return true; } })(); let mediaTargetRefreshTimeout = null; let mediaTargetRefreshBlockedUntil = 0; let lastMediaFrameVisible = window.innerWidth > 0 && window.innerHeight > 0; function requestMediaTargetRefresh(reason) { if (destroyed || mediaTargetRefreshTimeout || Date.now() < mediaTargetRefreshBlockedUntil) return; mediaTargetRefreshTimeout = scheduleLifecycleTimeout(() => { mediaTargetRefreshTimeout = null; mediaTargetRefreshBlockedUntil = Date.now() + 1500; runtimeMessage({ type: 'MEDIA_TARGET_REFRESH', reason }).catch(() => {}); }, 750); } function handleMediaFrameResize() { if (destroyed || !isEmbeddedContentFrame) return; const visible = window.innerWidth > 0 && window.innerHeight > 0; if (visible === lastMediaFrameVisible) return; lastMediaFrameVisible = visible; runtimeMessage({ type: 'MEDIA_FRAME_VISIBILITY', visible }).catch(() => {}); } if (isEmbeddedContentFrame) { window.addEventListener('resize', handleMediaFrameResize, { passive: true }); } // --- SHARED_EVENTS_INJECT_START --- // This block is automatically updated by /scripts/build-extension.cjs const EVENTS = { PLAY: "play", PAUSE: "pause", SEEK: "seek", FORCE_SYNC_PREPARE: "force_sync_prepare", FORCE_SYNC_ACK: "force_sync_ack", FORCE_SYNC_EXECUTE: "force_sync_execute", PEER_STATUS: "peer_status", EPISODE_LOBBY: "episode_lobby", EPISODE_READY: "episode_ready" }; // --- SHARED_EVENTS_INJECT_END --- // Suppresses native event reporting after a programmatic action. // Each entry is a per-type timer (key = 'playing'|'paused'|'seek'). // While a timer exists, matching native events are consumed and not relayed. // Timers self-clean after 300ms if the native event never fires. let _suppressTimers = {}; function _setSuppress(state) { if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]); _suppressTimers[state] = setTimeout(() => { delete _suppressTimers[state]; }, 300); } function _clearSuppress(state) { if (_suppressTimers[state]) { clearTimeout(_suppressTimers[state]); delete _suppressTimers[state]; } } // --- Seek Relay Filtering --- // Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks // from being relayed to peers as user-initiated seeks. const MIN_SEEK_DELTA = 2.0; let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK let seekDebounceTimer = null; // debounce timer for rapid seek events let expectedSeekTime = null; // strictly track programmatic seeks const PAGE_API_SEEK_BRIDGE = 1; // Accurate Disney+ playhead pushed by the MAIN-world page-API bridge // (background.js installPageApiSeekBridge). The isolated content world // can't read the page's media player directly. let disneyPageApiTime = null; function handlePageApiTime(event) { if (destroyed || event.source !== window) return; const data = event.data; if (data && data.__koalaPlayerTime === 1 && data.provider === 'disney' && Number.isFinite(data.position) && Number.isFinite(data.duration) && data.duration > 0) { disneyPageApiTime = { position: data.position, duration: data.duration, at: Date.now() }; } } if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { window.addEventListener('message', handlePageApiTime); } function hostMatchesUrl(host, url) { const normalized = String(url || '') .replace(/^https?:\/\//i, '') .split('/')[0] .toLowerCase(); return normalized && (host === normalized || host.endsWith(`.${normalized}`)); } function matchesPlayerUrls(urls) { const host = window.location.hostname.toLowerCase(); return Array.isArray(urls) && urls.some(url => hostMatchesUrl(host, url)); } function isDisneyPlusHost() { return matchesPlayerUrls(['disneyplus.com']); } function getDisneyPlusTimeline() { if (!isDisneyPlusHost()) return null; // Exact playhead/duration from the page media player, relayed by the // MAIN-world bridge. No Disney DOM scraping fallback. if (disneyPageApiTime && (Date.now() - disneyPageApiTime.at) < 2000 && disneyPageApiTime.duration > 0) { const cur = Math.max(0, Math.min(disneyPageApiTime.duration, disneyPageApiTime.position)); return { start: 0, end: disneyPageApiTime.duration, duration: disneyPageApiTime.duration, current: cur, nativeScale: 1, nativeStart: 0 }; } return null; } // Site-specific player exceptions live here. The default HTML5 path stays below. function getSiteQuirkAdapters() { return [{ name: 'disneyplus-page-api', key: 'disneyPlus', urls: ['disneyplus.com'], matches() { return matchesPlayerUrls(this.urls); }, getTimeline: getDisneyPlusTimeline, getDebug(video) { return { name: this.name, key: 'disneyPlus', urls: this.urls, timeline: getDisneyPlusTimeline(video) }; } }]; } function getActiveSiteQuirk() { return getSiteQuirkAdapters().find(adapter => adapter.matches()) || null; } function getSiteQuirkTimeline(video) { const adapter = getActiveSiteQuirk(); return adapter ? adapter.getTimeline(video) : null; } function getSiteQuirkDebug(video) { const adapter = getActiveSiteQuirk(); return adapter ? adapter.getDebug(video) : null; } function getSyncCurrentTime(video) { const siteTimeline = getSiteQuirkTimeline(video); if (siteTimeline) return siteTimeline.current; if (isDisneyPlusHost()) return null; const current = video.currentTime; return Number.isFinite(current) ? current : null; } function getSyncDuration(video) { const siteTimeline = getSiteQuirkTimeline(video); if (siteTimeline) return siteTimeline.duration; if (isDisneyPlusHost()) return 0; return Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0; } function toNativeSeekTime(video, targetTime) { if (!Number.isFinite(targetTime)) return targetTime; const siteTimeline = getSiteQuirkTimeline(video); if (!siteTimeline) return targetTime; const nativeTarget = siteTimeline.nativeStart + targetTime * siteTimeline.nativeScale; const max = Number.isFinite(siteTimeline.end) ? siteTimeline.end : nativeTarget; const min = Number.isFinite(siteTimeline.start) ? siteTimeline.start : 0; return Math.max(min, Math.min(max, nativeTarget)); } function shouldUsePageApiSeek() { return window.KOALA_PAGE_API_SEEK_ENABLED === true && typeof window.koalaFindPageApiSeekProvider === 'function' && !!window.koalaFindPageApiSeekProvider(window.location.hostname); } function seekVideo(video, targetTime) { // Prefer a precise page-level seek API when available (Netflix, Disney+); // for those players the DOM/button seek path is imprecise or impossible. if (shouldUsePageApiSeek()) { expectedSeekTime = targetTime; window.postMessage({ __koalaPageApiSeek: PAGE_API_SEEK_BRIDGE, kind: 'seek', time: targetTime }, '*'); return; } expectedSeekTime = targetTime; video.currentTime = toNativeSeekTime(video, targetTime); } // --- Play/Pause Coalescing (leading + trailing) --- // Media players (HLS/DASH, ad insertion, ABR/quality switches, source swaps, // page teardown) fire bursts of native play/pause events within a few hundred // ms. Relaying each as a distinct command spams peers and the relay. // // Strategy: emit the FIRST event immediately (leading edge → a deliberate // single play/pause has zero added latency), then hold a short window. If // more play/pause events arrive during the window it's a burst — we suppress // the intermediate churn and, once it settles, emit the FINAL state on the // trailing edge. // // We deliberately do NOT dedup the trailing send against the leading one. A // remote play/pause may be applied mid-window (e.g. I pause, peer plays, I // pause again within 150ms): the settled state then equals my last *sent* // state yet is a genuine change versus the now-shared state — suppressing it // would desync. Re-sending an unchanged state is a harmless no-op on peers, // so re-sending is always the safe choice. Echo-suppression and seek-flush // still run synchronously on event arrival (see reportEvent) — only the // network emit is governed here. const PLAY_PAUSE_COALESCE_MS = 150; let playPauseCoalesceTimer = null; // non-null = a coalescing window is open let pendingPlayPauseAction = null; // last play/pause seen during the window, awaiting trailing flush let pendingPlayPauseVideo = null; // source element for rejecting a stale trailing flush // --- Episode Auto-Sync State --- let lastKnownMediaTitle = null; let episodeTransitionDebounce = null; let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby) let lobbyPollTimer = null; let _autoSyncEnabled = true; // Cached setting, updated via storage.onChanged let _audioSettings = null; let _audioProcessingAllowed = true; // Cache the autoSyncNextEpisode setting (local-only; never read from sync) chrome.storage.local.get(['autoSyncNextEpisode', 'audioSettings'], (data) => { if (destroyed) return; _autoSyncEnabled = data.autoSyncNextEpisode !== false; _audioSettings = mergeAudioSettings(data.audioSettings); const video = findVideo(); if (video && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings); }); function handleStorageChanged(changes, area) { if (destroyed) return; if (area === 'local' && changes.autoSyncNextEpisode) { _autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false; } if (area === 'local' && changes.audioSettings) { _audioSettings = mergeAudioSettings(changes.audioSettings.newValue); const video = findVideo(); if (video && _audioProcessingAllowed) applyAudioSettings(video, _audioSettings); } } chrome.storage.onChanged.addListener(handleStorageChanged); // --- Host Control Mode (guest-side) --- // When a room is in 'host-only' mode and we're a guest, a deliberate local // pause/seek must not drive the room (background/server already drop it). Here // we handle the *local* UX: snap back to the host's position, or — if the user // really wants to — let them go solo (desync) with a resync escape hatch. let hcmControlMode = 'everyone'; // mirror of room control mode let hcmAmController = false; // are we allowed to drive (owner or co-host)? let hcmHostPeerId = null; // last known host peerId (room/host identity) let hcmDesynced = false; // user chose to go solo let hcmSnapBackCooldownUntil = 0; // suppress re-trigger right after a snap-back let hcmDeferredSnapPending = false; // a buffer-aware snap-back is waiting for readiness let hcmLastUserGestureAt = 0; // for deliberate-vs-involuntary classification let hcmBufferingUntil = 0; // set on 'waiting' — buffering grace window let hcmDialogTimer = null; // 8s auto-stay timer — cleared on dialog replace (H-4) let hcmBadgeDomReadyHandler = null; // Localized strings for the in-page dialog/badge (content has no i18n loader; // background resolves them via GET_HCM_STRINGS on init). English fallbacks here. const hcmStrings = { title: 'KoalaSync · Host controls this room', body: 'Only the host can control playback in this room. Keep watching together, or watch on your own?', stay: 'Stay in sync', solo: 'Watch on my own', badge: 'Watching on your own', resync: 'Resync' }; const HCM_USER_GESTURE_MS = 1000; const HCM_BUFFERING_GRACE_MS = 1500; const HCM_SNAP_BACK_COOLDOWN_MS = 1000; const HCM_BUFFER_WAIT_MS = 8000; // cap on waiting for a buffering player before snapping anyway // Track genuine user input so we can tell a deliberate pause/seek from a // player-/browser-initiated one. Capturing + passive so we never interfere. const _hcmGesture = () => { hcmLastUserGestureAt = Date.now(); }; document.addEventListener('keydown', _hcmGesture, { capture: true, passive: true }); document.addEventListener('pointerdown', _hcmGesture, { capture: true, passive: true }); function hcmIsGuestGated() { return hcmControlMode === 'host-only' && !hcmAmController; } // EC-9 intent classifier: only a *clearly deliberate* guest action triggers the // dialog/snap-back. Anything that smells involuntary (buffering, seeking, tab // refocus, no recent gesture) is treated as involuntary. Bias intentional — // in host-only the guest never broadcasts anyway, so this only tunes UX. function hcmClassifyIntent() { const video = findVideo(); if (!video) return 'involuntary'; if (hcmIsLive(video)) return 'live'; // EC-15: degrade, don't gate if (video.readyState < 3) return 'involuntary'; // buffering / not enough data if (video.seeking) return 'involuntary'; if (Date.now() < hcmBufferingUntil) return 'involuntary'; if (Date.now() < visibilityGraceUntil) return 'involuntary'; if (Date.now() - hcmLastUserGestureAt > HCM_USER_GESTURE_MS) return 'involuntary'; return 'deliberate'; } // Live detection (EC-15 + DVR). Pure live reports duration Infinity/NaN. Live-DVR // (Twitch/YouTube-live with rewind) reports a *finite, sliding* duration — its // seekable window doesn't start at 0, which we use as the DVR signal. function hcmIsLive(video) { // Don't trust duration before metadata has loaded (readyState >= 1) — // otherwise pre-loaded videos report NaN and get misclassified as live, // which suppresses the desync dialog (L-2). if (video.readyState < 1) return false; if (!isDisneyPlusHost() && !Number.isFinite(video.duration)) return true; try { const s = video.seekable; if (s && s.length > 0 && s.start(0) > 1) return true; // sliding DVR window } catch (_e) { /* seekable may throw if empty */ } return false; } // Snap the local player back to the host's current position/state. function hcmSnapBackToHost(target) { if (hcmDesynced) return; // user opted out — never yank them back automatically hcmSnapBackCooldownUntil = Date.now() + HCM_SNAP_BACK_COOLDOWN_MS; const video = findVideo(); if (!video) return; if (target && Number.isFinite(target.targetTime)) { tryMediaAction(EVENTS.SEEK, { targetTime: target.targetTime }); } // Adopt the host's play/pause state — but ONLY if we actually know it. // Defaulting to PLAY when the state is unknown would auto-resume a paused // video against the host's real state (H-1). if (target && target.playbackState === 'paused') { tryMediaAction(EVENTS.PAUSE); } else if (target && target.playbackState === 'playing') { tryMediaAction(EVENTS.PLAY); } reportLog('Host-only: snapped back to host position', 'info'); } // Resync to the host's current position, retrying briefly if the host's state // isn't known yet (e.g. they just paused and no heartbeat has propagated) — // otherwise the request is a silent no-op and the user thinks they're synced // when they aren't. Shared by the dialog's "Stay in sync" and the Resync badge. function hcmRequestHostSyncWithRetry() { let attempts = 0; const tryOnce = () => { runtimeMessage({ type: 'REQUEST_HOST_SYNC' }, (res) => { if (chrome.runtime.lastError || !res || !res.target) { if (++attempts < 5) scheduleLifecycleTimeout(tryOnce, 250); else reportLog('Host-only: resync requested but host state unavailable', 'warn'); return; } hcmSnapBackToHost(res.target); }); }; tryOnce(); } // Buffer-aware snap-back (#3). An involuntary pause/seek often coincides with the // player buffering — and a player can't actually play while it's stalled. Snapping // immediately just fights the buffer (seek → re-buffer → another pause → …), which // looks like stutter. Instead: if the player isn't ready, wait until it can play // (readyState>=3, not seeking), then snap ONCE to the host's *current* position // (re-queried, since the captured target may be stale by the time buffering ends). // Player-agnostic on purpose — we can't enumerate every site, so this must be safe // regardless of whether a given player fires 'pause' or only 'waiting'. function hcmDeferredSnapBack() { if (hcmDeferredSnapPending) return; // already waiting — don't stack polls hcmDeferredSnapPending = true; const deadline = Date.now() + HCM_BUFFER_WAIT_MS; const poll = () => { // Abort if the reason to snap is gone: user went solo, we're no longer a // gated guest, or the video vanished. if (hcmDesynced || !hcmIsGuestGated()) { hcmDeferredSnapPending = false; return; } const video = findVideo(); const ready = video && video.readyState >= 3 && !video.seeking; if (ready || Date.now() >= deadline) { hcmDeferredSnapPending = false; hcmRequestHostSyncWithRetry(); // fresh host position + snap once return; } scheduleLifecycleTimeout(poll, 300); }; poll(); } // Entry point: background told us our local action was blocked in host-only. function hcmHandleBlocked(action, target) { // HOST_BLOCKED is only ever sent to a gated guest (background verifies // host-only + !host before sending), so it's authoritative. Adopt the // role/mode from it in case our CONTROL_MODE broadcast hasn't landed yet // (join race, EC-5) — otherwise we'd miss the dialog/snap-back. hcmControlMode = 'host-only'; hcmAmController = false; if (hcmDesynced) return; // already solo, nothing to do const intent = hcmClassifyIntent(); if (intent === 'live') return; // EC-15: leave the guest alone on live if (intent === 'involuntary') { // EC-4 loop guard: only the silent auto snap-back is suppressed by the // cooldown — the deliberate dialog path below must still go through, // otherwise a second deliberate pause inside the cooldown window leaves // the user stuck paused with no UI (M-3). if (Date.now() < hcmSnapBackCooldownUntil || hcmDeferredSnapPending) return; // Buffering/ads/throttle — silently re-sync, no dialog spam. const video = findVideo(); if (video && video.readyState >= 3 && !video.seeking) { // Ready now → snap immediately. Use the captured target if it's // usable, otherwise re-query+retry (host state may not be known yet) // so we never leave the guest silently stuck (consistent with the // deferred and "Stay in sync" paths). if (target && Number.isFinite(target.targetTime)) hcmSnapBackToHost(target); else hcmRequestHostSyncWithRetry(); } else { hcmDeferredSnapBack(); // buffering → wait for ready, then snap once (#3) } return; } // Deliberate: offer the choice (Teleparty-style), default = snap back. hcmShowDesyncDialog(action, target); } // --- In-page UI (dialog + persistent desync badge) --- // Built with the DOM API (CSSOM .style is CSP-safe; inline style="" in innerHTML // is stripped by strict style-src on Netflix/YouTube/Disney+). Hosted in a // Shadow DOM so the page's CSS can't restyle or hide our controls. let hcmDialogHost = null; // shadow host element for the dialog let hcmBadgeHost = null; // shadow host element for the persistent badge let hcmBadgePending = false; // retry flag for early-injection badge creation (L-4) function hcmEl(tag, css, text) { const el = document.createElement(tag); if (css) el.style.cssText = css; // CSSOM assignment — not gated by CSP if (text != null) el.textContent = text; return el; } function hcmRemoveDialog() { // Cancel any pending auto-stay timer so a replaced dialog's stale closure // can't later remove its successor / snap to an outdated target (H-4). if (hcmDialogTimer) { clearTimeout(hcmDialogTimer); hcmDialogTimer = null; } if (hcmDialogHost) { hcmDialogHost.remove(); hcmDialogHost = null; } } function hcmShowDesyncDialog(action, target) { if (!document.body) { hcmSnapBackToHost(target); return; } hcmRemoveDialog(); const host = hcmEl('div', 'all:initial'); const root = host.attachShadow({ mode: 'open' }); const wrap = hcmEl('div', 'position:fixed;z-index:2147483647;left:50%;bottom:32px;transform:translateX(-50%);background:#212a17;color:#f4f2ea;font:14px/1.4 system-ui,sans-serif;padding:16px 18px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.45);max-width:360px;border:1px solid #2f3625'); wrap.setAttribute('role', 'dialog'); const title = hcmEl('div', 'font-weight:600;margin-bottom:6px', hcmStrings.title); const body = hcmEl('div', 'margin-bottom:12px;color:#bcb7a9', hcmStrings.body); const btnRow = hcmEl('div', 'display:flex;gap:8px;justify-content:flex-end'); const soloBtn = hcmEl('button', 'background:#2f3625;color:#f4f2ea;border:0;padding:8px 12px;border-radius:8px;cursor:pointer', hcmStrings.solo); const stayBtn = hcmEl('button', 'background:#56ae6c;color:#0a1a0d;border:0;padding:8px 12px;border-radius:8px;cursor:pointer;font-weight:600', hcmStrings.stay); btnRow.append(soloBtn, stayBtn); wrap.append(title, body, btnRow); root.appendChild(wrap); document.body.appendChild(host); hcmDialogHost = host; let settled = false; // Re-query the host's current position on click instead of using the // potentially stale target captured at HOST_BLOCKED time (M-1). const stay = () => { if (settled) return; settled = true; hcmRemoveDialog(); hcmRequestHostSyncWithRetry(); }; const solo = () => { if (settled) return; settled = true; hcmRemoveDialog(); hcmEnterDesync(); }; stayBtn.addEventListener('click', stay); soloBtn.addEventListener('click', solo); // EC-18: if the user ignores the prompt, default to staying in sync. hcmDialogTimer = setTimeout(() => { if (!settled) stay(); }, 8000); } function hcmEnterDesync() { hcmDesynced = true; reportLog('Host-only: you chose to watch on your own (desynced)', 'warn'); // Notify background so it can relay our desynced state to the host via // heartbeats — the host's UI then knows we're not following commands // instead of appearing silently un-ACK'd. runtimeMessage({ type: 'HCM_DESYNC_STATE', desynced: true }).catch(() => {}); hcmShowBadge(); } function hcmExitDesync() { const wasDesynced = hcmDesynced; hcmDesynced = false; hcmRemoveBadge(); if (wasDesynced) { runtimeMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {}); } // Resync to the host's current position (retries if host state not yet known). hcmRequestHostSyncWithRetry(); reportLog('Host-only: resynced with the host', 'info'); } function hcmShowBadge() { if (hcmBadgeHost) return; if (!document.body) { // Body not ready yet (very early injection). Defer until DOMReady, // otherwise the desynced user silently never sees the badge (L-4). if (!hcmBadgePending) { hcmBadgePending = true; const retry = () => { hcmBadgeDomReadyHandler = null; hcmBadgePending = false; if (hcmDesynced && !hcmBadgeHost) hcmShowBadge(); }; if (document.readyState === 'loading') { hcmBadgeDomReadyHandler = retry; document.addEventListener('DOMContentLoaded', retry, { once: true }); } else { scheduleLifecycleTimeout(retry, 50); } } return; } const host = hcmEl('div', 'all:initial'); const root = host.attachShadow({ mode: 'open' }); const b = hcmEl('div', 'position:fixed;z-index:2147483646;right:16px;bottom:16px;background:#c96736;color:#fff;font:13px/1.3 system-ui,sans-serif;padding:8px 12px;border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.4);cursor:pointer;display:flex;align-items:center;gap:8px'); b.append(hcmEl('span', null, '● ' + hcmStrings.badge), hcmEl('span', 'text-decoration:underline', hcmStrings.resync)); b.addEventListener('click', hcmExitDesync); root.appendChild(b); document.body.appendChild(host); hcmBadgeHost = host; } function hcmRemoveBadge() { if (hcmBadgeHost) { hcmBadgeHost.remove(); hcmBadgeHost = null; } } function hcmReset() { const wasDesynced = hcmDesynced; hcmDesynced = false; hcmDeferredSnapPending = false; hcmSnapBackCooldownUntil = 0; // don't let a stale cooldown swallow the next snap-back hcmBufferingUntil = 0; hcmRemoveDialog(); hcmRemoveBadge(); // If we were desynced, notify background so it stops reporting us as // desynced in heartbeats (otherwise the host's UI keeps showing the // stale Solo badge until the next state change). if (wasDesynced) { runtimeMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {}); } } function reportLog(message, level = 'info') { runtimeMessage({ type: 'LOG', message, level }).catch(() => {}); } // --- Helper: find the best video element on the page --- // Ranks candidates by a fixed order of signals rather than a weighted sum, // so a disqualifying trait (no source, not rendered) can never be outvoted // by sheer size. See pickBestVideo for the order and the reasoning. function findVideo(root = document, depth = 0) { return pickBestVideo(collectVideoCandidates(root, depth)); } function collectVideoCandidates(root = document, depth = 0, out = []) { for (const video of root.querySelectorAll('video')) out.push(video); // Scan likely media hosts even when light-DOM videos exist; many players // expose a tiny preview/ad video outside Shadow DOM and the real player inside. const potentialHosts = root.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player'); for (const el of potentialHosts) { if (el.shadowRoot) collectVideoCandidates(el.shadowRoot, depth, out); } // Same-origin player frames (jkanime.net and similar) keep the real //