fix(extension): harden chat and target tab lifecycle

This commit is contained in:
Timo
2026-07-31 09:48:22 +02:00
parent 0b6ae803c8
commit 8050748e61
26 changed files with 776 additions and 226 deletions
+289 -131
View File
@@ -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') {