fix(sync): harden episode transition compatibility

This commit is contained in:
Timo
2026-09-01 07:08:39 +02:00
parent 742386064c
commit dc7eb86ce6
14 changed files with 1625 additions and 220 deletions
+242 -28
View File
@@ -1,7 +1,7 @@
import { EVENTS, ERROR_CODES, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
import { EVENTS, ERROR_CODES, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, EPISODE_SYNC_V2_LOAD_TIMEOUT, EPISODE_SYNC_V2_PREPARE_TIMEOUT, EPISODE_SYNC_V2_EXECUTE_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
import { generateUsername } from './shared/names.js';
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
import { sameEpisode, sameEpisodeStrict, extractEpisodeId } from './episode-utils.js';
import { createEpisodeWireIdentity, createLocalEpisodeDeadline, extractEpisodeId, isEpisodeSyncV2StartContextCurrent, matchesEpisodeSyncV2StartRejection, sameEpisode, sameEpisodeIdentity, toEpisodeWireTitle } from './episode-utils.js';
import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js';
import { initTabManager } from './modules/tab-manager.js';
import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js';
@@ -677,6 +677,8 @@ let forceSyncTimeout = null;
let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt }
let episodeLobbyTimeout = null;
let episodeSyncV2 = null;
let episodeSyncV2PendingStart = null;
let episodeSyncV2PendingStartId = 0;
// --- Storage Utils ---
@@ -818,7 +820,9 @@ function emitEpisodeLobbyForCurrentPrivacy() {
if (episodeLobby !== lobby
|| currentRoom?.roomId !== roomId
|| settings.roomId !== roomId) return;
const expectedTitle = sanitizeSharedTitle(lobby.expectedTitle, settings.mediaTitlePrivacyMode);
const expectedTitle = toEpisodeWireTitle(
sanitizeSharedTitle(lobby.expectedTitle, settings.mediaTitlePrivacyMode)
);
if (expectedTitle) {
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle });
}
@@ -1217,10 +1221,13 @@ function normalizeEpisodeSyncV2(value, allowedPeerIds = null) {
const transactionId = typeof value.transactionId === 'string'
? value.transactionId.substring(0, 64)
: '';
const phase = value.phase === 'lobby' || value.phase === 'prepare' ? value.phase : '';
const expectedTitle = typeof value.expectedTitle === 'string'
? value.expectedTitle.substring(0, 100)
: '';
const phase = value.phase === 'lobby' || value.phase === 'prepare'
? value.phase
: (value.phase === 'execute' || value.phase === 'executing' ? 'execute' : '');
const expectedTitle = toEpisodeWireTitle(value.expectedTitle) || '';
const expectedEpisodeId = typeof value.expectedEpisodeId === 'string'
? value.expectedEpisodeId.substring(0, 16)
: extractEpisodeId(expectedTitle);
const initiatorPeerId = typeof value.initiatorPeerId === 'string'
? value.initiatorPeerId.substring(0, 16)
: '';
@@ -1238,20 +1245,117 @@ function normalizeEpisodeSyncV2(value, allowedPeerIds = null) {
if (participants.length < 2
|| !participantSet.has(initiatorPeerId)
|| (allowedPeerIds && participants.some(candidate => !allowedPeerIds.has(candidate)))) return null;
const phaseTimeout = phase === 'lobby'
? EPISODE_SYNC_V2_LOAD_TIMEOUT
: (phase === 'prepare' ? EPISODE_SYNC_V2_PREPARE_TIMEOUT : EPISODE_SYNC_V2_EXECUTE_TIMEOUT);
// The relay's wall clock is not comparable to the browser's. Convert its
// bounded remaining duration into a local deadline. Older v2 relays omit
// remainingMs; a full local phase timeout is safer than trusting clock skew.
const localDeadline = createLocalEpisodeDeadline(value.remainingMs, phaseTimeout);
return {
transactionId,
phase,
expectedTitle,
expectedEpisodeId,
initiatorPeerId,
participants,
loadedPeers,
preparedPeers,
createdAt: Number.isFinite(value.createdAt) ? value.createdAt : Date.now(),
deadlineAt: Number.isFinite(value.deadlineAt) ? value.deadlineAt : null,
revision: Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : 1
deadlineAt: localDeadline.deadlineAt,
remainingMs: localDeadline.remainingMs,
revision: Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : 1,
executionReportStatus: value.executionReportStatus === 'executed'
|| value.executionReportStatus === 'failed_execute'
? value.executionReportStatus
: null,
executionReportInFlight: false
};
}
function createPendingEpisodeSyncV2Start(identity, sender = null) {
return {
requestId: ++episodeSyncV2PendingStartId,
roomId: currentRoom?.roomId || null,
connectionGeneration,
targetGeneration: targetActivationGeneration,
tabId: normalizeTabId(sender?.tab?.id ?? currentTabId),
frameId: normalizeFrameId(sender?.frameId ?? currentTargetFrameId),
documentId: sender?.documentId || currentTargetDocumentId || null,
expectedTitle: identity.expectedTitle,
expectedEpisodeId: identity.expectedEpisodeId,
requestedAt: Date.now()
};
}
function isCurrentEpisodeSyncV2Start(pending) {
return episodeSyncV2PendingStart === pending
&& isEpisodeSyncV2StartContextCurrent(pending, {
roomId: currentRoom?.roomId || null,
connectionGeneration,
targetGeneration: targetActivationGeneration,
tabId: normalizeTabId(currentTabId)
});
}
function startLegacyEpisodeLobbyForTransition(identity, pending = null) {
if (!identity?.expectedTitle || !identity.expectedEpisodeId) return 'invalid_identity';
if (pending && !isCurrentEpisodeSyncV2Start(pending)) return 'stale_session';
// Legacy has no separate expectedEpisodeId field. If the 100-code-unit
// title clamp removed the episode marker, use the canonical ID itself so
// old content scripts can still match the local full title.
const lobbyTitle = extractEpisodeId(identity.expectedTitle)
? identity.expectedTitle
: identity.expectedEpisodeId;
if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, lobbyTitle)) {
if (!episodeLobby.readyPeers.includes(peerId)) {
if (!emitLive(EVENTS.EPISODE_READY, {
peerId,
title: lobbyTitle,
expectedTitle: episodeLobby.expectedTitle
})) return 'offline';
episodeLobby.readyPeers.push(peerId);
persistEpisodeLobby();
broadcastLobbyUpdate();
checkEpisodeLobbyCompletion();
}
return 'ready_sent';
}
if (episodeLobby) clearEpisodeLobbyState();
if (!emitLive(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: lobbyTitle })) return 'offline';
supersedeCanonicalMediaRecovery('local episode_lobby', EVENTS.PAUSE);
episodeLobby = {
expectedTitle: lobbyTitle,
initiatorPeerId: peerId,
readyPeers: [peerId],
createdAt: Date.now()
};
persistEpisodeLobby();
broadcastLobbyUpdate();
addLog(`Episode lobby created via compatibility fallback: "${lobbyTitle}"`, 'info');
const targetTabId = pending?.tabId ?? normalizeTabId(currentTabId);
if (targetTabId !== null) {
sendMessageToFrame(
targetTabId,
pending?.frameId ?? currentTargetFrameId,
{ type: 'PAUSE_FOR_LOBBY', expectedTitle: lobbyTitle },
null,
pending?.documentId ?? currentTargetDocumentId
).catch(() => {});
}
episodeLobbyTimeout = setTimeout(
() => cancelEpisodeLobby('Timeout — not all peers loaded the episode'),
EPISODE_LOBBY_TIMEOUT
);
checkEpisodeLobbyCompletion();
return 'lobby_created';
}
function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
return clearTargetSelectionForLifecycle({
expectedTabId,
@@ -1266,6 +1370,7 @@ async function performRoomSessionTeardown({ notifyServer = false, reason = 'Left
reconnectFailed = false;
reconnectAttempts = 0;
reconnectStartTime = null;
episodeSyncV2PendingStart = null;
completeForceSyncBeforeTargetChange(null);
if (notifyServer) emit(EVENTS.LEAVE_ROOM, { peerId });
@@ -2272,7 +2377,13 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
currentRoom.episodeSyncV2 = authoritativeEpisodeSyncV2;
persistEpisodeSyncV2();
broadcastLobbyUpdate();
if (shouldNotifyContent) sendEpisodeSyncV2ToContent().catch(() => {});
if (shouldNotifyContent) {
if (authoritativeEpisodeSyncV2.phase === 'execute') {
executeEpisodeSyncV2FromRelay(authoritativeEpisodeSyncV2).catch(() => {});
} else {
sendEpisodeSyncV2ToContent().catch(() => {});
}
}
} else if (episodeSyncV2) {
clearEpisodeSyncV2State({ reason: 'relay_state_ended' });
} else {
@@ -2701,6 +2812,28 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
}
const phase = data.phase;
if (phase === 'cancel' && !data.transactionId) {
const pending = episodeSyncV2PendingStart;
const matchesPending = episodeSyncV2PendingStart === pending
&& matchesEpisodeSyncV2StartRejection(pending, {
roomId: currentRoom?.roomId || null,
connectionGeneration,
targetGeneration: targetActivationGeneration,
tabId: normalizeTabId(currentTabId)
}, data);
if (!matchesPending) {
addLog(`Ignored stale Episode Sync v2 rejection: ${data.reason || 'rejected'}`, 'info');
break;
}
if (data.reason === 'capability_mismatch') {
const fallbackStatus = startLegacyEpisodeLobbyForTransition({
expectedTitle: pending.expectedTitle,
expectedEpisodeId: pending.expectedEpisodeId
}, pending);
episodeSyncV2PendingStart = null;
addLog(`Episode Sync v2 unavailable; compatibility fallback: ${fallbackStatus}`, 'warn');
break;
}
episodeSyncV2PendingStart = null;
addLog(`Episode Sync v2 unavailable: ${data.reason || 'rejected'}`, 'warn');
break;
}
@@ -2728,6 +2861,11 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
clearEpisodeSyncV2State({ reason: 'transaction_replaced' });
}
if (episodeLobby) clearEpisodeLobbyState();
if (episodeSyncV2PendingStart
&& incoming.expectedTitle === episodeSyncV2PendingStart.expectedTitle
&& incoming.expectedEpisodeId === episodeSyncV2PendingStart.expectedEpisodeId) {
episodeSyncV2PendingStart = null;
}
episodeSyncV2 = incoming;
if (currentRoom) currentRoom.episodeSyncV2 = incoming;
persistEpisodeSyncV2();
@@ -2736,14 +2874,16 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
addLog(`Episode Sync v2 ${incoming.phase}: "${incoming.expectedTitle}" (${incoming.transactionId.substring(0, 8)})`, 'info');
break;
}
if ((phase === 'execute' || phase === 'cancel')
if ((phase === 'execute' || phase === 'complete' || phase === 'cancel')
&& episodeSyncV2
&& data.transactionId === episodeSyncV2.transactionId) {
const completed = episodeSyncV2;
if (phase === 'execute') {
await executeEpisodeSyncV2FromRelay(data);
} else if (phase === 'complete') {
sendMessageToCurrentContent({
type: 'EPISODE_SYNC_V2',
transaction: { ...completed, phase: 'execute', targetTime: 0 }
transaction: { ...completed, phase: 'complete', targetTime: 0 }
}).catch(() => {});
if (currentRoom && Array.isArray(currentRoom.peers)) {
currentRoom.peers.forEach(candidate => {
@@ -2758,7 +2898,10 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
clearEpisodeSyncV2State({ notifyContent: false, reason: 'executed' });
addLog(`Episode Sync v2 executed for "${completed.expectedTitle}"`, 'success');
} else {
clearEpisodeSyncV2State({ reason: data.reason || 'cancelled' });
clearEpisodeSyncV2State({
reason: data.reason || 'cancelled',
relayState: data
});
addLog(`Episode Sync v2 cancelled: ${data.reason || 'cancelled'}`, 'warn');
}
}
@@ -2928,6 +3071,7 @@ function completeForceSyncBeforeTargetChange(nextTabId) {
const normalizedNextTabId = normalizeTabId(nextTabId);
if (selectedTabId !== null && selectedTabId === normalizedNextTabId) return;
episodeSyncV2PendingStart = null;
if (episodeSyncV2) cancelEpisodeSyncV2('target_changed');
if (!isForceSyncInitiator) return;
@@ -2945,7 +3089,7 @@ function episodeLobbyForUi() {
return {
expectedTitle: episodeSyncV2.expectedTitle,
initiatorPeerId: episodeSyncV2.initiatorPeerId,
readyPeers: episodeSyncV2.phase === 'prepare'
readyPeers: episodeSyncV2.phase === 'prepare' || episodeSyncV2.phase === 'execute'
? [...episodeSyncV2.preparedPeers]
: [...episodeSyncV2.loadedPeers],
createdAt: episodeSyncV2.createdAt,
@@ -2972,7 +3116,60 @@ function sendEpisodeSyncV2ToContent(transaction = episodeSyncV2) {
});
}
function clearEpisodeSyncV2State({ notifyContent = true, reason = 'cancelled' } = {}) {
async function executeEpisodeSyncV2FromRelay(relayState) {
const transaction = episodeSyncV2;
if (!transaction
|| !relayState
|| relayState.transactionId !== transaction.transactionId
|| transaction.executionReportInFlight
|| transaction.executionReportStatus) return false;
transaction.phase = 'execute';
const remainingMs = Number.isFinite(relayState.remainingMs)
? Math.max(0, Math.min(EPISODE_SYNC_V2_EXECUTE_TIMEOUT, relayState.remainingMs))
: EPISODE_SYNC_V2_EXECUTE_TIMEOUT;
transaction.remainingMs = remainingMs;
transaction.deadlineAt = Date.now() + remainingMs;
transaction.executionReportInFlight = true;
persistEpisodeSyncV2();
broadcastLobbyUpdate();
let response = null;
try {
response = await sendMessageToCurrentContent({
type: 'EPISODE_SYNC_V2',
transaction: {
...transaction,
phase: 'execute',
targetTime: 0
}
});
} catch (error) {
addLog(`Episode Sync v2 execute content error: ${error.message}`, 'warn');
}
if (episodeSyncV2 !== transaction) return false;
const reportStatus = response?.status === 'executed' ? 'executed' : 'failed_execute';
transaction.executionReportInFlight = false;
const sent = emitLive(EVENTS.EPISODE_SYNC_V2, {
phase: reportStatus,
transactionId: transaction.transactionId,
reason: reportStatus === 'failed_execute'
? (typeof response?.reason === 'string' ? response.reason.substring(0, 32) : 'content_execute_failed')
: undefined
});
transaction.executionReportStatus = sent ? reportStatus : null;
persistEpisodeSyncV2();
addLog(
sent
? `Episode Sync v2 execute result sent: ${reportStatus}`
: `Episode Sync v2 execute result could not be sent: ${reportStatus}`,
reportStatus === 'executed' && sent ? 'info' : 'warn'
);
return sent;
}
function clearEpisodeSyncV2State({ notifyContent = true, reason = 'cancelled', relayState = null } = {}) {
const previous = episodeSyncV2;
episodeSyncV2 = null;
if (currentRoom) currentRoom.episodeSyncV2 = null;
@@ -2983,6 +3180,7 @@ function clearEpisodeSyncV2State({ notifyContent = true, reason = 'cancelled' }
type: 'EPISODE_SYNC_V2',
transaction: {
...previous,
...(relayState && typeof relayState === 'object' ? relayState : {}),
phase: 'cancel',
reason
}
@@ -5623,7 +5821,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
const newTitle = message.payload && message.payload.newTitle;
if (newTitle && extractEpisodeId(newTitle) === null) {
addLog(`Episode change detected ("${newTitle}") but no episode ID was found; ignoring.`, 'info');
addLog(`Episode change detected ("${toEpisodeWireTitle(newTitle) || 'unknown'}") but no episode ID was found; ignoring.`, 'info');
sendResponse({ status: 'not_an_episode' });
return;
}
@@ -5637,12 +5835,14 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'ignored_stale_session' });
return;
}
const lobbyTitle = sanitizeSharedTitle(newTitle, settings.mediaTitlePrivacyMode);
if (!lobbyTitle) {
const sharedLobbyTitle = sanitizeSharedTitle(newTitle, settings.mediaTitlePrivacyMode);
const episodeIdentity = createEpisodeWireIdentity(sharedLobbyTitle);
if (!episodeIdentity) {
addLog(`Episode change detected but media title sharing is ${settings.mediaTitlePrivacyMode}; not creating a lobby.`, 'info');
sendResponse({ status: 'title_privacy_no_lobby' });
return;
}
const lobbyTitle = episodeIdentity.expectedTitle;
// Check setting
const epSettings = await chrome.storage.local.get(['autoSyncNextEpisode']);
@@ -5680,21 +5880,29 @@ async function handleAsyncMessage(message, sender, sendResponse) {
return;
}
// Automatic episode sync v2 is relay-owned. Never self-pause or fall
// back to the legacy client-owned lobby: on an old/mixed relay that path
// can create multiple Force Sync initiators. Manual Force Sync remains
// available and unchanged.
// Prefer the relay-owned barrier. Old relays and mixed rooms retain the
// established legacy lobby, but only from this exact room/target/start
// context so a delayed rejection cannot resurrect stale automation.
if (!serverSupports(CAPABILITIES.EPISODE_SYNC_V2)) {
addLog(`Episode change ("${lobbyTitle}") — relay lacks Episode Sync v2; automatic sync skipped safely.`, 'warn');
sendResponse({ status: 'episode_sync_v2_unsupported' });
const pending = createPendingEpisodeSyncV2Start(episodeIdentity, sender);
episodeSyncV2PendingStart = pending;
const fallbackStatus = startLegacyEpisodeLobbyForTransition(episodeIdentity, pending);
episodeSyncV2PendingStart = null;
addLog(`Episode change ("${lobbyTitle}") — legacy relay fallback: ${fallbackStatus}.`, 'warn');
sendResponse({ status: fallbackStatus });
return;
}
if (episodeSyncV2 && sameEpisode(episodeSyncV2.expectedTitle, lobbyTitle)) {
if (episodeSyncV2
&& episodeSyncV2.expectedTitle === episodeIdentity.expectedTitle
&& episodeSyncV2.expectedEpisodeId === episodeIdentity.expectedEpisodeId) {
sendResponse({ status: 'transaction_active', transactionId: episodeSyncV2.transactionId });
return;
}
if (episodeSyncV2) cancelEpisodeSyncV2('new_episode');
if (!emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start', expectedTitle: lobbyTitle })) {
const pending = createPendingEpisodeSyncV2Start(episodeIdentity, sender);
episodeSyncV2PendingStart = pending;
if (!emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start', ...episodeIdentity })) {
if (episodeSyncV2PendingStart === pending) episodeSyncV2PendingStart = null;
addLog(`Episode change ("${lobbyTitle}") — not connected; automatic sync was not queued.`, 'warn');
sendResponse({ status: 'episode_sync_v2_offline' });
return;
@@ -5725,7 +5933,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'ok' });
return;
}
const readyTitle = sanitizeSharedTitle(message.payload.title, settings.mediaTitlePrivacyMode);
const readyTitle = toEpisodeWireTitle(
sanitizeSharedTitle(message.payload.title, settings.mediaTitlePrivacyMode)
);
lobby.readyPeers.push(peerId);
persistEpisodeLobby();
broadcastLobbyUpdate();
@@ -5758,7 +5968,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
const localTitle = message.payload?.title;
if (localPhase !== 'failed'
&& (typeof localTitle !== 'string' || !sameEpisodeStrict(localTitle, transaction.expectedTitle))) {
&& (typeof localTitle !== 'string' || !sameEpisodeIdentity(
localTitle,
transaction.expectedTitle,
transaction.expectedEpisodeId
))) {
sendResponse({ status: 'ignored_episode_mismatch' });
return;
}
+480 -53
View File
@@ -331,8 +331,17 @@
let pendingPlayPauseVideo = null; // source element for rejecting a stale trailing flush
// --- Episode Auto-Sync State ---
const EPISODE_TRANSITION_QUARANTINE_MS = 2000;
const EPISODE_TRANSITION_CANDIDATE_MS = 30000;
const EPISODE_TRANSITION_POLL_MS = 250;
const EPISODE_TRANSITION_END_WINDOW_SECONDS = 30;
let lastKnownMediaTitle = null;
let episodeTransitionDebounce = null;
let episodeTransitionCandidate = null;
let episodeTransitionPollTimer = null;
let episodeTransitionQuarantine = null;
let episodeTransitionNoiseVideo = null;
let episodeTransitionNoiseUntil = 0;
let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
let lobbyPollTimer = null;
let episodeSyncV2State = null;
@@ -354,6 +363,10 @@
if (destroyed) return;
if (area === 'local' && changes.autoSyncNextEpisode) {
_autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false;
if (!_autoSyncEnabled) {
clearEpisodeTransitionCandidate({ commitCurrentTitle: true });
flushEpisodeTransitionQuarantine();
}
}
if (area === 'local' && changes.audioSettings) {
_audioSettings = mergeAudioSettings(changes.audioSettings.newValue);
@@ -1090,6 +1103,13 @@
// Returns null if no episode pattern found.
// --- SHARED_EPISODE_UTILS_INJECT_START ---
// This block is automatically replaced by /scripts/build-extension.cjs
const EPISODE_WIRE_TITLE_LENGTH = 100;
function toEpisodeWireTitle(title) {
if (typeof title !== 'string' || title.length === 0) return null;
return title.substring(0, EPISODE_WIRE_TITLE_LENGTH);
}
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);
@@ -1125,10 +1145,24 @@
// it to agree so two unrelated S01E06 videos cannot cross-sync. Privacy-reduced
// S/E-only titles still fall back to the canonical episode ID.
function sameEpisodeStrict(titleA, titleB) {
if (!sameEpisode(titleA, titleB)) return false;
const contextA = episodeContext(titleA);
const contextB = episodeContext(titleB);
return !contextA || !contextB || contextA === contextB;
const wireTitleA = toEpisodeWireTitle(titleA);
const wireTitleB = toEpisodeWireTitle(titleB);
if (!sameEpisode(wireTitleA, wireTitleB)) return false;
const contextA = episodeContext(wireTitleA);
const contextB = episodeContext(wireTitleB);
if (!contextA || !contextB || contextA === contextB) return true;
const [shorter, longer] = contextA.length <= contextB.length
? [contextA, contextB]
: [contextB, contextA];
return shorter.length >= 4 && ` ${longer} `.includes(` ${shorter} `);
}
function sameEpisodeIdentity(localTitle, expectedTitle, expectedEpisodeId = null) {
const normalizedExpectedId = typeof expectedEpisodeId === 'string'
? expectedEpisodeId.trim().toUpperCase().substring(0, 16)
: null;
if (normalizedExpectedId && extractEpisodeId(localTitle) !== normalizedExpectedId) return false;
return sameEpisodeStrict(localTitle, expectedTitle);
}
// --- SHARED_EPISODE_UTILS_INJECT_END ---
@@ -1142,26 +1176,177 @@
if (!idA || !idB) return false; // At least one unparseable → allow
return idA !== idB; // Both parseable → only block if different
}
function checkEpisodeTransition() {
function getEpisodeSource(video) {
return video ? (video.currentSrc || video.src || null) : null;
}
function isNearEpisodeBoundary(video, current = getSyncCurrentTime(video)) {
if (!video || current === null) return false;
if (video.ended) return true;
const duration = getSyncDuration(video);
const boundaryWindow = Math.min(
EPISODE_TRANSITION_END_WINDOW_SECONDS,
Math.max(3, duration * 0.05)
);
return duration > 0 && current >= Math.max(0, duration - boundaryWindow);
}
function isAutomaticTerminalEvent(video, current = getSyncCurrentTime(video)) {
if (!video || current === null) return false;
if (video.ended) return true;
const duration = getSyncDuration(video);
return duration > 0
&& duration - current <= 1.5
&& hcmClassifyIntent() !== 'deliberate';
}
function clearEpisodeTransitionPoll() {
if (!episodeTransitionPollTimer) return;
clearTimeout(episodeTransitionPollTimer);
episodeTransitionPollTimer = null;
}
function clearEpisodeTransitionCandidate({ commitCurrentTitle = false } = {}) {
clearEpisodeTransitionPoll();
episodeTransitionCandidate = null;
if (commitCurrentTitle) {
const title = getMediaTitle();
if (title) lastKnownMediaTitle = title;
}
}
function scheduleEpisodeTransitionPoll() {
if (destroyed || pageSuspended || episodeTransitionPollTimer || !episodeTransitionCandidate) return;
episodeTransitionPollTimer = setTimeout(() => {
episodeTransitionPollTimer = null;
if (destroyed || pageSuspended || !episodeTransitionCandidate) return;
if (Date.now() >= episodeTransitionCandidate.deadlineAt) {
clearEpisodeTransitionCandidate({ commitCurrentTitle: true });
flushEpisodeTransitionQuarantine();
return;
}
checkEpisodeTransition('poll');
scheduleEpisodeTransitionPoll();
}, EPISODE_TRANSITION_POLL_MS);
}
function ensureEpisodeTransitionCandidate(video, reason, {
baselineSource = lastVideoSrc,
nearBoundary = false,
transitionLike = false,
quarantineEvents = false
} = {}) {
if (!_autoSyncEnabled || !video || !lastKnownMediaTitle) return null;
const source = getEpisodeSource(video);
if (!episodeTransitionCandidate || episodeTransitionCandidate.video !== video) {
clearEpisodeTransitionCandidate();
episodeTransitionCandidate = {
video,
baselineTitle: lastKnownMediaTitle,
baselineSource: baselineSource === undefined ? source : baselineSource,
startedAt: Date.now(),
deadlineAt: Date.now() + EPISODE_TRANSITION_CANDIDATE_MS,
nearBoundary: nearBoundary === true,
transitionLike: transitionLike === true,
quarantineEvents: quarantineEvents === true || transitionLike === true,
reason
};
} else {
episodeTransitionCandidate.nearBoundary ||= nearBoundary === true;
episodeTransitionCandidate.transitionLike ||= transitionLike === true;
episodeTransitionCandidate.quarantineEvents ||= quarantineEvents === true || transitionLike === true;
episodeTransitionCandidate.reason = reason || episodeTransitionCandidate.reason;
}
scheduleEpisodeTransitionPoll();
return episodeTransitionCandidate;
}
function discardEpisodeTransitionQuarantine() {
const quarantine = episodeTransitionQuarantine;
episodeTransitionQuarantine = null;
if (quarantine?.timer) clearTimeout(quarantine.timer);
}
function checkEpisodeTransition(reason = 'signal') {
const currentTitle = getMediaTitle();
const video = findVideo();
if (!video) return false;
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
&& video
&& current !== null && current < 5
&& video.readyState >= 1) {
onEpisodeTransition(currentTitle);
const current = getSyncCurrentTime(video);
const source = getEpisodeSource(video);
if (!lastKnownMediaTitle) {
if (currentTitle) lastKnownMediaTitle = currentTitle;
return false;
}
// Always track the latest known title
if (currentTitle) lastKnownMediaTitle = currentTitle;
const candidate = episodeTransitionCandidate;
const baselineTitle = candidate?.video === video
? candidate.baselineTitle
: lastKnownMediaTitle;
const baselineSource = candidate?.video === video
? candidate.baselineSource
: lastVideoSrc;
const titleChanged = !!currentTitle && !sameEpisode(currentTitle, baselineTitle);
const sourceChanged = baselineSource !== undefined
&& !!baselineSource
&& !!source
&& source !== baselineSource;
const nearBoundary = isNearEpisodeBoundary(video, current);
const nearNewStart = current !== null && current < 5;
const transitionSignal = titleChanged
|| sourceChanged
|| reason === 'source_changed'
|| reason === 'loadstart'
|| reason === 'emptied'
|| reason === 'loadeddata'
|| reason === 'player_changed'
|| candidate?.nearBoundary;
if (transitionSignal) {
const nextCandidate = ensureEpisodeTransitionCandidate(video, reason, {
baselineSource,
nearBoundary,
transitionLike: titleChanged
|| (sourceChanged && (nearNewStart || candidate?.nearBoundary === true)),
quarantineEvents: titleChanged
|| sourceChanged
|| reason === 'player_changed'
|| candidate?.nearBoundary === true
});
if (nextCandidate && nearNewStart
&& (sourceChanged || reason === 'loadeddata')
&& nextCandidate.nearBoundary) {
nextCandidate.transitionLike = true;
}
}
// Commit the title baseline only after the replacement is actually usable.
// This keeps both title-before-loadeddata and loadeddata-before-title alive
// as pending candidates instead of consuming the only transition signal.
if (titleChanged
&& extractEpisodeId(currentTitle) !== null
&& current !== null && current < 5
&& video.readyState >= 1) {
lastKnownMediaTitle = currentTitle;
clearEpisodeTransitionCandidate();
discardEpisodeTransitionQuarantine();
episodeTransitionNoiseVideo = video;
episodeTransitionNoiseUntil = Date.now() + EPISODE_TRANSITION_QUARANTINE_MS;
cancelPlayPauseCoalesce();
if (seekDebounceTimer) {
clearTimeout(seekDebounceTimer);
seekDebounceTimer = null;
}
onEpisodeTransition(currentTitle);
return true;
}
// Harmless metadata reformatting for the same canonical episode may update
// the baseline. A genuinely different episode stays pending until ready.
if (currentTitle && sameEpisode(currentTitle, baselineTitle)) {
lastKnownMediaTitle = currentTitle;
}
return false;
}
function onEpisodeTransition(newTitle) {
@@ -1304,7 +1489,7 @@
&& video
&& video === findVideo()
&& video.isConnected !== false
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle)
&& sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId)
&& video.paused;
if (mayResume) {
const resumed = await tryMediaAction(EVENTS.PLAY);
@@ -1318,8 +1503,8 @@
const title = getMediaTitle();
const matches = video
&& title
&& sameEpisodeStrict(title, state.expectedTitle)
&& video.readyState >= 1
&& sameEpisodeIdentity(title, state.expectedTitle, state.expectedEpisodeId)
&& video.readyState >= 3
&& getSyncCurrentTime(video) !== null;
if (!matches) {
state.loadCandidateVideo = null;
@@ -1339,8 +1524,19 @@
function startEpisodeSyncV2Lobby(transaction) {
if (!transaction?.transactionId || !transaction.expectedTitle) return;
// Any held source-swap media events predate the relay-owned transaction.
// They are transition noise, not a newer intent that may cancel the lobby.
discardEpisodeTransitionQuarantine();
cancelPlayPauseCoalesce();
if (seekDebounceTimer) {
clearTimeout(seekDebounceTimer);
seekDebounceTimer = null;
}
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
episodeSyncV2State.phase = 'lobby';
episodeSyncV2State.expectedEpisodeId = typeof transaction.expectedEpisodeId === 'string'
? transaction.expectedEpisodeId
: episodeSyncV2State.expectedEpisodeId;
episodeSyncV2State.deadlineAt = Number.isFinite(transaction.deadlineAt)
? transaction.deadlineAt
: episodeSyncV2State.deadlineAt;
@@ -1351,6 +1547,9 @@
episodeSyncV2State = {
transactionId: transaction.transactionId,
expectedTitle: transaction.expectedTitle,
expectedEpisodeId: typeof transaction.expectedEpisodeId === 'string'
? transaction.expectedEpisodeId
: null,
phase: 'lobby',
generation: episodeSyncV2Generation,
loadedReported: false,
@@ -1365,7 +1564,9 @@
pausedByTransaction: false,
manualAction: false,
programmaticPausePending: false,
prepareStarted: false
prepareStarted: false,
executePromise: null,
executeResult: null
};
stopEpisodeSyncV2Poll();
checkEpisodeSyncV2Loaded(episodeSyncV2State);
@@ -1384,7 +1585,7 @@
&& state.phase === 'prepare'
&& video === findVideo()
&& video.isConnected !== false
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle)
&& sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId)
&& video.paused
&& !video.seeking
&& video.readyState >= 3
@@ -1417,6 +1618,9 @@
}
const state = episodeSyncV2State;
if (!state || state.transactionId !== transaction.transactionId) return;
if (typeof transaction.expectedEpisodeId === 'string') {
state.expectedEpisodeId = transaction.expectedEpisodeId;
}
state.deadlineAt = Number.isFinite(transaction.deadlineAt) ? transaction.deadlineAt : state.deadlineAt;
if (state.prepareStarted) return;
stopEpisodeSyncV2Poll();
@@ -1424,7 +1628,8 @@
state.prepareStarted = true;
const video = findVideo();
const currentTitle = getMediaTitle();
if (!video || !currentTitle || !sameEpisodeStrict(currentTitle, state.expectedTitle)) {
if (!video || !currentTitle
|| !sameEpisodeIdentity(currentTitle, state.expectedTitle, state.expectedEpisodeId)) {
if (!await reportEpisodeSyncV2Local(state, 'failed', 'episode_mismatch')) {
retryEpisodeSyncV2Report(state, 'failed', 'episode_mismatch');
}
@@ -1479,6 +1684,56 @@
clearEpisodeSyncV2Content({ manualAction: true }).catch(() => {});
}
function executeEpisodeSyncV2(transaction) {
const state = episodeSyncV2State;
if (!state || state.transactionId !== transaction.transactionId) {
return Promise.resolve({ status: 'failed_execute', reason: 'stale_transaction' });
}
if (state.executeResult) return Promise.resolve(state.executeResult);
if (state.executePromise) return state.executePromise;
const video = state.video || findVideo();
const current = video ? getSyncCurrentTime(video) : null;
const canExecute = video
&& state.phase === 'prepare'
&& video === findVideo()
&& video.isConnected !== false
&& sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId)
&& video.paused
&& !video.seeking
&& video.readyState >= 3
&& current !== null
&& Math.abs(current) < 1;
if (!canExecute) {
state.executeResult = { status: 'failed_execute', reason: 'prepared_state_changed' };
return Promise.resolve(state.executeResult);
}
state.executePromise = Promise.resolve(tryMediaAction(EVENTS.PLAY)).then(applied => {
if (!isEpisodeSyncV2Current(state)) {
return { status: 'failed_execute', reason: 'stale_transaction' };
}
if (!applied) {
reportLog('Episode Sync v2 execute could not start playback', 'warn');
state.executeResult = { status: 'failed_execute', reason: 'play_failed' };
return state.executeResult;
}
scheduleProactiveHeartbeat();
state.executeResult = { status: 'executed' };
return state.executeResult;
}).catch(error => {
reportLog(`Episode Sync v2 execute failed: ${error.message}`, 'warn');
if (isEpisodeSyncV2Current(state)) {
state.executeResult = { status: 'failed_execute', reason: 'play_failed' };
return state.executeResult;
}
return { status: 'failed_execute', reason: 'stale_transaction' };
}).finally(() => {
if (isEpisodeSyncV2Current(state)) state.executePromise = null;
});
return state.executePromise;
}
function getPlayerActionFixes() {
return [
{
@@ -1980,36 +2235,51 @@
reportLog(`Episode Sync v2 prepare failed: ${error.message}`, 'warn');
});
} else if (transaction.phase === 'execute') {
const state = episodeSyncV2State;
const video = state?.video || findVideo();
const current = video ? getSyncCurrentTime(video) : null;
const canExecute = state
&& state.transactionId === transaction.transactionId
&& state.phase === 'prepare'
&& video === findVideo()
&& video.isConnected !== false
&& sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId)
&& video.paused
&& !video.seeking
&& video.readyState >= 3
&& current !== null
&& Math.abs(current) < 1;
if (!canExecute) {
sendResponse({ status: 'failed_execute', reason: 'prepared_state_changed' });
return true;
}
executeEpisodeSyncV2(transaction).then(sendResponse).catch(() => {
sendResponse({ status: 'failed_execute', reason: 'unexpected_error' });
});
return true;
} else if (transaction.phase === 'complete') {
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
const state = episodeSyncV2State;
const video = state.video || findVideo();
const current = video ? getSyncCurrentTime(video) : null;
const canExecute = video
&& state.phase === 'prepare'
&& video === findVideo()
&& video.isConnected !== false
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle)
&& video.paused
&& !video.seeking
&& video.readyState >= 3
&& current !== null
&& Math.abs(current) < 1;
clearEpisodeSyncV2Content({ resume: false }).catch(() => {});
if (canExecute) {
Promise.resolve(tryMediaAction(EVENTS.PLAY)).then(applied => {
if (applied) scheduleProactiveHeartbeat();
else reportLog('Episode Sync v2 execute could not start playback', 'warn');
}).catch(() => {});
} else {
reportLog('Episode Sync v2 execute ignored: prepared player state changed', 'warn');
}
}
} else if (transaction.phase === 'cancel') {
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
const settlePaused = transaction.settlePlaybackState === 'paused';
// A superseding room command follows this cancellation on
// the same ordered socket. Do not race it with restoration
// of the pre-transaction play state.
clearEpisodeSyncV2Content({ resume: transaction.reason !== 'superseded' }).catch(() => {});
clearEpisodeSyncV2Content({
resume: !settlePaused && transaction.reason !== 'superseded'
}).then(async () => {
if (!settlePaused) return;
const video = findVideo();
if (!video || video.isConnected === false) return;
const paused = video.paused || await tryMediaAction(EVENTS.PAUSE);
const targetTime = Number.isFinite(transaction.targetTime)
? transaction.targetTime
: 0;
if (paused) await tryMediaAction(EVENTS.SEEK, { targetTime });
scheduleProactiveHeartbeat();
}).catch(() => {});
}
}
sendResponse({ status: 'ok' });
@@ -2215,6 +2485,10 @@
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
const episodeSyncIntent = episodeSyncV2State?.phase === 'lobby'
&& hcmClassifyIntent() === 'deliberate'
? 'manual'
: undefined;
runtimeMessage({
type: 'CONTENT_EVENT',
action,
@@ -2222,7 +2496,8 @@
currentTime: current,
targetTime: current,
mediaTitle: mediaTitle,
timestamp: Date.now()
timestamp: Date.now(),
episodeSyncIntent
}
}).catch(() => {});
@@ -2230,6 +2505,114 @@
scheduleProactiveHeartbeat();
}
function cancelPlayPauseCoalesce() {
if (playPauseCoalesceTimer) clearTimeout(playPauseCoalesceTimer);
playPauseCoalesceTimer = null;
pendingPlayPauseAction = null;
pendingPlayPauseVideo = null;
}
function v2LoadingEventIsChurn(video) {
const state = episodeSyncV2State;
if (!state || state.phase !== 'lobby') return false;
if (hcmClassifyIntent() === 'deliberate') return false;
// Before this player has stably reported the expected episode, native
// media events are part of loading by definition. Once loaded, require a
// fresh real input gesture before allowing a command to supersede v2.
if (!state.loadedReported) return true;
if (video !== findVideo() || video.readyState < 3 || video.seeking) return true;
return hcmClassifyIntent() !== 'deliberate';
}
function queueEpisodeTransitionEvent(action, video) {
let quarantine = episodeTransitionQuarantine;
if (!quarantine || quarantine.video !== video) {
discardEpisodeTransitionQuarantine();
quarantine = {
video,
sawPlayback: false,
seekPending: false,
timer: null
};
episodeTransitionQuarantine = quarantine;
quarantine.timer = setTimeout(flushEpisodeTransitionQuarantine, EPISODE_TRANSITION_QUARANTINE_MS);
}
if (action === EVENTS.PLAY || action === EVENTS.PAUSE) quarantine.sawPlayback = true;
if (action === EVENTS.SEEK) quarantine.seekPending = true;
}
function flushEpisodeTransitionQuarantine() {
const quarantine = episodeTransitionQuarantine;
if (!quarantine) return;
episodeTransitionQuarantine = null;
if (quarantine.timer) clearTimeout(quarantine.timer);
const video = quarantine.video;
if (destroyed || pageSuspended || !video || video !== activeVideo || video !== findVideo()
|| video.isConnected === false) return;
// Give a late MediaSession update one final synchronous chance to turn
// this candidate into a confirmed episode transition.
if (checkEpisodeTransition('quarantine_timeout')) return;
if (v2LoadingEventIsChurn(video)) return;
if (episodeTransitionNoiseVideo === video && Date.now() < episodeTransitionNoiseUntil) return;
if (episodeTransitionCandidate?.video === video
&& episodeTransitionCandidate.transitionLike) return;
if (episodeTransitionCandidate?.video === video) {
clearEpisodeTransitionCandidate({ commitCurrentTitle: true });
}
// No transition materialized within the bounded window: preserve the
// user's final settled intent with fresh time/title rather than replaying
// the stale first edge that opened the quarantine.
if (quarantine.seekPending) sendContentEvent(EVENTS.SEEK, video);
if (quarantine.sawPlayback) {
sendContentEvent(video.paused ? EVENTS.PAUSE : EVENTS.PLAY, video);
}
}
function shouldQuarantineEpisodeEvent(action, video, current) {
if (!_autoSyncEnabled) return 'relay';
if (v2LoadingEventIsChurn(video)) return 'quarantine';
// A fresh pointer/keyboard gesture is explicit user intent, including
// near an episode boundary. Never add transition latency to it.
if (hcmClassifyIntent() === 'deliberate') return 'relay';
if (episodeTransitionNoiseVideo === video && Date.now() < episodeTransitionNoiseUntil) {
return 'quarantine';
}
const title = getMediaTitle();
const source = getEpisodeSource(video);
const candidate = episodeTransitionCandidate?.video === video
? episodeTransitionCandidate
: null;
const baselineTitle = candidate?.baselineTitle || lastKnownMediaTitle;
const baselineSource = candidate?.baselineSource !== undefined
? candidate.baselineSource
: lastVideoSrc;
const titleChanged = !!baselineTitle && !!title && !sameEpisode(title, baselineTitle);
const sourceChanged = baselineSource !== undefined
&& !!baselineSource
&& !!source
&& source !== baselineSource;
const nearBoundary = isNearEpisodeBoundary(video, current);
const automaticTerminal = isAutomaticTerminalEvent(video, current);
if (!candidate?.quarantineEvents && !titleChanged && !sourceChanged && !nearBoundary) return 'relay';
const pending = ensureEpisodeTransitionCandidate(video, `media_${action}`, {
baselineSource,
nearBoundary,
transitionLike: automaticTerminal
|| titleChanged
|| (sourceChanged && (current < 5 || candidate?.nearBoundary === true)),
quarantineEvents: true
});
if (pending && sourceChanged && current < 5 && pending.nearBoundary) {
pending.transitionLike = true;
}
return checkEpisodeTransition(`media_${action}`) ? 'discard' : 'quarantine';
}
// 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
@@ -2298,6 +2681,13 @@
// Play/Pause pass through — user may want to immediately pause after tabbing back.
if (Date.now() < visibilityGraceUntil && action === EVENTS.SEEK) return;
const transitionDisposition = shouldQuarantineEpisodeEvent(action, video, current);
if (transitionDisposition === 'discard') return;
if (transitionDisposition === 'quarantine') {
queueEpisodeTransitionEvent(action, video);
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
@@ -2493,9 +2883,31 @@
let lastVideoSrc = undefined;
// Episode detection handler for loadeddata event
// Episode detection signals deliberately keep polling after either side of
// the common metadata race. Some players update MediaSession before media is
// ready; others fire loadeddata before updating the title.
const handleLoadedData = event => {
if (isCurrentVideoEvent(event)) checkEpisodeTransition();
if (isCurrentVideoEvent(event)) checkEpisodeTransition('loadeddata');
};
const handleLoadStart = event => {
if (!isCurrentVideoEvent(event)) return;
cancelPlayPauseCoalesce();
checkEpisodeTransition('loadstart');
};
const handleEmptied = event => {
if (!isCurrentVideoEvent(event)) return;
cancelPlayPauseCoalesce();
checkEpisodeTransition('emptied');
};
const handleEnded = event => {
if (!isCurrentVideoEvent(event) || !_autoSyncEnabled) return;
const video = event.currentTarget;
ensureEpisodeTransitionCandidate(video, 'ended', {
baselineSource: getEpisodeSource(video),
nearBoundary: true,
transitionLike: true,
quarantineEvents: true
});
};
function detachVideoListeners(video) {
@@ -2507,6 +2919,9 @@
if (handlers.seeking) video.removeEventListener('seeking', handlers.seeking);
video.removeEventListener('seeked', handlers.seeked);
video.removeEventListener('loadeddata', handlers.loadeddata);
if (handlers.loadstart) video.removeEventListener('loadstart', handlers.loadstart);
if (handlers.emptied) video.removeEventListener('emptied', handlers.emptied);
if (handlers.ended) video.removeEventListener('ended', handlers.ended);
if (handlers.waiting) video.removeEventListener('waiting', handlers.waiting);
delete video._koalaHandlers;
}
@@ -2517,14 +2932,18 @@
function cancelPendingVideoEvents() {
if (seekDebounceTimer) { clearTimeout(seekDebounceTimer); seekDebounceTimer = null; }
if (playPauseCoalesceTimer) { clearTimeout(playPauseCoalesceTimer); playPauseCoalesceTimer = null; }
pendingPlayPauseAction = null;
pendingPlayPauseVideo = null;
cancelPlayPauseCoalesce();
discardEpisodeTransitionQuarantine();
clearEpisodeTransitionCandidate();
episodeTransitionNoiseVideo = null;
episodeTransitionNoiseUntil = 0;
}
function setupListeners() {
if (destroyed) return;
const video = findVideo();
const previousVideo = activeVideo;
const playerChanged = !!previousVideo && previousVideo !== video;
if (activeVideo !== video) cancelPendingVideoEvents();
for (const attached of [...attachedVideos]) {
if (attached !== video) detachVideoListeners(attached);
@@ -2543,6 +2962,9 @@
seeking: handleSeeking,
seeked: handleSeeked,
loadeddata: handleLoadedData,
loadstart: handleLoadStart,
emptied: handleEmptied,
ended: handleEnded,
waiting: handleWaiting
};
video.addEventListener('play', handlePlay);
@@ -2550,9 +2972,13 @@
video.addEventListener('seeking', handleSeeking);
video.addEventListener('seeked', handleSeeked);
video.addEventListener('loadeddata', handleLoadedData);
video.addEventListener('loadstart', handleLoadStart);
video.addEventListener('emptied', handleEmptied);
video.addEventListener('ended', handleEnded);
video.addEventListener('waiting', handleWaiting);
attachedVideos.add(video);
video.dataset.koalaAttached = 'true';
if (playerChanged) checkEpisodeTransition('player_changed');
lastVideoSrc = video.currentSrc || video.src || null;
if (!lastKnownMediaTitle) {
@@ -2594,7 +3020,8 @@
if (!video.dataset.koalaAttached || (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc)) {
if (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc) {
checkEpisodeTransition();
cancelPlayPauseCoalesce();
checkEpisodeTransition('source_changed');
}
setupListeners();
}
+102 -11
View File
@@ -2,7 +2,15 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { CAPABILITIES, EVENTS, EPISODE_SYNC_V2_STABILITY_MS } from '../shared/constants.js';
import {
CAPABILITIES,
EPISODE_LOBBY_TIMEOUT,
EPISODE_SYNC_V2_LOAD_TIMEOUT,
EPISODE_SYNC_V2_PREPARE_TIMEOUT,
EPISODE_SYNC_V2_EXECUTE_TIMEOUT,
EPISODE_SYNC_V2_STABILITY_MS,
EVENTS
} from '../shared/constants.js';
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
@@ -26,7 +34,7 @@ describe('Episode Sync v2 extension contract', () => {
expect(backgroundSource).toContain('EVENTS.EPISODE_SYNC_V2');
});
it('never queues v2 coordination or auto-falls back to a client-owned legacy lobby', () => {
it('keeps v2 live-only and provides a session-correlated legacy fallback', () => {
expect(offlineSource).toContain('EVENTS.EPISODE_SYNC_V2');
const episodeChanged = between(
backgroundSource,
@@ -34,9 +42,28 @@ describe('Episode Sync v2 extension contract', () => {
"message.type === 'EPISODE_READY_LOCAL'"
);
expect(episodeChanged).toContain('serverSupports(CAPABILITIES.EPISODE_SYNC_V2)');
expect(episodeChanged).toContain("emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start'");
expect(episodeChanged).not.toContain('PAUSE_FOR_LOBBY');
expect(episodeChanged).not.toContain('emit(EVENTS.EPISODE_LOBBY');
expect(episodeChanged).toContain("emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start', ...episodeIdentity })");
expect(episodeChanged).toContain('createPendingEpisodeSyncV2Start(episodeIdentity, sender)');
expect(episodeChanged).toContain('startLegacyEpisodeLobbyForTransition(episodeIdentity, pending)');
expect(backgroundSource).toContain("data.reason === 'capability_mismatch'");
expect(backgroundSource).toContain('isEpisodeSyncV2StartContextCurrent(pending');
expect(backgroundSource).toContain("emitLive(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: lobbyTitle })");
const fallback = between(
backgroundSource,
'function startLegacyEpisodeLobbyForTransition(',
'function clearTargetTabForIdle('
);
expect(fallback.indexOf('if (episodeLobby && sameEpisode('))
.toBeLessThan(fallback.indexOf('emitLive(EVENTS.EPISODE_LOBBY'));
const rejectionFallback = between(
backgroundSource,
"if (phase === 'cancel' && !data.transactionId)",
"if (phase === 'lobby' || phase === 'prepare')"
);
expect(rejectionFallback.indexOf('if (!matchesPending)'))
.toBeLessThan(rejectionFallback.indexOf('episodeSyncV2PendingStart = null'));
expect(rejectionFallback).toContain('episodeSyncV2PendingStart = null');
});
it('correlates content reports to the current transaction, phase, target and episode', () => {
@@ -48,10 +75,26 @@ describe('Episode Sync v2 extension contract', () => {
expect(handler).toContain('message.transactionId !== transaction.transactionId');
expect(handler).toContain('transaction.phase !== expectedLocalPhase');
expect(handler).toContain("!isCurrentContentSender(sender)");
expect(handler).toContain('!sameEpisodeStrict(localTitle, transaction.expectedTitle)');
expect(handler).toContain('!sameEpisodeIdentity(');
expect(handler).toContain('emitLive(EVENTS.EPISODE_SYNC_V2');
});
it('separates legacy and v2 loading timeouts and converts remaining duration locally', () => {
expect(EPISODE_LOBBY_TIMEOUT).toBe(60_000);
expect(EPISODE_SYNC_V2_LOAD_TIMEOUT).toBe(120_000);
expect(EPISODE_SYNC_V2_PREPARE_TIMEOUT).toBe(15_000);
expect(EPISODE_SYNC_V2_EXECUTE_TIMEOUT).toBe(10_000);
const normalize = between(
backgroundSource,
'function normalizeEpisodeSyncV2(',
'function createPendingEpisodeSyncV2Start('
);
expect(normalize).toContain('createLocalEpisodeDeadline(value.remainingMs, phaseTimeout)');
expect(normalize).toContain('deadlineAt: localDeadline.deadlineAt');
expect(normalize).not.toContain('value.deadlineAt');
expect(normalize).toContain("phase === 'prepare' ? EPISODE_SYNC_V2_PREPARE_TIMEOUT : EPISODE_SYNC_V2_EXECUTE_TIMEOUT");
});
it('requires the same player and episode to remain paused, seeked, buffered and stable', () => {
const stable = between(
contentSource,
@@ -59,7 +102,7 @@ describe('Episode Sync v2 extension contract', () => {
'async function prepareEpisodeSyncV2('
);
expect(stable).toContain('video === findVideo()');
expect(stable).toContain('sameEpisodeStrict(getMediaTitle(), state.expectedTitle)');
expect(stable).toContain('sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId)');
expect(stable).toContain('video.paused');
expect(stable).toContain('!video.seeking');
expect(stable).toContain('video.readyState >= 3');
@@ -94,7 +137,7 @@ describe('Episode Sync v2 extension contract', () => {
expect(clear).toContain('state.wasPlayingBeforePrepare');
expect(clear).toContain('!state.manualAction');
expect(clear).toContain('video === findVideo()');
expect(clear).toContain('sameEpisodeStrict(getMediaTitle(), state.expectedTitle)');
expect(clear).toContain('sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId)');
expect(contentSource).toContain('failEpisodeSyncV2ForManualAction(action)');
});
@@ -104,14 +147,40 @@ describe('Episode Sync v2 extension contract', () => {
"message.type === 'EPISODE_SYNC_V2'",
'// Episode Auto-Sync: Legacy lobby notification from background'
);
expect(handler).toContain("resume: transaction.reason !== 'superseded'");
expect(handler).toContain("transaction.reason !== 'superseded'");
});
it('distinguishes deliberate loading intent and settles execute failures paused', () => {
const eventSender = between(
contentSource,
'function sendContentEvent(',
'function cancelPlayPauseCoalesce('
);
expect(eventSender).toContain("episodeSyncIntent = episodeSyncV2State?.phase === 'lobby'");
expect(eventSender).toContain("? 'manual'");
const loadingClassifier = between(
contentSource,
'function v2LoadingEventIsChurn(',
'function queueEpisodeTransitionEvent('
);
expect(loadingClassifier).toContain("hcmClassifyIntent() === 'deliberate'");
const handler = between(
contentSource,
"message.type === 'EPISODE_SYNC_V2'",
'// Episode Auto-Sync: Legacy lobby notification from background'
);
expect(handler).toContain("transaction.settlePlaybackState === 'paused'");
expect(handler).toContain('await tryMediaAction(EVENTS.PAUSE)');
expect(handler).toContain('await tryMediaAction(EVENTS.SEEK, { targetTime })');
});
it('revalidates the complete prepared state immediately before execute', () => {
const handler = between(
contentSource,
"transaction.phase === 'execute'",
"transaction.phase === 'cancel'"
'function executeEpisodeSyncV2(',
'function getPlayerActionFixes('
);
expect(handler).toContain("state.phase === 'prepare'");
expect(handler).toContain('video === findVideo()');
@@ -122,8 +191,30 @@ describe('Episode Sync v2 extension contract', () => {
expect(handler).toContain('Math.abs(current) < 1');
});
it('reports content execute outcome and retains state until relay completion', () => {
const execute = between(
backgroundSource,
'async function executeEpisodeSyncV2FromRelay(',
'function clearEpisodeSyncV2State('
);
expect(execute).toContain("response?.status === 'executed' ? 'executed' : 'failed_execute'");
expect(execute).toContain('phase: reportStatus');
expect(execute).not.toContain('clearEpisodeSyncV2State(');
const handler = between(
backgroundSource,
'case EVENTS.EPISODE_SYNC_V2:',
'case EVENTS.EPISODE_LOBBY:'
);
expect(handler).toContain("phase === 'execute' || phase === 'complete' || phase === 'cancel'");
expect(handler).toContain('await executeEpisodeSyncV2FromRelay(data)');
expect(handler).toContain("phase: 'complete'");
expect(handler).toContain("clearEpisodeSyncV2State({ notifyContent: false, reason: 'executed' })");
});
it('injects the shared stability window into packaged content scripts', () => {
expect(buildSource).toContain('EPISODE_SYNC_V2_STABILITY_MS');
expect(buildSource).toContain('episodeSyncStabilityVal');
expect(buildSource).toContain(".replace(/export const /g, 'const ')");
});
});
+70 -4
View File
@@ -4,6 +4,16 @@
* Keep in sync with the injection block in content.js!
*/
// The relay clamps episode titles to 100 UTF-16 code units. Clamp before the
// wire and before strict comparison as well; otherwise a valid long local title
// can never match the relay's authoritative value.
export const EPISODE_WIRE_TITLE_LENGTH = 100;
export function toEpisodeWireTitle(title) {
if (typeof title !== 'string' || title.length === 0) return null;
return title.substring(0, EPISODE_WIRE_TITLE_LENGTH);
}
export 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);
@@ -39,8 +49,64 @@ function episodeContext(title) {
// it to agree so two unrelated S01E06 videos cannot cross-sync. Privacy-reduced
// S/E-only titles still fall back to the canonical episode ID.
export function sameEpisodeStrict(titleA, titleB) {
if (!sameEpisode(titleA, titleB)) return false;
const contextA = episodeContext(titleA);
const contextB = episodeContext(titleB);
return !contextA || !contextB || contextA === contextB;
const wireTitleA = toEpisodeWireTitle(titleA);
const wireTitleB = toEpisodeWireTitle(titleB);
if (!sameEpisode(wireTitleA, wireTitleB)) return false;
const contextA = episodeContext(wireTitleA);
const contextB = episodeContext(wireTitleB);
if (!contextA || !contextB || contextA === contextB) return true;
// Players disagree about whether MediaSession.title contains the series,
// episode title, or both. Accept a complete token-boundary subset while
// still rejecting unrelated contextual titles for the same S/E number.
const [shorter, longer] = contextA.length <= contextB.length
? [contextA, contextB]
: [contextB, contextA];
return shorter.length >= 4 && ` ${longer} `.includes(` ${shorter} `);
}
export function createEpisodeWireIdentity(title) {
const expectedEpisodeId = extractEpisodeId(title);
const expectedTitle = toEpisodeWireTitle(title);
if (!expectedTitle || !expectedEpisodeId) return null;
return { expectedTitle, expectedEpisodeId };
}
export function sameEpisodeIdentity(localTitle, expectedTitle, expectedEpisodeId = null) {
const normalizedExpectedId = typeof expectedEpisodeId === 'string'
? expectedEpisodeId.trim().toUpperCase().substring(0, 16)
: null;
if (normalizedExpectedId && extractEpisodeId(localTitle) !== normalizedExpectedId) return false;
return sameEpisodeStrict(localTitle, expectedTitle);
}
export function createLocalEpisodeDeadline(remainingMs, fallbackMs, now = Date.now()) {
const safeFallback = Number.isFinite(fallbackMs) && fallbackMs > 0 ? fallbackMs : 0;
const safeRemaining = Number.isFinite(remainingMs)
? Math.max(0, Math.min(safeFallback, remainingMs))
: safeFallback;
return {
remainingMs: safeRemaining,
deadlineAt: now + safeRemaining
};
}
export function isEpisodeSyncV2StartContextCurrent(pending, current, now = Date.now()) {
return !!pending
&& !!current
&& now - pending.requestedAt >= 0
&& now - pending.requestedAt <= 15000
&& pending.roomId === current.roomId
&& pending.connectionGeneration === current.connectionGeneration
&& pending.targetGeneration === current.targetGeneration
&& pending.tabId === current.tabId;
}
export function matchesEpisodeSyncV2StartRejection(pending, current, rejection, now = Date.now()) {
if (!isEpisodeSyncV2StartContextCurrent(pending, current, now)) return false;
const rejectionTitle = toEpisodeWireTitle(rejection?.expectedTitle);
const rejectionEpisodeId = typeof rejection?.expectedEpisodeId === 'string'
? rejection.expectedEpisodeId.trim().toUpperCase().substring(0, 16)
: extractEpisodeId(rejectionTitle);
return rejectionTitle === pending.expectedTitle
&& (!rejectionEpisodeId || rejectionEpisodeId === pending.expectedEpisodeId);
}
+92 -1
View File
@@ -1,5 +1,16 @@
import { describe, expect, it } from 'vitest';
import { extractEpisodeId, sameEpisode, sameEpisodeStrict } from './episode-utils.js';
import {
EPISODE_WIRE_TITLE_LENGTH,
createLocalEpisodeDeadline,
createEpisodeWireIdentity,
extractEpisodeId,
sameEpisode,
sameEpisodeIdentity,
sameEpisodeStrict,
isEpisodeSyncV2StartContextCurrent,
matchesEpisodeSyncV2StartRejection,
toEpisodeWireTitle
} from './episode-utils.js';
describe('episode title matching', () => {
it.each([
@@ -58,5 +69,85 @@ describe('episode title matching', () => {
expect(sameEpisodeStrict('S1:E6 - Visiting Ours', 'S01E06 - Visiting Ours')).toBe(true);
expect(sameEpisodeStrict('S1:E6 - Visiting Ours', 'S1:E6 - Another Show')).toBe(false);
expect(sameEpisodeStrict('S1:E6', 'S01E06 - Visiting Ours')).toBe(true);
expect(sameEpisodeStrict(
'Arrested Development - S1:E6 - Visiting Ours',
'S01E06 - Visiting Ours'
)).toBe(true);
expect(sameEpisodeStrict(
'Arrested Development - S1:E6 - Visiting Ours',
'Different Series - S01E06 - Visiting Ours'
)).toBe(false);
});
it('mirrors the relay title clamp before strict comparison', () => {
const title = `S01E06 - ${'Very Long Episode Context '.repeat(8)}`;
const wireTitle = toEpisodeWireTitle(title);
expect(EPISODE_WIRE_TITLE_LENGTH).toBe(100);
expect(wireTitle).toHaveLength(100);
expect(sameEpisodeStrict(title, wireTitle)).toBe(true);
});
it('keeps the full-title episode id in a bounded wire identity', () => {
const title = `${'Long Series Context '.repeat(8)} S01E06`;
const identity = createEpisodeWireIdentity(title);
expect(identity).toEqual({
expectedTitle: title.substring(0, 100),
expectedEpisodeId: 'S01E06'
});
expect(sameEpisodeIdentity(title, identity.expectedTitle, identity.expectedEpisodeId)).toBe(true);
expect(sameEpisodeIdentity(title.replace('S01E06', 'S01E07'), identity.expectedTitle, identity.expectedEpisodeId)).toBe(false);
});
it('bounds oversized and UTF-16 titles exactly like relay substring sanitization', () => {
const title = `${'x'.repeat(99)}😀S01E06${'y'.repeat(5000)}`;
expect(toEpisodeWireTitle(title)).toBe(title.substring(0, 100));
expect(toEpisodeWireTitle(title).length).toBe(100);
expect(toEpisodeWireTitle(null)).toBeNull();
});
it('converts relay remaining duration to a bounded local deadline without wall-clock trust', () => {
expect(createLocalEpisodeDeadline(30_000, 120_000, 1_000)).toEqual({
remainingMs: 30_000,
deadlineAt: 31_000
});
expect(createLocalEpisodeDeadline(undefined, 120_000, 1_000)).toEqual({
remainingMs: 120_000,
deadlineAt: 121_000
});
expect(createLocalEpisodeDeadline(999_999, 120_000, 1_000).remainingMs).toBe(120_000);
expect(createLocalEpisodeDeadline(-1, 120_000, 1_000).remainingMs).toBe(0);
});
it('rejects stale or mismatched capability fallback responses', () => {
const pending = {
roomId: 'ROOM-A',
connectionGeneration: 4,
targetGeneration: 7,
tabId: 12,
expectedTitle: 'S01E06 - Visiting Ours',
expectedEpisodeId: 'S01E06',
requestedAt: 10_000
};
const current = {
roomId: 'ROOM-A',
connectionGeneration: 4,
targetGeneration: 7,
tabId: 12
};
const rejection = {
expectedTitle: 'S01E06 - Visiting Ours',
expectedEpisodeId: 's01e06'
};
expect(isEpisodeSyncV2StartContextCurrent(pending, current, 20_000)).toBe(true);
expect(matchesEpisodeSyncV2StartRejection(pending, current, rejection, 20_000)).toBe(true);
expect(matchesEpisodeSyncV2StartRejection(pending, { ...current, targetGeneration: 8 }, rejection, 20_000)).toBe(false);
expect(matchesEpisodeSyncV2StartRejection(pending, current, rejection, 25_001)).toBe(false);
expect(matchesEpisodeSyncV2StartRejection(pending, current, {
...rejection,
expectedTitle: 'S01E07 - Different'
}, 20_000)).toBe(false);
});
});