mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-09-03 22:45:21 +00:00
fix(sync): make episode transitions relay-authoritative
This commit is contained in:
@@ -29,7 +29,7 @@ describe('async room-session guards', () => {
|
||||
it('revalidates ROOM_DATA after every asynchronous join boundary', () => {
|
||||
const roomData = sourceBetween('case EVENTS.ROOM_DATA:', 'case EVENTS.CONTROL_MODE:');
|
||||
expect(roomData.match(/currentRoom\?\.roomId !== data\.roomId/g)?.length).toBeGreaterThanOrEqual(2);
|
||||
expect(roomData).toContain('const authoritativeLobby = normalizeEpisodeLobby(');
|
||||
expect(roomData).toContain('const authoritativeLobby = authoritativeEpisodeSyncV2 ? null : normalizeEpisodeLobby(');
|
||||
});
|
||||
|
||||
it('does not return chat context after its room or target changed', () => {
|
||||
|
||||
+292
-74
@@ -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 { generateUsername } from './shared/names.js';
|
||||
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
|
||||
import { sameEpisode, sameEpisodeStrict, extractEpisodeId } 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';
|
||||
@@ -290,7 +290,8 @@ function serverSupportsChat() {
|
||||
}
|
||||
const CLIENT_CAPABILITIES = Object.freeze([
|
||||
CAPABILITIES.CHAT_V1,
|
||||
CAPABILITIES.MEDIA_STATE_V1
|
||||
CAPABILITIES.MEDIA_STATE_V1,
|
||||
CAPABILITIES.EPISODE_SYNC_V2
|
||||
]);
|
||||
|
||||
function persistCanonicalMediaRecovery() {
|
||||
@@ -462,7 +463,7 @@ function ensureState() {
|
||||
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle',
|
||||
'currentTargetFrameId', 'currentTargetDocumentId', 'currentTargetHasVideo',
|
||||
'selectedTabId', 'selectedTabTitle', 'selectionErrorTabId', 'selectionErrorMessage',
|
||||
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt',
|
||||
'episodeLobby', 'episodeSyncV2', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt',
|
||||
'hcmDesynced', 'chatActivityTimeline', 'canonicalMediaRecovery'
|
||||
], (data) => {
|
||||
// A late callback must not resurrect a room, queue or canonical
|
||||
@@ -583,6 +584,20 @@ function ensureState() {
|
||||
}
|
||||
}
|
||||
|
||||
const restoredEpisodeSyncV2 = currentRoom
|
||||
? normalizeEpisodeSyncV2(
|
||||
data.episodeSyncV2 || currentRoom.episodeSyncV2,
|
||||
new Set(currentRoom.peers.map(candidate => candidate.peerId))
|
||||
)
|
||||
: null;
|
||||
if (restoredEpisodeSyncV2) {
|
||||
episodeSyncV2 = restoredEpisodeSyncV2;
|
||||
if (episodeLobbyTimeout) clearTimeout(episodeLobbyTimeout);
|
||||
episodeLobbyTimeout = null;
|
||||
episodeLobby = null;
|
||||
currentRoom.activeLobby = null;
|
||||
}
|
||||
|
||||
if (Number.isSafeInteger(data.localSeq) && data.localSeq >= 0) localSeq = data.localSeq;
|
||||
eventQueue = normalizePersistedEventQueue(
|
||||
[...eventQueue, ...(Array.isArray(data.eventQueue) ? data.eventQueue : [])],
|
||||
@@ -661,6 +676,7 @@ let forceSyncTimeout = null;
|
||||
// Episode Auto-Sync Lobby
|
||||
let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt }
|
||||
let episodeLobbyTimeout = null;
|
||||
let episodeSyncV2 = null;
|
||||
|
||||
// --- Storage Utils ---
|
||||
|
||||
@@ -1192,6 +1208,46 @@ async function clearTargetSelectionForLifecycle({
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeEpisodeSyncV2(value, allowedPeerIds = null) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return 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 initiatorPeerId = typeof value.initiatorPeerId === 'string'
|
||||
? value.initiatorPeerId.substring(0, 16)
|
||||
: '';
|
||||
if (!transactionId || !phase || !expectedTitle || !initiatorPeerId
|
||||
|| !Array.isArray(value.participants)
|
||||
|| !Array.isArray(value.loadedPeers)
|
||||
|| !Array.isArray(value.preparedPeers)) return null;
|
||||
const normalizePeers = peers => [...new Set(peers
|
||||
.filter(candidate => typeof candidate === 'string' && candidate)
|
||||
.map(candidate => candidate.substring(0, 16)))];
|
||||
const participants = normalizePeers(value.participants);
|
||||
const participantSet = new Set(participants);
|
||||
const loadedPeers = normalizePeers(value.loadedPeers).filter(candidate => participantSet.has(candidate));
|
||||
const preparedPeers = normalizePeers(value.preparedPeers).filter(candidate => participantSet.has(candidate));
|
||||
if (participants.length < 2
|
||||
|| !participantSet.has(initiatorPeerId)
|
||||
|| (allowedPeerIds && participants.some(candidate => !allowedPeerIds.has(candidate)))) return null;
|
||||
return {
|
||||
transactionId,
|
||||
phase,
|
||||
expectedTitle,
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
|
||||
return clearTargetSelectionForLifecycle({
|
||||
expectedTabId,
|
||||
@@ -1212,6 +1268,8 @@ async function performRoomSessionTeardown({ notifyServer = false, reason = 'Left
|
||||
// Stop room-specific polling before the content script itself is removed.
|
||||
// Every terminal room exit must pass through the exact target identity while
|
||||
// it is still available, regardless of who initiated the exit.
|
||||
if (notifyServer) cancelEpisodeSyncV2('room_exit');
|
||||
else clearEpisodeSyncV2State({ reason: 'room_exit' });
|
||||
clearEpisodeLobbyState();
|
||||
currentRoom = null;
|
||||
clearCanonicalMediaRecovery();
|
||||
@@ -1249,6 +1307,7 @@ async function performRoomSessionTeardown({ notifyServer = false, reason = 'Left
|
||||
forceSyncDeadline: null,
|
||||
expectedAcksCount: 0,
|
||||
episodeLobby: null,
|
||||
episodeSyncV2: null,
|
||||
hcmDesynced: false,
|
||||
reconnectFailed: false,
|
||||
reconnectAttempts: 0,
|
||||
@@ -1845,7 +1904,7 @@ async function flushEventQueue(replaySettingsOverride = undefined) {
|
||||
}
|
||||
applyQueuedRoomPolicy(flushRoomId, {
|
||||
canControl: !(controlMode === CONTROL_MODES.HOST_ONLY && hostPeerId && !amController()),
|
||||
activeLobby: !!episodeLobby,
|
||||
activeLobby: !!(episodeLobby || episodeSyncV2),
|
||||
desynced: hcmDesynced,
|
||||
authoritativeLobby: !!currentRoom?.activeLobby
|
||||
}, 'Queue replay authority changed');
|
||||
@@ -2020,9 +2079,9 @@ async function performPendingCanonicalMediaStateApply() {
|
||||
addLog(`Canonical media state r${mediaState.revision} skipped: local guest is desynced`, 'info');
|
||||
return { status: 'ignored_desynced' };
|
||||
}
|
||||
if (episodeLobby) {
|
||||
if (episodeLobby || episodeSyncV2) {
|
||||
markCanonicalMediaStateHandled(roomId, mediaState.revision);
|
||||
addLog(`Canonical media state r${mediaState.revision} skipped: episode lobby is active`, 'info');
|
||||
addLog(`Canonical media state r${mediaState.revision} skipped: episode sync is active`, 'info');
|
||||
return { status: 'ignored_episode_lobby' };
|
||||
}
|
||||
|
||||
@@ -2193,6 +2252,29 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
||||
currentRoom.peers = [];
|
||||
}
|
||||
|
||||
const roomPeerIds = new Set(currentRoom.peers.map(candidate => candidate.peerId));
|
||||
const authoritativeEpisodeSyncV2 = serverSupports(CAPABILITIES.EPISODE_SYNC_V2)
|
||||
? normalizeEpisodeSyncV2(data.episodeSyncV2, roomPeerIds)
|
||||
: null;
|
||||
if (data.episodeSyncV2 && !authoritativeEpisodeSyncV2) {
|
||||
addLog('Ignored malformed Episode Sync v2 transaction in ROOM_DATA', 'warn');
|
||||
}
|
||||
if (authoritativeEpisodeSyncV2) {
|
||||
const shouldNotifyContent = !episodeSyncV2
|
||||
|| episodeSyncV2.transactionId !== authoritativeEpisodeSyncV2.transactionId
|
||||
|| episodeSyncV2.phase !== authoritativeEpisodeSyncV2.phase;
|
||||
if (episodeLobby) clearEpisodeLobbyState();
|
||||
episodeSyncV2 = authoritativeEpisodeSyncV2;
|
||||
currentRoom.episodeSyncV2 = authoritativeEpisodeSyncV2;
|
||||
persistEpisodeSyncV2();
|
||||
broadcastLobbyUpdate();
|
||||
if (shouldNotifyContent) sendEpisodeSyncV2ToContent().catch(() => {});
|
||||
} else if (episodeSyncV2) {
|
||||
clearEpisodeSyncV2State({ reason: 'relay_state_ended' });
|
||||
} else {
|
||||
currentRoom.episodeSyncV2 = null;
|
||||
}
|
||||
|
||||
// ROOM_DATA is authoritative for an already-active server lobby,
|
||||
// but a locally-created offline lobby has not reached the relay
|
||||
// yet and must remain owned by its initiator until queued replay.
|
||||
@@ -2200,7 +2282,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
||||
entry?.event === EVENTS.EPISODE_LOBBY
|
||||
&& (!entry.roomId || entry.roomId === data.roomId)
|
||||
);
|
||||
const authoritativeLobby = normalizeEpisodeLobby(
|
||||
const authoritativeLobby = authoritativeEpisodeSyncV2 ? null : normalizeEpisodeLobby(
|
||||
data.activeLobby,
|
||||
Date.now(),
|
||||
new Set(currentRoom.peers.map(candidate => candidate.peerId))
|
||||
@@ -2274,12 +2356,12 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
||||
&& !amController();
|
||||
const queuePolicy = applyQueuedRoomPolicy(data.roomId, {
|
||||
canControl: !lostRoomAuthority,
|
||||
activeLobby: !!episodeLobby,
|
||||
activeLobby: !!(episodeLobby || episodeSyncV2),
|
||||
desynced: hcmDesynced,
|
||||
authoritativeLobby: !!authoritativeLobby
|
||||
authoritativeLobby: !!(authoritativeLobby || authoritativeEpisodeSyncV2)
|
||||
}, lostRoomAuthority
|
||||
? 'Host Control role changed while offline'
|
||||
: (episodeLobby ? 'Active Episode Lobby takes precedence' : 'Reconnect queue policy'));
|
||||
: ((episodeLobby || episodeSyncV2) ? 'Active Episode Sync takes precedence' : 'Reconnect queue policy'));
|
||||
|
||||
await handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent);
|
||||
await flushEventQueue(replaySettings);
|
||||
@@ -2608,6 +2690,76 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
||||
}
|
||||
}
|
||||
break;
|
||||
case EVENTS.EPISODE_SYNC_V2: {
|
||||
if (!serverSupports(CAPABILITIES.EPISODE_SYNC_V2)) {
|
||||
addLog('Ignored Episode Sync v2 event from relay without advertised capability', 'warn');
|
||||
break;
|
||||
}
|
||||
const phase = data.phase;
|
||||
if (phase === 'cancel' && !data.transactionId) {
|
||||
addLog(`Episode Sync v2 unavailable: ${data.reason || 'rejected'}`, 'warn');
|
||||
break;
|
||||
}
|
||||
if (phase === 'lobby' || phase === 'prepare') {
|
||||
const activePeerIds = new Set((currentRoom?.peers || []).map(candidate =>
|
||||
typeof candidate === 'object' ? candidate.peerId : candidate
|
||||
));
|
||||
const incoming = normalizeEpisodeSyncV2(data, activePeerIds);
|
||||
if (!incoming
|
||||
|| !incoming.participants.includes(peerId)
|
||||
|| data.senderId !== incoming.initiatorPeerId) {
|
||||
addLog('Ignored malformed Episode Sync v2 state', 'warn');
|
||||
break;
|
||||
}
|
||||
if (episodeSyncV2
|
||||
&& episodeSyncV2.transactionId === incoming.transactionId
|
||||
&& incoming.revision < episodeSyncV2.revision) {
|
||||
addLog(`Ignored stale Episode Sync v2 revision ${incoming.revision}`, 'warn');
|
||||
break;
|
||||
}
|
||||
const shouldNotifyContent = !episodeSyncV2
|
||||
|| episodeSyncV2.transactionId !== incoming.transactionId
|
||||
|| episodeSyncV2.phase !== incoming.phase;
|
||||
if (episodeSyncV2 && episodeSyncV2.transactionId !== incoming.transactionId) {
|
||||
clearEpisodeSyncV2State({ reason: 'transaction_replaced' });
|
||||
}
|
||||
if (episodeLobby) clearEpisodeLobbyState();
|
||||
episodeSyncV2 = incoming;
|
||||
if (currentRoom) currentRoom.episodeSyncV2 = incoming;
|
||||
persistEpisodeSyncV2();
|
||||
broadcastLobbyUpdate();
|
||||
if (shouldNotifyContent) sendEpisodeSyncV2ToContent().catch(() => {});
|
||||
addLog(`Episode Sync v2 ${incoming.phase}: "${incoming.expectedTitle}" (${incoming.transactionId.substring(0, 8)})`, 'info');
|
||||
break;
|
||||
}
|
||||
if ((phase === 'execute' || phase === 'cancel')
|
||||
&& episodeSyncV2
|
||||
&& data.transactionId === episodeSyncV2.transactionId) {
|
||||
const completed = episodeSyncV2;
|
||||
if (phase === 'execute') {
|
||||
sendMessageToCurrentContent({
|
||||
type: 'EPISODE_SYNC_V2',
|
||||
transaction: { ...completed, phase: 'execute', targetTime: 0 }
|
||||
}).catch(() => {});
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
currentRoom.peers.forEach(candidate => {
|
||||
if (candidate && typeof candidate === 'object') {
|
||||
candidate.playbackState = 'playing';
|
||||
candidate.currentTime = 0;
|
||||
candidate.lastReactiveUpdate = Date.now();
|
||||
}
|
||||
});
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
}
|
||||
clearEpisodeSyncV2State({ notifyContent: false, reason: 'executed' });
|
||||
addLog(`Episode Sync v2 executed for "${completed.expectedTitle}"`, 'success');
|
||||
} else {
|
||||
clearEpisodeSyncV2State({ reason: data.reason || 'cancelled' });
|
||||
addLog(`Episode Sync v2 cancelled: ${data.reason || 'cancelled'}`, 'warn');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case EVENTS.EPISODE_LOBBY:
|
||||
if (typeof data.senderId === 'string'
|
||||
&& typeof data.expectedTitle === 'string'
|
||||
@@ -2768,11 +2920,13 @@ function executeForceSync() {
|
||||
}
|
||||
|
||||
function completeForceSyncBeforeTargetChange(nextTabId) {
|
||||
if (!isForceSyncInitiator) return;
|
||||
const selectedTabId = normalizeTabId(currentTabId);
|
||||
const normalizedNextTabId = normalizeTabId(nextTabId);
|
||||
if (selectedTabId !== null && selectedTabId === normalizedNextTabId) return;
|
||||
|
||||
if (episodeSyncV2) cancelEpisodeSyncV2('target_changed');
|
||||
if (!isForceSyncInitiator) return;
|
||||
|
||||
addLog('Finishing Force Sync before target change', 'info');
|
||||
executeForceSync();
|
||||
}
|
||||
@@ -2782,8 +2936,66 @@ function persistEpisodeLobby() {
|
||||
if (storageInitialized) chrome.storage.session.set({ episodeLobby });
|
||||
}
|
||||
|
||||
function episodeLobbyForUi() {
|
||||
if (!episodeSyncV2) return episodeLobby;
|
||||
return {
|
||||
expectedTitle: episodeSyncV2.expectedTitle,
|
||||
initiatorPeerId: episodeSyncV2.initiatorPeerId,
|
||||
readyPeers: episodeSyncV2.phase === 'prepare'
|
||||
? [...episodeSyncV2.preparedPeers]
|
||||
: [...episodeSyncV2.loadedPeers],
|
||||
createdAt: episodeSyncV2.createdAt,
|
||||
mode: 'v2',
|
||||
phase: episodeSyncV2.phase,
|
||||
transactionId: episodeSyncV2.transactionId
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastLobbyUpdate() {
|
||||
chrome.runtime.sendMessage({ type: 'LOBBY_UPDATE', lobby: episodeLobby }).catch(() => {});
|
||||
chrome.runtime.sendMessage({ type: 'LOBBY_UPDATE', lobby: episodeLobbyForUi() }).catch(() => {});
|
||||
}
|
||||
|
||||
function persistEpisodeSyncV2() {
|
||||
if (!storageInitialized) return;
|
||||
chrome.storage.session.set({ episodeSyncV2, currentRoom }).catch(() => {});
|
||||
}
|
||||
|
||||
function sendEpisodeSyncV2ToContent(transaction = episodeSyncV2) {
|
||||
if (!transaction || !currentTabId) return Promise.resolve();
|
||||
return sendMessageToCurrentContent({
|
||||
type: 'EPISODE_SYNC_V2',
|
||||
transaction: { ...transaction }
|
||||
});
|
||||
}
|
||||
|
||||
function clearEpisodeSyncV2State({ notifyContent = true, reason = 'cancelled' } = {}) {
|
||||
const previous = episodeSyncV2;
|
||||
episodeSyncV2 = null;
|
||||
if (currentRoom) currentRoom.episodeSyncV2 = null;
|
||||
persistEpisodeSyncV2();
|
||||
broadcastLobbyUpdate();
|
||||
if (notifyContent && previous && currentTabId) {
|
||||
sendMessageToCurrentContent({
|
||||
type: 'EPISODE_SYNC_V2',
|
||||
transaction: {
|
||||
...previous,
|
||||
phase: 'cancel',
|
||||
reason
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEpisodeSyncV2(reason = 'cancelled') {
|
||||
if (!episodeSyncV2) return false;
|
||||
const transaction = episodeSyncV2;
|
||||
emitLive(EVENTS.EPISODE_SYNC_V2, {
|
||||
phase: 'cancel',
|
||||
transactionId: transaction.transactionId,
|
||||
reason
|
||||
});
|
||||
clearEpisodeSyncV2State({ reason });
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearEpisodeLobbyState() {
|
||||
@@ -2808,7 +3020,7 @@ function cancelEpisodeLobby(reason) {
|
||||
supersedeCanonicalMediaRecovery('local episode_lobby_cancel');
|
||||
|
||||
// Broadcast cancellation to room
|
||||
emit(EVENTS.EPISODE_LOBBY_CANCEL, { peerId });
|
||||
emitLive(EVENTS.EPISODE_LOBBY_CANCEL, { peerId });
|
||||
|
||||
clearEpisodeLobbyState();
|
||||
addLog(`Episode lobby cancelled: ${reason} for "${title}"`, 'warn');
|
||||
@@ -2869,7 +3081,7 @@ function executeEpisodeLobby() {
|
||||
expectedAcksCount: expectedAcksCount
|
||||
});
|
||||
|
||||
const syncPayload = { targetTime: 0.0 };
|
||||
const syncPayload = { targetTime: 0.0, mediaTitle: title };
|
||||
localSeq++;
|
||||
chrome.storage.session.set({ localSeq });
|
||||
emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId, actionTimestamp: timestamp, seq: localSeq });
|
||||
@@ -2885,6 +3097,7 @@ function executeEpisodeLobby() {
|
||||
|
||||
function checkEpisodeLobbyCompletion() {
|
||||
if (!episodeLobby || !currentRoom) return;
|
||||
if (episodeLobby.initiatorPeerId !== peerId) return;
|
||||
const peers = Array.isArray(currentRoom.peers) ? currentRoom.peers : [];
|
||||
// M-3: desynced peers (watching on their own) sit out the lobby — their content
|
||||
// script ignores EPISODE_LOBBY and never reports ready. Don't let them block
|
||||
@@ -2895,7 +3108,7 @@ function checkEpisodeLobbyCompletion() {
|
||||
.filter(Boolean));
|
||||
const readyParticipatingCount = episodeLobby.readyPeers
|
||||
.filter(candidate => participatingPeerIds.has(candidate)).length;
|
||||
if (readyParticipatingCount >= participatingPeerIds.size) {
|
||||
if (participatingPeerIds.size > 0 && readyParticipatingCount >= participatingPeerIds.size) {
|
||||
executeEpisodeLobby();
|
||||
}
|
||||
}
|
||||
@@ -4456,6 +4669,7 @@ function leaveOldRoomIfSwitching(newRoomId) {
|
||||
if (currentRoom && currentRoom.roomId !== newRoomId) {
|
||||
if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_RESET' }).catch(() => {});
|
||||
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
|
||||
cancelEpisodeSyncV2('room_switch');
|
||||
forceDisconnect();
|
||||
currentRoom = null;
|
||||
clearCanonicalMediaRecovery();
|
||||
@@ -4680,7 +4894,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
pendingTargetHost: pendingTarget?.host ?? null,
|
||||
pendingTargetOriginPattern: pendingTarget?.originPattern ?? null,
|
||||
pendingTargetRequestId: pendingTarget?.requestId ?? null,
|
||||
episodeLobby: episodeLobby,
|
||||
episodeLobby: episodeLobbyForUi(),
|
||||
reconnectAttempts,
|
||||
reconnectSlowMode: reconnectFailed,
|
||||
queuedLogicalEvents: eventQueue.length,
|
||||
@@ -4914,6 +5128,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
checkEpisodeLobbyCompletion();
|
||||
}
|
||||
}
|
||||
if (hcmDesynced && episodeSyncV2?.participants.includes(peerId)) {
|
||||
cancelEpisodeSyncV2('desynced');
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'LEAVE_ROOM') {
|
||||
await endRoomSession({ notifyServer: true, reason: 'Left Room' });
|
||||
@@ -5254,7 +5471,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else {
|
||||
localSeq++;
|
||||
chrome.storage.session.set({ localSeq });
|
||||
emit(EVENTS.FORCE_SYNC_ACK, { peerId, seq: localSeq });
|
||||
emitLive(EVENTS.FORCE_SYNC_ACK, { peerId, seq: localSeq });
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'CMD_ACK') {
|
||||
@@ -5441,12 +5658,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Host Control Mode: a gated guest must NOT initiate an episode lobby — the
|
||||
// server drops the guest's EPISODE_LOBBY, so the lobby would never complete
|
||||
// and the guest would self-pause (PAUSE_FOR_LOBBY) into a 60s freeze. In
|
||||
// host-only the controllers (owner + co-hosts) drive episode sync; a plain
|
||||
// guest just follows / snaps back. Use amController() for parity with the
|
||||
// CONTENT_EVENT gate and the server's controllers-based check.
|
||||
// Host Control Mode: only controllers may initiate the relay-owned
|
||||
// transaction. Plain guests wait for the controller's accepted lobby.
|
||||
if (controlMode === CONTROL_MODES.HOST_ONLY && !amController()) {
|
||||
addLog(`Episode change ("${lobbyTitle}") — host-only guest, not creating a lobby (controller drives).`, 'info');
|
||||
sendResponse({ status: 'host_only_guest_skip' });
|
||||
@@ -5463,57 +5676,27 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If lobby already exists for this title, just mark self ready
|
||||
if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, lobbyTitle)) {
|
||||
if (!episodeLobby.readyPeers.includes(peerId)) {
|
||||
episodeLobby.readyPeers.push(peerId);
|
||||
persistEpisodeLobby();
|
||||
broadcastLobbyUpdate();
|
||||
emit(EVENTS.EPISODE_READY, {
|
||||
peerId,
|
||||
title: lobbyTitle,
|
||||
expectedTitle: episodeLobby.expectedTitle
|
||||
});
|
||||
checkEpisodeLobbyCompletion();
|
||||
}
|
||||
sendResponse({ status: 'ready_sent' });
|
||||
// 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.
|
||||
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' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any existing lobby for a different episode
|
||||
if (episodeLobby) clearEpisodeLobbyState();
|
||||
|
||||
// Create new lobby
|
||||
supersedeCanonicalMediaRecovery('local episode_lobby', EVENTS.PAUSE);
|
||||
episodeLobby = {
|
||||
expectedTitle: lobbyTitle,
|
||||
initiatorPeerId: peerId,
|
||||
readyPeers: [peerId], // We are already ready
|
||||
createdAt: Date.now()
|
||||
};
|
||||
persistEpisodeLobby();
|
||||
broadcastLobbyUpdate();
|
||||
addLog(`Episode lobby created: "${lobbyTitle}"`, 'info');
|
||||
|
||||
// Tell content script to pause the video and start polling
|
||||
// (This is the only place we pause — after confirming the feature is enabled)
|
||||
if (sender.tab && sender.tab.id) {
|
||||
sendMessageToFrame(sender.tab.id, sender.frameId, {
|
||||
type: 'PAUSE_FOR_LOBBY',
|
||||
expectedTitle: lobbyTitle
|
||||
}, null, sender.documentId).catch(() => {});
|
||||
if (episodeSyncV2 && sameEpisode(episodeSyncV2.expectedTitle, lobbyTitle)) {
|
||||
sendResponse({ status: 'transaction_active', transactionId: episodeSyncV2.transactionId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Broadcast to room
|
||||
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: lobbyTitle });
|
||||
|
||||
// Start timeout (Q1: Option B — cancel on timeout)
|
||||
episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout — not all peers loaded the episode'), EPISODE_LOBBY_TIMEOUT);
|
||||
|
||||
// Immediate check — maybe we're the only one in the room
|
||||
checkEpisodeLobbyCompletion();
|
||||
|
||||
sendResponse({ status: 'lobby_created' });
|
||||
if (episodeSyncV2) cancelEpisodeSyncV2('new_episode');
|
||||
if (!emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start', expectedTitle: lobbyTitle })) {
|
||||
addLog(`Episode change ("${lobbyTitle}") — not connected; automatic sync was not queued.`, 'warn');
|
||||
sendResponse({ status: 'episode_sync_v2_offline' });
|
||||
return;
|
||||
}
|
||||
addLog(`Episode Sync v2 requested: "${lobbyTitle}"`, 'info');
|
||||
sendResponse({ status: 'episode_sync_v2_requested' });
|
||||
} else if (message.type === 'EPISODE_READY_LOCAL') {
|
||||
if (sender.tab) {
|
||||
if (!isCurrentContentSender(sender)) {
|
||||
@@ -5542,7 +5725,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
lobby.readyPeers.push(peerId);
|
||||
persistEpisodeLobby();
|
||||
broadcastLobbyUpdate();
|
||||
emit(EVENTS.EPISODE_READY, {
|
||||
emitLive(EVENTS.EPISODE_READY, {
|
||||
peerId,
|
||||
title: readyTitle,
|
||||
expectedTitle: lobby.expectedTitle
|
||||
@@ -5552,6 +5735,35 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'EPISODE_SYNC_V2_LOCAL') {
|
||||
if (sender.tab && !isCurrentContentSender(sender)) {
|
||||
sendResponse({ status: 'ignored_stale_target' });
|
||||
return;
|
||||
}
|
||||
const transaction = episodeSyncV2;
|
||||
const localPhase = message.phase;
|
||||
const expectedLocalPhase = localPhase === 'loaded'
|
||||
? 'lobby'
|
||||
: (localPhase === 'prepared' ? 'prepare' : null);
|
||||
if (!transaction
|
||||
|| message.transactionId !== transaction.transactionId
|
||||
|| (expectedLocalPhase && transaction.phase !== expectedLocalPhase)
|
||||
|| !['loaded', 'prepared', 'failed'].includes(localPhase)) {
|
||||
sendResponse({ status: 'ignored_stale_transaction' });
|
||||
return;
|
||||
}
|
||||
const localTitle = message.payload?.title;
|
||||
if (localPhase !== 'failed'
|
||||
&& (typeof localTitle !== 'string' || !sameEpisodeStrict(localTitle, transaction.expectedTitle))) {
|
||||
sendResponse({ status: 'ignored_episode_mismatch' });
|
||||
return;
|
||||
}
|
||||
const sent = emitLive(EVENTS.EPISODE_SYNC_V2, {
|
||||
phase: localPhase,
|
||||
transactionId: transaction.transactionId,
|
||||
reason: typeof message.reason === 'string' ? message.reason.substring(0, 32) : undefined
|
||||
});
|
||||
sendResponse({ status: sent ? 'sent' : 'offline' });
|
||||
} else if (message.type === 'TITLE_PRIVACY_CHANGED') {
|
||||
const privacyRoomId = currentRoom?.roomId || null;
|
||||
const privacyLobby = episodeLobby;
|
||||
@@ -5566,6 +5778,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
cancelEpisodeLobby('Title privacy changed');
|
||||
}
|
||||
}
|
||||
if (episodeSyncV2) cancelEpisodeSyncV2('title_privacy_changed');
|
||||
if (currentRoom) {
|
||||
const sharedTitles = getSharedTitleFields(settings);
|
||||
emit(EVENTS.PEER_STATUS, {
|
||||
@@ -5650,13 +5863,18 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
requestCanonicalMediaRecoveryAttempt();
|
||||
// Content script re-injected, check if there's an active lobby
|
||||
if (episodeLobby) {
|
||||
if (episodeSyncV2) {
|
||||
sendResponse({ episodeSyncV2: { ...episodeSyncV2 }, lobbyActive: false });
|
||||
} else if (episodeLobby) {
|
||||
sendResponse({ lobbyActive: true, expectedTitle: episodeLobby.expectedTitle });
|
||||
} else {
|
||||
sendResponse({ lobbyActive: false });
|
||||
}
|
||||
} else if (message.type === 'CANCEL_EPISODE_LOBBY') {
|
||||
if (episodeLobby) {
|
||||
if (episodeSyncV2) {
|
||||
cancelEpisodeSyncV2('cancelled_by_user');
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (episodeLobby) {
|
||||
cancelEpisodeLobby('Cancelled by user');
|
||||
sendResponse({ status: 'ok' });
|
||||
} else {
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('canonical ROOM_DATA recovery contract', () => {
|
||||
const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState');
|
||||
const roomData = functionBody(backgroundSource, 'handleCanonicalRoomData', 'handleServerEvent');
|
||||
expect(apply).toContain('if (hcmDesynced)');
|
||||
expect(apply).toContain('if (episodeLobby)');
|
||||
expect(apply).toContain('if (episodeLobby || episodeSyncV2)');
|
||||
expect(roomData).toContain('if (hasPendingLocalIntent)');
|
||||
expect(backgroundSource).toContain('awaitingRoomData = true');
|
||||
expect(backgroundSource).toContain('await handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)');
|
||||
|
||||
+332
-2
@@ -84,6 +84,7 @@
|
||||
EPISODE_READY: "episode_ready"
|
||||
};
|
||||
const MAX_MEDIA_TIME = 86400;
|
||||
const EPISODE_SYNC_V2_STABILITY_MS = 1000;
|
||||
// --- SHARED_EVENTS_INJECT_END ---
|
||||
|
||||
// Suppresses native event reporting after a programmatic action.
|
||||
@@ -334,6 +335,9 @@
|
||||
let episodeTransitionDebounce = null;
|
||||
let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
|
||||
let lobbyPollTimer = null;
|
||||
let episodeSyncV2State = null;
|
||||
let episodeSyncV2PollTimer = null;
|
||||
let episodeSyncV2Generation = 0;
|
||||
let _autoSyncEnabled = true; // Cached setting, updated via storage.onChanged
|
||||
let _audioSettings = null;
|
||||
let _audioProcessingAllowed = true;
|
||||
@@ -1104,6 +1108,28 @@
|
||||
if (idA || idB) return false;
|
||||
return titleA === titleB;
|
||||
}
|
||||
|
||||
function episodeContext(title) {
|
||||
if (!title || typeof title !== 'string') return '';
|
||||
return title
|
||||
.replace(/S(?:eason\s*)?\d+[^a-zA-Z0-9]*E(?:pisode\s*)?\d+/ig, ' ')
|
||||
.replace(/(?:Episode|Folge|Ep\.?)\s*\d+|#\s*\d+/ig, ' ')
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Transactional episode sync is deliberately stricter than ordinary playback
|
||||
// filtering: if both peers expose contextual text (episode/show name), require
|
||||
// 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;
|
||||
}
|
||||
// --- SHARED_EPISODE_UTILS_INJECT_END ---
|
||||
|
||||
// Returns true only when we are CERTAIN the episodes differ.
|
||||
@@ -1215,6 +1241,244 @@
|
||||
}
|
||||
}
|
||||
|
||||
function isEpisodeSyncV2Current(state) {
|
||||
return !destroyed
|
||||
&& episodeSyncV2State === state
|
||||
&& state.generation === episodeSyncV2Generation;
|
||||
}
|
||||
|
||||
function stopEpisodeSyncV2Poll() {
|
||||
if (episodeSyncV2PollTimer) {
|
||||
clearInterval(episodeSyncV2PollTimer);
|
||||
episodeSyncV2PollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function reportEpisodeSyncV2Local(state, phase, reason = null) {
|
||||
if (!isEpisodeSyncV2Current(state) || state.reportInFlight) return false;
|
||||
state.reportInFlight = true;
|
||||
try {
|
||||
const response = await runtimeMessage({
|
||||
type: 'EPISODE_SYNC_V2_LOCAL',
|
||||
transactionId: state.transactionId,
|
||||
phase,
|
||||
reason,
|
||||
payload: { title: getMediaTitle() }
|
||||
});
|
||||
if (!isEpisodeSyncV2Current(state)) return false;
|
||||
if (response?.status === 'sent') {
|
||||
if (phase === 'loaded') state.loadedReported = true;
|
||||
if (phase === 'prepared') state.preparedReported = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (isEpisodeSyncV2Current(state)) state.reportInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function retryEpisodeSyncV2Report(state, phase, reason = null) {
|
||||
if (!isEpisodeSyncV2Current(state) || state.reportRetryScheduled) return;
|
||||
if (Number.isFinite(state.deadlineAt) && Date.now() >= state.deadlineAt) return;
|
||||
state.reportRetryScheduled = true;
|
||||
scheduleLifecycleTimeout(async () => {
|
||||
if (!isEpisodeSyncV2Current(state)) return;
|
||||
state.reportRetryScheduled = false;
|
||||
const sent = await reportEpisodeSyncV2Local(state, phase, reason);
|
||||
if (!sent) retryEpisodeSyncV2Report(state, phase, reason);
|
||||
}, 750);
|
||||
}
|
||||
|
||||
async function clearEpisodeSyncV2Content({ resume = false, manualAction = false } = {}) {
|
||||
const state = episodeSyncV2State;
|
||||
if (!state) return;
|
||||
episodeSyncV2Generation++;
|
||||
episodeSyncV2State = null;
|
||||
stopEpisodeSyncV2Poll();
|
||||
state.manualAction = state.manualAction || manualAction;
|
||||
const video = state.video;
|
||||
const mayResume = resume
|
||||
&& state.pausedByTransaction
|
||||
&& state.wasPlayingBeforePrepare
|
||||
&& !state.manualAction
|
||||
&& video
|
||||
&& video === findVideo()
|
||||
&& video.isConnected !== false
|
||||
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle)
|
||||
&& video.paused;
|
||||
if (mayResume) {
|
||||
const resumed = await tryMediaAction(EVENTS.PLAY);
|
||||
if (!resumed) reportLog('Episode Sync v2: could not restore pre-transaction playback', 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
function checkEpisodeSyncV2Loaded(state) {
|
||||
if (!isEpisodeSyncV2Current(state) || state.phase !== 'lobby' || state.loadedReported) return;
|
||||
const video = findVideo();
|
||||
const title = getMediaTitle();
|
||||
const matches = video
|
||||
&& title
|
||||
&& sameEpisodeStrict(title, state.expectedTitle)
|
||||
&& video.readyState >= 1
|
||||
&& getSyncCurrentTime(video) !== null;
|
||||
if (!matches) {
|
||||
state.loadCandidateVideo = null;
|
||||
state.loadCandidateAt = 0;
|
||||
return;
|
||||
}
|
||||
if (state.loadCandidateVideo !== video) {
|
||||
state.loadCandidateVideo = video;
|
||||
state.loadCandidateAt = Date.now();
|
||||
return;
|
||||
}
|
||||
if (Date.now() - state.loadCandidateAt < 500) return;
|
||||
reportEpisodeSyncV2Local(state, 'loaded').then(sent => {
|
||||
if (sent) reportLog(`Episode Sync v2: loaded "${title}"`, 'success');
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function startEpisodeSyncV2Lobby(transaction) {
|
||||
if (!transaction?.transactionId || !transaction.expectedTitle) return;
|
||||
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
|
||||
episodeSyncV2State.phase = 'lobby';
|
||||
episodeSyncV2State.deadlineAt = Number.isFinite(transaction.deadlineAt)
|
||||
? transaction.deadlineAt
|
||||
: episodeSyncV2State.deadlineAt;
|
||||
return;
|
||||
}
|
||||
clearEpisodeSyncV2Content({ resume: true }).catch(() => {});
|
||||
episodeSyncV2Generation++;
|
||||
episodeSyncV2State = {
|
||||
transactionId: transaction.transactionId,
|
||||
expectedTitle: transaction.expectedTitle,
|
||||
phase: 'lobby',
|
||||
generation: episodeSyncV2Generation,
|
||||
loadedReported: false,
|
||||
preparedReported: false,
|
||||
reportInFlight: false,
|
||||
reportRetryScheduled: false,
|
||||
deadlineAt: Number.isFinite(transaction.deadlineAt) ? transaction.deadlineAt : null,
|
||||
loadCandidateVideo: null,
|
||||
loadCandidateAt: 0,
|
||||
video: null,
|
||||
wasPlayingBeforePrepare: false,
|
||||
pausedByTransaction: false,
|
||||
manualAction: false,
|
||||
programmaticPausePending: false,
|
||||
prepareStarted: false
|
||||
};
|
||||
stopEpisodeSyncV2Poll();
|
||||
checkEpisodeSyncV2Loaded(episodeSyncV2State);
|
||||
episodeSyncV2PollTimer = setInterval(() => {
|
||||
if (episodeSyncV2State) checkEpisodeSyncV2Loaded(episodeSyncV2State);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function waitForEpisodeSyncV2Stable(state, video, targetTime = 0, timeoutMs = 12000) {
|
||||
return new Promise(resolve => {
|
||||
const startedAt = Date.now();
|
||||
let stableSince = 0;
|
||||
const timer = setInterval(() => {
|
||||
const current = getSyncCurrentTime(video);
|
||||
const ready = isEpisodeSyncV2Current(state)
|
||||
&& state.phase === 'prepare'
|
||||
&& video === findVideo()
|
||||
&& video.isConnected !== false
|
||||
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle)
|
||||
&& video.paused
|
||||
&& !video.seeking
|
||||
&& video.readyState >= 3
|
||||
&& current !== null
|
||||
&& Math.abs(current - targetTime) < 1;
|
||||
if (ready) {
|
||||
if (!stableSince) stableSince = Date.now();
|
||||
if (Date.now() - stableSince >= EPISODE_SYNC_V2_STABILITY_MS) {
|
||||
clearInterval(timer);
|
||||
seekPollTimers.delete(timer);
|
||||
resolve(true);
|
||||
}
|
||||
} else {
|
||||
stableSince = 0;
|
||||
}
|
||||
if (!isEpisodeSyncV2Current(state) || Date.now() - startedAt >= timeoutMs) {
|
||||
clearInterval(timer);
|
||||
seekPollTimers.delete(timer);
|
||||
resolve(false);
|
||||
}
|
||||
}, 100);
|
||||
seekPollTimers.add(timer);
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareEpisodeSyncV2(transaction) {
|
||||
if (!transaction?.transactionId || !transaction.expectedTitle) return;
|
||||
if (!episodeSyncV2State || episodeSyncV2State.transactionId !== transaction.transactionId) {
|
||||
startEpisodeSyncV2Lobby({ ...transaction, phase: 'lobby' });
|
||||
}
|
||||
const state = episodeSyncV2State;
|
||||
if (!state || state.transactionId !== transaction.transactionId) return;
|
||||
state.deadlineAt = Number.isFinite(transaction.deadlineAt) ? transaction.deadlineAt : state.deadlineAt;
|
||||
if (state.prepareStarted) return;
|
||||
stopEpisodeSyncV2Poll();
|
||||
state.phase = 'prepare';
|
||||
state.prepareStarted = true;
|
||||
const video = findVideo();
|
||||
const currentTitle = getMediaTitle();
|
||||
if (!video || !currentTitle || !sameEpisodeStrict(currentTitle, state.expectedTitle)) {
|
||||
if (!await reportEpisodeSyncV2Local(state, 'failed', 'episode_mismatch')) {
|
||||
retryEpisodeSyncV2Report(state, 'failed', 'episode_mismatch');
|
||||
}
|
||||
return;
|
||||
}
|
||||
state.video = video;
|
||||
state.wasPlayingBeforePrepare = !video.paused;
|
||||
if (!video.paused) {
|
||||
state.programmaticPausePending = true;
|
||||
const paused = await tryMediaAction(EVENTS.PAUSE);
|
||||
if (!isEpisodeSyncV2Current(state)) return;
|
||||
state.pausedByTransaction = paused === true;
|
||||
if (!paused) {
|
||||
state.programmaticPausePending = false;
|
||||
if (!await reportEpisodeSyncV2Local(state, 'failed', 'pause_failed')) {
|
||||
retryEpisodeSyncV2Report(state, 'failed', 'pause_failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const current = getSyncCurrentTime(video);
|
||||
if (current === null || Math.abs(current) >= 0.25) {
|
||||
const sought = await tryMediaAction(EVENTS.SEEK, { targetTime: 0 });
|
||||
if (!isEpisodeSyncV2Current(state)) return;
|
||||
if (!sought) {
|
||||
if (!await reportEpisodeSyncV2Local(state, 'failed', 'seek_failed')) {
|
||||
retryEpisodeSyncV2Report(state, 'failed', 'seek_failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const stable = await waitForEpisodeSyncV2Stable(state, video, 0);
|
||||
if (!isEpisodeSyncV2Current(state)) return;
|
||||
if (!stable) {
|
||||
if (!await reportEpisodeSyncV2Local(state, 'failed', 'stability_timeout')) {
|
||||
retryEpisodeSyncV2Report(state, 'failed', 'stability_timeout');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (await reportEpisodeSyncV2Local(state, 'prepared')) {
|
||||
reportLog(`Episode Sync v2: prepared "${currentTitle}"`, 'success');
|
||||
} else {
|
||||
retryEpisodeSyncV2Report(state, 'prepared');
|
||||
}
|
||||
}
|
||||
|
||||
function failEpisodeSyncV2ForManualAction(action) {
|
||||
const state = episodeSyncV2State;
|
||||
if (!state || state.phase !== 'prepare') return;
|
||||
state.manualAction = true;
|
||||
reportEpisodeSyncV2Local(state, 'failed', `user_${action}`).catch(() => {});
|
||||
clearEpisodeSyncV2Content({ manualAction: true }).catch(() => {});
|
||||
}
|
||||
|
||||
function getPlayerActionFixes() {
|
||||
return [
|
||||
{
|
||||
@@ -1702,7 +1966,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Episode Auto-Sync: Lobby notification from background
|
||||
if (message.type === 'EPISODE_SYNC_V2') {
|
||||
const transaction = message.transaction;
|
||||
if (!transaction?.transactionId || !transaction.phase) {
|
||||
sendResponse({ status: 'invalid_transaction' });
|
||||
return true;
|
||||
}
|
||||
if (transaction.phase === 'lobby') {
|
||||
reportLog(`Episode Sync v2 lobby: waiting for "${transaction.expectedTitle}"`, 'info');
|
||||
startEpisodeSyncV2Lobby(transaction);
|
||||
} else if (transaction.phase === 'prepare') {
|
||||
prepareEpisodeSyncV2(transaction).catch(error => {
|
||||
reportLog(`Episode Sync v2 prepare failed: ${error.message}`, 'warn');
|
||||
});
|
||||
} else if (transaction.phase === 'execute') {
|
||||
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
|
||||
const state = episodeSyncV2State;
|
||||
const video = state.video || findVideo();
|
||||
const canExecute = video
|
||||
&& video === findVideo()
|
||||
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle);
|
||||
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 if (transaction.phase === 'cancel') {
|
||||
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
|
||||
clearEpisodeSyncV2Content({ resume: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Episode Auto-Sync: Legacy lobby notification from background
|
||||
if (message.type === 'EPISODE_LOBBY') {
|
||||
// Host Control Mode: a desynced guest is watching on their own and must
|
||||
// not join the lobby flow. Otherwise they'd pause on title match, report
|
||||
@@ -1962,6 +2263,19 @@
|
||||
return;
|
||||
}
|
||||
if (action === EVENTS.PLAY && consumeCanonicalRestorePlaySuppression()) return;
|
||||
if (episodeSyncV2State?.phase === 'prepare'
|
||||
&& action === EVENTS.PAUSE
|
||||
&& episodeSyncV2State.programmaticPausePending
|
||||
&& video === episodeSyncV2State.video
|
||||
&& video.paused) {
|
||||
episodeSyncV2State.programmaticPausePending = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// An unsuppressed native action during PREPARE is a newer user intent.
|
||||
// Fail the automation before relaying that action; the relay broadcasts
|
||||
// CANCEL first and then applies this manual PLAY/PAUSE/SEEK normally.
|
||||
failEpisodeSyncV2ForManualAction(action);
|
||||
|
||||
if (action === EVENTS.PLAY || action === EVENTS.PAUSE) {
|
||||
cancelCanonicalMediaApply(action, video);
|
||||
@@ -2037,6 +2351,7 @@
|
||||
closeAudioContext();
|
||||
if (keepAlivePort) { try { keepAlivePort.disconnect(); } catch (_e) { /* ignore */ } keepAlivePort = null; }
|
||||
if (lobbyPollTimer) { clearInterval(lobbyPollTimer); lobbyPollTimer = null; }
|
||||
stopEpisodeSyncV2Poll();
|
||||
if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; }
|
||||
if (proactiveHeartbeatTimeout) { clearTimeout(proactiveHeartbeatTimeout); proactiveHeartbeatTimeout = null; }
|
||||
// Drop any pending coalesced play/pause: we are tearing down and will
|
||||
@@ -2064,6 +2379,12 @@
|
||||
connectKeepAlivePort();
|
||||
schedulePeriodicHeartbeat();
|
||||
scheduleProactiveHeartbeat();
|
||||
if (episodeSyncV2State?.phase === 'lobby') {
|
||||
checkEpisodeSyncV2Loaded(episodeSyncV2State);
|
||||
episodeSyncV2PollTimer = setInterval(() => {
|
||||
if (episodeSyncV2State) checkEpisodeSyncV2Loaded(episodeSyncV2State);
|
||||
}, 500);
|
||||
}
|
||||
reportLog(`Page restored from cache — suppressing seeks for ${VISIBILITY_GRACE_MS / 1000}s`, 'warn');
|
||||
}
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
@@ -2445,6 +2766,9 @@
|
||||
if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; }
|
||||
if (proactiveHeartbeatTimeout) { clearTimeout(proactiveHeartbeatTimeout); proactiveHeartbeatTimeout = null; }
|
||||
stopLobbyPoll();
|
||||
stopEpisodeSyncV2Poll();
|
||||
episodeSyncV2Generation++;
|
||||
episodeSyncV2State = null;
|
||||
hcmDeferredSnapPending = false;
|
||||
|
||||
observer.disconnect();
|
||||
@@ -2501,7 +2825,13 @@
|
||||
// Episode Auto-Sync: Boot recovery — check if background has an active lobby
|
||||
runtimeMessage({ type: 'CONTENT_BOOT' }, (res) => {
|
||||
if (destroyed || chrome.runtime.lastError) return;
|
||||
if (res && res.lobbyActive && res.expectedTitle) {
|
||||
if (res?.episodeSyncV2) {
|
||||
if (res.episodeSyncV2.phase === 'prepare') {
|
||||
prepareEpisodeSyncV2(res.episodeSyncV2).catch(() => {});
|
||||
} else {
|
||||
startEpisodeSyncV2Lobby(res.episodeSyncV2);
|
||||
}
|
||||
} else if (res && res.lobbyActive && res.expectedTitle) {
|
||||
reportLog(`Boot: Active lobby detected for "${res.expectedTitle}"`, 'info');
|
||||
startLobbyPoll(res.expectedTitle);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('episode lobby completion races', () => {
|
||||
it('validates restored and authoritative lobby state before using readyPeers', () => {
|
||||
expect(backgroundSource).toContain('function normalizeEpisodeLobby(value, fallbackCreatedAt = Date.now(), allowedPeerIds = null)');
|
||||
expect(backgroundSource).toContain('data.currentRoom.activeLobby,');
|
||||
expect(backgroundSource).toContain('const authoritativeLobby = normalizeEpisodeLobby(');
|
||||
expect(backgroundSource).toContain('const authoritativeLobby = authoritativeEpisodeSyncV2 ? null : normalizeEpisodeLobby(');
|
||||
expect(backgroundSource).toContain('new Set(currentRoom.peers.map(candidate => candidate.peerId))');
|
||||
});
|
||||
|
||||
@@ -74,9 +74,10 @@ describe('episode lobby completion races', () => {
|
||||
|
||||
it('counts only ready peers who still participate in lobby completion', () => {
|
||||
const completion = sourceBetween('function checkEpisodeLobbyCompletion()', 'function checkEpisodeLobbyPeerDeparture()');
|
||||
expect(completion).toContain('episodeLobby.initiatorPeerId !== peerId');
|
||||
expect(completion).toContain('const participatingPeerIds = new Set(peers');
|
||||
expect(completion).toContain('participatingPeerIds.has(candidate)');
|
||||
expect(completion).toContain('readyParticipatingCount >= participatingPeerIds.size');
|
||||
expect(completion).toContain('participatingPeerIds.size > 0 && readyParticipatingCount >= participatingPeerIds.size');
|
||||
});
|
||||
|
||||
it('re-evaluates or cancels a lobby when the local peer enters solo mode', () => {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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';
|
||||
|
||||
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
|
||||
const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8');
|
||||
const offlineSource = fs.readFileSync(path.join(extensionDir, 'offline-media-intent.js'), 'utf8');
|
||||
const buildSource = fs.readFileSync(path.join(extensionDir, '..', 'scripts', 'build-extension.cjs'), 'utf8');
|
||||
|
||||
function between(source, startNeedle, endNeedle) {
|
||||
const start = source.indexOf(startNeedle);
|
||||
const end = source.indexOf(endNeedle, start + startNeedle.length);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
describe('Episode Sync v2 extension contract', () => {
|
||||
it('advertises the additive capability without changing the protocol', () => {
|
||||
expect(CAPABILITIES.EPISODE_SYNC_V2).toBe('episode-sync-v2');
|
||||
expect(EVENTS.EPISODE_SYNC_V2).toBe('episode_sync_v2');
|
||||
expect(backgroundSource).toContain('CAPABILITIES.EPISODE_SYNC_V2');
|
||||
expect(backgroundSource).toContain('EVENTS.EPISODE_SYNC_V2');
|
||||
});
|
||||
|
||||
it('never queues v2 coordination or auto-falls back to a client-owned legacy lobby', () => {
|
||||
expect(offlineSource).toContain('EVENTS.EPISODE_SYNC_V2');
|
||||
const episodeChanged = between(
|
||||
backgroundSource,
|
||||
"message.type === 'EPISODE_CHANGED'",
|
||||
"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');
|
||||
});
|
||||
|
||||
it('correlates content reports to the current transaction, phase, target and episode', () => {
|
||||
const handler = between(
|
||||
backgroundSource,
|
||||
"message.type === 'EPISODE_SYNC_V2_LOCAL'",
|
||||
"message.type === 'TITLE_PRIVACY_CHANGED'"
|
||||
);
|
||||
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('emitLive(EVENTS.EPISODE_SYNC_V2');
|
||||
});
|
||||
|
||||
it('requires the same player and episode to remain paused, seeked, buffered and stable', () => {
|
||||
const stable = between(
|
||||
contentSource,
|
||||
'function waitForEpisodeSyncV2Stable(',
|
||||
'async function prepareEpisodeSyncV2('
|
||||
);
|
||||
expect(stable).toContain('video === findVideo()');
|
||||
expect(stable).toContain('sameEpisodeStrict(getMediaTitle(), state.expectedTitle)');
|
||||
expect(stable).toContain('video.paused');
|
||||
expect(stable).toContain('!video.seeking');
|
||||
expect(stable).toContain('video.readyState >= 3');
|
||||
expect(stable).toContain('Math.abs(current - targetTime) < 1');
|
||||
expect(stable).toContain('Date.now() - stableSince >= EPISODE_SYNC_V2_STABILITY_MS');
|
||||
expect(EPISODE_SYNC_V2_STABILITY_MS).toBe(1000);
|
||||
});
|
||||
|
||||
it('reports prepared only after awaited pause, seek and stable verification', () => {
|
||||
const prepare = between(
|
||||
contentSource,
|
||||
'async function prepareEpisodeSyncV2(',
|
||||
'function failEpisodeSyncV2ForManualAction('
|
||||
);
|
||||
const pause = prepare.indexOf('await tryMediaAction(EVENTS.PAUSE)');
|
||||
const seek = prepare.indexOf('await tryMediaAction(EVENTS.SEEK');
|
||||
const stable = prepare.indexOf('await waitForEpisodeSyncV2Stable');
|
||||
const prepared = prepare.indexOf("reportEpisodeSyncV2Local(state, 'prepared')");
|
||||
expect(pause).toBeGreaterThan(-1);
|
||||
expect(seek).toBeGreaterThan(pause);
|
||||
expect(stable).toBeGreaterThan(seek);
|
||||
expect(prepared).toBeGreaterThan(stable);
|
||||
});
|
||||
|
||||
it('restores playback only when this transaction paused a playing unchanged player', () => {
|
||||
const clear = between(
|
||||
contentSource,
|
||||
'async function clearEpisodeSyncV2Content(',
|
||||
'function checkEpisodeSyncV2Loaded('
|
||||
);
|
||||
expect(clear).toContain('state.pausedByTransaction');
|
||||
expect(clear).toContain('state.wasPlayingBeforePrepare');
|
||||
expect(clear).toContain('!state.manualAction');
|
||||
expect(clear).toContain('video === findVideo()');
|
||||
expect(clear).toContain('sameEpisodeStrict(getMediaTitle(), state.expectedTitle)');
|
||||
expect(contentSource).toContain('failEpisodeSyncV2ForManualAction(action)');
|
||||
});
|
||||
|
||||
it('injects the shared stability window into packaged content scripts', () => {
|
||||
expect(buildSource).toContain('EPISODE_SYNC_V2_STABILITY_MS');
|
||||
expect(buildSource).toContain('episodeSyncStabilityVal');
|
||||
});
|
||||
});
|
||||
@@ -22,3 +22,25 @@ export function sameEpisode(titleA, titleB) {
|
||||
if (idA || idB) return false;
|
||||
return titleA === titleB;
|
||||
}
|
||||
|
||||
function episodeContext(title) {
|
||||
if (!title || typeof title !== 'string') return '';
|
||||
return title
|
||||
.replace(/S(?:eason\s*)?\d+[^a-zA-Z0-9]*E(?:pisode\s*)?\d+/ig, ' ')
|
||||
.replace(/(?:Episode|Folge|Ep\.?)\s*\d+|#\s*\d+/ig, ' ')
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Transactional episode sync is deliberately stricter than ordinary playback
|
||||
// filtering: if both peers expose contextual text (episode/show name), require
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractEpisodeId, sameEpisode } from './episode-utils.js';
|
||||
import { extractEpisodeId, sameEpisode, sameEpisodeStrict } from './episode-utils.js';
|
||||
|
||||
describe('episode title matching', () => {
|
||||
it.each([
|
||||
@@ -53,4 +53,10 @@ describe('episode title matching', () => {
|
||||
])('rejects different titles %j and %j', (left, right) => {
|
||||
expect(sameEpisode(left, right)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps transactional matching strict when both titles expose context', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,10 +44,10 @@ describe('offline media intent background integration', () => {
|
||||
expect(flushIndex).toBeGreaterThan(-1);
|
||||
expect(policyIndex).toBeLessThan(canonicalIndex);
|
||||
expect(canonicalIndex).toBeLessThan(flushIndex);
|
||||
expect(roomData).toContain('activeLobby: !!episodeLobby');
|
||||
expect(roomData).toContain('activeLobby: !!(episodeLobby || episodeSyncV2)');
|
||||
expect(roomData).toContain('desynced: hcmDesynced');
|
||||
expect(roomData).toContain('if (!authoritativeLobby && episodeLobby && !hasQueuedLocalLobby)');
|
||||
expect(roomData).toContain('authoritativeLobby: !!authoritativeLobby');
|
||||
expect(roomData).toContain('authoritativeLobby: !!(authoritativeLobby || authoritativeEpisodeSyncV2)');
|
||||
});
|
||||
|
||||
it('clears queued room intent on failed join, leave and room switch paths', () => {
|
||||
|
||||
@@ -5,7 +5,13 @@ export const MAX_LOGICAL_QUEUE_SIZE = 50;
|
||||
|
||||
const MEDIA_EVENTS = new Set([EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK]);
|
||||
const KNOWN_EVENTS = new Set(Object.values(EVENTS));
|
||||
const STALE_OFFLINE_EVENTS = new Set([EVENTS.PING, EVENTS.PONG, EVENTS.PEER_STATUS, EVENTS.EVENT_ACK]);
|
||||
const STALE_OFFLINE_EVENTS = new Set([
|
||||
EVENTS.PING,
|
||||
EVENTS.PONG,
|
||||
EVENTS.PEER_STATUS,
|
||||
EVENTS.EVENT_ACK,
|
||||
EVENTS.EPISODE_SYNC_V2
|
||||
]);
|
||||
const FORCE_SYNC_EVENTS = new Set([EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE]);
|
||||
const HOST_GATED_EVENTS = new Set([
|
||||
...MEDIA_EVENTS,
|
||||
|
||||
Reference in New Issue
Block a user