mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-27 20:39:10 +00:00
bd60a14754
When no other peer is present, heartbeats, force-sync, and episode auto-sync are now fully suppressed (previously the keepAlive heartbeat, force-sync, and episode lobby were still broadcast to an empty room): - keepAlive heartbeat gated on a live peer count - force-sync is a no-op when solo (no pause-then-timeout freeze, no traffic) - episode auto-sync skips the lobby entirely and just plays the next episode The solo state is recomputed live from the current peer list on every event (never cached), so the instant another peer joins syncing resumes. On that solo->not-solo transition the background also requests an immediate heartbeat from the content script so the newcomer sees the current position without waiting for the next interval. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1140 lines
48 KiB
JavaScript
1140 lines
48 KiB
JavaScript
/**
|
|
* 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
|
|
|
|
// --- 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;
|
|
}
|
|
});
|
|
|
|
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');
|
|
|
|
// 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) {
|
|
expectedSeekTime = data.targetTime;
|
|
video.currentTime = data.targetTime;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (isTwitch) {
|
|
const twitchButton = document.querySelector('[data-a-target="player-play-pause-button"]');
|
|
if (twitchButton) {
|
|
const isCurrentlyPlaying = !video.paused;
|
|
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
|
|
_setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused');
|
|
twitchButton.click();
|
|
}
|
|
if (action === EVENTS.SEEK) {
|
|
expectedSeekTime = data.targetTime;
|
|
video.currentTime = data.targetTime;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Fallback for native HTML5
|
|
if (action === EVENTS.PLAY) {
|
|
_setSuppress('playing');
|
|
video.play().catch((e) => {
|
|
reportLog(`Playback prevented: ${e.message}`, 'warn');
|
|
_clearSuppress('playing');
|
|
});
|
|
} else if (action === EVENTS.PAUSE) {
|
|
_setSuppress('paused');
|
|
video.pause();
|
|
} else if (action === EVENTS.SEEK) {
|
|
expectedSeekTime = data.targetTime;
|
|
video.currentTime = data.targetTime;
|
|
}
|
|
} catch (e) {
|
|
reportLog(`Media Action Error: ${e.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// --- Helper: Wait until video is ready for playback (buffered & seeked) ---
|
|
function pollSeekReady(targetTime, timeoutMs = 8000) {
|
|
return new Promise((resolve) => {
|
|
const interval = 150;
|
|
let elapsed = 0;
|
|
const timer = setInterval(() => {
|
|
const video = findVideo(); // Re-query DOM on every iteration
|
|
if (!video) {
|
|
clearInterval(timer);
|
|
resolve(false);
|
|
return;
|
|
}
|
|
|
|
elapsed += interval;
|
|
const timeDiff = Math.abs(video.currentTime - targetTime);
|
|
const ready = video.readyState >= 3 && timeDiff < 2.0;
|
|
if (ready) {
|
|
clearInterval(timer);
|
|
resolve(true);
|
|
} else if (elapsed >= timeoutMs) {
|
|
clearInterval(timer);
|
|
resolve(false);
|
|
}
|
|
}, interval);
|
|
});
|
|
}
|
|
|
|
// Listen for commands from background.js
|
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
if (!message) 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;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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');
|
|
expectedSeekTime = payload.targetTime;
|
|
video.pause();
|
|
try {
|
|
video.currentTime = 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') {
|
|
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);
|
|
|
|
// 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);
|
|
}
|
|
video._koalaHandlers = { play: handlePlay, pause: handlePause, seeked: handleSeeked, loadeddata: handleLoadedData };
|
|
|
|
video.addEventListener('play', handlePlay);
|
|
video.addEventListener('pause', handlePause);
|
|
video.addEventListener('seeked', handleSeeked);
|
|
video.addEventListener('loadeddata', handleLoadedData);
|
|
video.dataset.koalaAttached = 'true';
|
|
lastVideoSrc = video.currentSrc || video.src || null;
|
|
|
|
if (!lastKnownMediaTitle) {
|
|
lastKnownMediaTitle = getMediaTitle();
|
|
}
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
})();
|