/** * 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 = []; let lastKnownDisneyPlusDuration = 0; let lastKnownDisneyPlusScale = 1; let lastKnownDisneyPlusStart = 0; let lastDisneyPlusUiCurrent = null; let lastDisneyPlusNativeAtUi = null; 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 = querySelectorAllShadow(selectors).slice(0, 2000); const textParts = []; const candidates = []; let best = null; let bestScore = -1; let remainingSeconds = null; 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); } // Disney's scrubber aria-valuenow can lag several seconds behind // real playback; a "time remaining" indicator updates live, so // capture it and later derive current = duration - remaining. const remClass = String(node.getAttribute?.('class') || '') + ' ' + String(node.getAttribute?.('data-testid') || ''); if (/remain/i.test(remClass)) { const remMatch = String(node.textContent || '').match(/\d{1,3}:\d{2}(?::\d{2})?/); const rem = remMatch ? parseClockTime(remMatch[0]) : null; if (Number.isFinite(rem) && rem >= 0) remainingSeconds = rem; } } if (best && remainingSeconds !== null && best.duration > 0) { const liveCurrent = best.duration - remainingSeconds; if (liveCurrent >= 0 && liveCurrent <= best.duration + 5) { considerTimeline({ current: Math.min(liveCurrent, best.duration), duration: best.duration }, 'time-remaining', 1000); } } 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 querySelectorAllShadow('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 = querySelectorAllShadow('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') { 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; } // Fallback: Simulate ArrowLeft / ArrowRight keyboard events const clicks = Math.max(1, Math.min(12, Math.round(Math.abs(delta) / 10))); const eventInit = { key: backward ? 'ArrowLeft' : 'ArrowRight', code: backward ? 'ArrowLeft' : 'ArrowRight', keyCode: backward ? 37 : 39, which: backward ? 37 : 39, bubbles: true, cancelable: true, view: window }; const target = document.querySelector('.hive-video') || document.activeElement || document.body; for (let i = 0; i < clicks; i++) { setTimeout(() => { target.dispatchEvent(new window.KeyboardEvent('keydown', eventInit)); target.dispatchEvent(new window.KeyboardEvent('keyup', eventInit)); }, i * 60); } return true; } function getDisneyPlusTimeline(video) { if (!isDisneyPlusHost()) return null; const range = getSeekableRange(video); const current = video.currentTime; const ui = getDisneyPlusUiTimeline(); if (ui) { lastKnownDisneyPlusDuration = ui.duration; 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); lastKnownDisneyPlusScale = nativeScale; lastKnownDisneyPlusStart = nativeStart; lastDisneyPlusUiCurrent = ui.current; lastDisneyPlusNativeAtUi = Number.isFinite(current) ? current : null; return { ...(range || {}), current: ui.current, duration: ui.duration, nativeScale, nativeStart }; } // No live UI readout (control overlay hidden or mid-rebuild). Extrapolate // from the last live UI position using the native clock, which advances 1:1 // with real playback. Disney recreates the