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