mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-30 12:29:27 +00:00
fix(extension): harden chat and target tab lifecycle
This commit is contained in:
+289
-131
@@ -12,7 +12,34 @@
|
||||
|
||||
// Injection Guard: Check if already injected AND context is valid
|
||||
|
||||
try {
|
||||
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();
|
||||
|
||||
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);
|
||||
@@ -62,15 +89,16 @@
|
||||
|
||||
// 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) {
|
||||
// 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 = {};
|
||||
|
||||
|
||||
|
||||
|
||||
if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]);
|
||||
|
||||
_suppressTimers[state] = setTimeout(() => {
|
||||
@@ -210,17 +238,20 @@
|
||||
if (!Number.isFinite(targetTime)) return targetTime;
|
||||
const siteTimeline = getSiteQuirkTimeline(video);
|
||||
if (!siteTimeline) return targetTime;
|
||||
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() {
|
||||
|
||||
function shouldUsePageApiSeek() {
|
||||
return window.KOALA_PAGE_API_SEEK_ENABLED === true &&
|
||||
typeof window.koalaFindPageApiSeekProvider === 'function' &&
|
||||
!!window.koalaFindPageApiSeekProvider(window.location.hostname);
|
||||
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;
|
||||
@@ -234,8 +265,9 @@
|
||||
|
||||
// --- 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.
|
||||
|
||||
@@ -320,9 +352,9 @@
|
||||
|
||||
// --- 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
|
||||
|
||||
@@ -355,7 +387,7 @@
|
||||
|
||||
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',
|
||||
|
||||
@@ -457,7 +489,7 @@
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Snap the local player back to the host's current position/state.
|
||||
|
||||
@@ -466,7 +498,7 @@
|
||||
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;
|
||||
@@ -480,14 +512,16 @@
|
||||
// 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
|
||||
if (target && Number.isFinite(target.targetTime)) {
|
||||
|
||||
tryMediaAction(EVENTS.SEEK, { targetTime: target.targetTime });
|
||||
|
||||
}
|
||||
|
||||
|
||||
// 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);
|
||||
|
||||
}
|
||||
|
||||
@@ -518,12 +552,12 @@
|
||||
else reportLog('Host-only: resync requested but host state unavailable', 'warn');
|
||||
|
||||
return;
|
||||
let attempts = 0;
|
||||
|
||||
}
|
||||
|
||||
hcmSnapBackToHost(res.target);
|
||||
|
||||
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
@@ -786,7 +820,7 @@
|
||||
hcmShowBadge();
|
||||
|
||||
}
|
||||
hcmDesynced = true;
|
||||
|
||||
|
||||
|
||||
function hcmExitDesync() {
|
||||
@@ -810,7 +844,7 @@
|
||||
|
||||
}
|
||||
|
||||
hcmRemoveBadge();
|
||||
|
||||
|
||||
function hcmShowBadge() {
|
||||
|
||||
@@ -923,36 +957,52 @@
|
||||
|
||||
// Scan likely media hosts even when light-DOM videos exist; many players
|
||||
|
||||
|
||||
|
||||
|
||||
function reportLog(message, level = 'info') {
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'LOG', message, level }).catch(() => {});
|
||||
|
||||
}
|
||||
|
||||
|
||||
// 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
|
||||
|
||||
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');
|
||||
|
||||
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;
|
||||
@@ -1019,7 +1069,7 @@
|
||||
|
||||
...safeSettings,
|
||||
|
||||
|
||||
compressor: {
|
||||
|
||||
...DEFAULT_AUDIO_SETTINGS.compressor,
|
||||
|
||||
@@ -1037,7 +1087,7 @@
|
||||
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1045,15 +1095,15 @@
|
||||
|
||||
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');
|
||||
|
||||
@@ -1071,7 +1121,7 @@
|
||||
|
||||
return audioCtx;
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1082,7 +1132,7 @@
|
||||
audioCtx.close().catch(() => {});
|
||||
|
||||
audioCtx = null;
|
||||
if (!audioCtx) {
|
||||
|
||||
}
|
||||
|
||||
audioChains = new WeakMap();
|
||||
@@ -1265,14 +1315,16 @@
|
||||
|
||||
: 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"
|
||||
|
||||
chain.compressor.release.value = params.release ?? 0.300;
|
||||
|
||||
// Returns null if no episode pattern found.
|
||||
|
||||
// --- SHARED_EPISODE_UTILS_INJECT_START ---
|
||||
|
||||
// This block is automatically replaced by /scripts/build-extension.cjs
|
||||
@@ -1280,7 +1332,7 @@
|
||||
|
||||
if (!title || typeof title !== 'string') return null;
|
||||
|
||||
rampGain(chain.dryGain, 0, t);
|
||||
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')}`;
|
||||
|
||||
@@ -1308,8 +1360,9 @@
|
||||
|
||||
if (idA || idB) return false;
|
||||
|
||||
// Extract a canonical episode identifier from a title string.
|
||||
|
||||
return titleA === titleB;
|
||||
|
||||
}
|
||||
|
||||
// --- SHARED_EPISODE_UTILS_INJECT_END ---
|
||||
|
||||
@@ -1373,42 +1426,64 @@
|
||||
|
||||
function onEpisodeTransition(newTitle) {
|
||||
|
||||
return idA !== idB; // Both parseable → only block if different
|
||||
// Debounce: prevent duplicate fires from multiple signals
|
||||
|
||||
if (episodeTransitionDebounce) return;
|
||||
|
||||
episodeTransitionDebounce = setTimeout(() => {
|
||||
function checkEpisodeTransition() {
|
||||
|
||||
const currentTitle = getMediaTitle();
|
||||
|
||||
|
||||
episodeTransitionDebounce = null;
|
||||
|
||||
}, 2000);
|
||||
|
||||
|
||||
|
||||
reportLog(`Episode transition detected: "${newTitle}"`, 'info');
|
||||
|
||||
|
||||
|
||||
// EC-12: a new episode dissolves any solo/desync state — the guest rejoins
|
||||
|
||||
// the room for the fresh content rather than staying stuck on the old one.
|
||||
|
||||
if (hcmDesynced) hcmReset();
|
||||
const current = video ? getSyncCurrentTime(video) : null;
|
||||
// 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)
|
||||
&& extractEpisodeId(currentTitle) !== null
|
||||
|
||||
|
||||
|
||||
// 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.
|
||||
|
||||
runtimeMessage({
|
||||
type: 'EPISODE_CHANGED',
|
||||
|
||||
payload: { newTitle }
|
||||
|
||||
}).catch(() => {});
|
||||
|
||||
}
|
||||
|
||||
onEpisodeTransition(currentTitle);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Always track the latest known title
|
||||
|
||||
if (currentTitle) lastKnownMediaTitle = currentTitle;
|
||||
|
||||
|
||||
function checkAndReportLobbyReady(expectedTitle) {
|
||||
|
||||
const video = findVideo();
|
||||
|
||||
const currentTitle = getMediaTitle();
|
||||
|
||||
const current = video ? getSyncCurrentTime(video) : null;
|
||||
if (video && currentTitle && sameEpisode(currentTitle, expectedTitle)
|
||||
|
||||
&& current !== null && video.readyState >= 1) {
|
||||
|
||||
if (current >= 5) {
|
||||
expectedSeekTime = 0;
|
||||
video.currentTime = 0;
|
||||
}
|
||||
// Match! Pause at start and report ready.
|
||||
|
||||
if (!video.paused) {
|
||||
|
||||
_setSuppress('paused');
|
||||
|
||||
video.pause();
|
||||
@@ -1475,8 +1550,9 @@
|
||||
}
|
||||
|
||||
|
||||
|
||||
video.pause();
|
||||
|
||||
|
||||
|
||||
function stopLobbyPoll() {
|
||||
|
||||
_pendingLobbyTitle = null;
|
||||
@@ -1492,9 +1568,10 @@
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
function getPlayerActionFixes() {
|
||||
return [
|
||||
{
|
||||
name: 'youtube-player-buttons',
|
||||
urls: ['youtube.com'],
|
||||
playPauseButtonSelector: '.ytp-play-button'
|
||||
@@ -1509,8 +1586,9 @@
|
||||
|
||||
function getActivePlayerActionFix() {
|
||||
return getPlayerActionFixes().find(fix => matchesPlayerUrls(fix.urls)) || null;
|
||||
|
||||
// NOTE: Do NOT pause here. Three callers reach this function:
|
||||
}
|
||||
|
||||
function tryPlayerActionFix(fix, action, video, data) {
|
||||
if (!fix) return false;
|
||||
const button = document.querySelector(fix.playPauseButtonSelector);
|
||||
if (!button) return false;
|
||||
@@ -1532,8 +1610,9 @@
|
||||
if (!video) return;
|
||||
|
||||
|
||||
lobbyPollTimer = setInterval(() => {
|
||||
|
||||
if (action === EVENTS.SEEK) {
|
||||
|
||||
const target = data ? (data.targetTime !== undefined ? data.targetTime : data.currentTime) : undefined;
|
||||
|
||||
if (!Number.isFinite(target)) {
|
||||
|
||||
@@ -1552,12 +1631,13 @@
|
||||
const actionFix = getActivePlayerActionFix();
|
||||
if (tryPlayerActionFix(actionFix, action, video, data)) {
|
||||
return;
|
||||
lobbyPollTimer = null;
|
||||
|
||||
}
|
||||
|
||||
// Fallback for native HTML5
|
||||
if (action === EVENTS.PLAY) {
|
||||
|
||||
_setSuppress('playing');
|
||||
|
||||
|
||||
video.play().catch((e) => {
|
||||
|
||||
reportLog(`Playback prevented: ${e.message}`, 'warn');
|
||||
@@ -1579,18 +1659,22 @@
|
||||
} catch (e) {
|
||||
|
||||
reportLog(`Media Action Error: ${e.message}`, 'error');
|
||||
function tryPlayerActionFix(fix, action, video, data) {
|
||||
if (!fix) return false;
|
||||
const button = document.querySelector(fix.playPauseButtonSelector);
|
||||
if (!button) return false;
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- Helper: Wait until video is ready for playback (buffered & seeked) ---
|
||||
|
||||
function pollSeekReady(targetTime, timeoutMs = 8000) {
|
||||
button.click();
|
||||
}
|
||||
if (action === EVENTS.SEEK) {
|
||||
seekVideo(video, data.targetTime, data.delta);
|
||||
return new Promise((resolve) => {
|
||||
const interval = 150;
|
||||
let elapsed = 0;
|
||||
const timer = setInterval(() => {
|
||||
if (destroyed) {
|
||||
clearInterval(timer);
|
||||
seekPollTimers.delete(timer);
|
||||
resolve(false);
|
||||
return;
|
||||
@@ -1599,30 +1683,104 @@
|
||||
if (!video) {
|
||||
clearInterval(timer);
|
||||
seekPollTimers.delete(timer);
|
||||
|
||||
|
||||
if (action === EVENTS.SEEK) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if (!Number.isFinite(target)) {
|
||||
|
||||
reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error');
|
||||
elapsed += interval;
|
||||
|
||||
const current = getSyncCurrentTime(video);
|
||||
const timeDiff = current !== null ? Math.abs(current - targetTime) : Infinity;
|
||||
const ready = video.readyState >= 3 && timeDiff < 2.0;
|
||||
if (ready) {
|
||||
|
||||
}
|
||||
|
||||
data = { ...data, targetTime: target };
|
||||
clearInterval(timer);
|
||||
seekPollTimers.delete(timer);
|
||||
resolve(true);
|
||||
} else if (elapsed >= timeoutMs) {
|
||||
clearInterval(timer);
|
||||
seekPollTimers.delete(timer);
|
||||
resolve(false);
|
||||
}
|
||||
}, interval);
|
||||
seekPollTimers.add(timer);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Listen for commands from background.js
|
||||
|
||||
function handleRuntimeMessage(message, sender, sendResponse) {
|
||||
if (!message) return;
|
||||
if (message.type === 'TARGET_DEACTIVATE') {
|
||||
destroyContentScript();
|
||||
sendResponse({ ok: true });
|
||||
return true;
|
||||
}
|
||||
if (destroyed) return;
|
||||
if (message.action === 'get_current_time') {
|
||||
const video = findVideo();
|
||||
sendResponse({ currentTime: video ? getSyncCurrentTime(video) : null });
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
if (message.action === 'APPLY_AUDIO_SETTINGS') {
|
||||
|
||||
_audioProcessingAllowed = true;
|
||||
|
||||
_audioSettings = mergeAudioSettings(message.settings);
|
||||
|
||||
const video = findVideo();
|
||||
|
||||
if (video) applyAudioSettings(video, _audioSettings);
|
||||
|
||||
sendResponse({ ok: true });
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (message.action === 'RESET_AUDIO_PROCESSING') {
|
||||
|
||||
_audioProcessingAllowed = false;
|
||||
|
||||
bypassCurrentAudioProcessing();
|
||||
|
||||
sendResponse({ ok: true });
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Host Control Mode: room mode/role changed.
|
||||
|
||||
if (message.type === 'CONTROL_MODE') {
|
||||
|
||||
const wasGated = hcmIsGuestGated();
|
||||
|
||||
const prevHostPeerId = hcmHostPeerId;
|
||||
|
||||
hcmControlMode = message.controlMode || 'everyone';
|
||||
|
||||
hcmAmController = !!message.amController;
|
||||
|
||||
hcmHostPeerId = message.hostPeerId || null;
|
||||
|
||||
// Reset guest-side state when leaving the gated state, OR when the
|
||||
|
||||
// host identity changes (room switch, host-leave fallback, missed
|
||||
|
||||
try {
|
||||
// teardown broadcast) — clears stale desync so a rejoin starts clean (H-3).
|
||||
|
||||
const hostChanged = prevHostPeerId !== null && hcmHostPeerId !== prevHostPeerId;
|
||||
return;
|
||||
}
|
||||
|
||||
if ((wasGated && !hcmIsGuestGated()) || hostChanged) hcmReset();
|
||||
|
||||
sendResponse({ ok: true });
|
||||
|
||||
@@ -1631,8 +1789,8 @@
|
||||
}
|
||||
|
||||
|
||||
reportLog(`Playback prevented: ${e.message}`, 'warn');
|
||||
|
||||
|
||||
// Host Control Mode: background blocked our local action — handle UX locally.
|
||||
|
||||
if (message.type === 'HOST_BLOCKED') {
|
||||
|
||||
@@ -1647,8 +1805,8 @@
|
||||
|
||||
|
||||
// Background asks for an immediate state push (e.g. the first peer just
|
||||
} catch (e) {
|
||||
|
||||
|
||||
// joined while we were solo) so the newcomer syncs without waiting.
|
||||
|
||||
if (message.type === 'REQUEST_HEARTBEAT') {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user