mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-30 20:49:22 +00:00
feat(sync): add canonical room media state
This commit is contained in:
+150
-3
@@ -8,6 +8,7 @@ import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChat
|
||||
import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js';
|
||||
import { createChatEchoTracker, createChatSendLimiter, createLatestTaskQueue, normalizeRoomId, shouldShowChatNotification } from './chat-session.js';
|
||||
import { createChatActivityStore } from './chat-activity.js';
|
||||
import { canonicalMediaStateFromRoomData, createCanonicalMediaStateTracker } from './canonical-media-state.js';
|
||||
import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js';
|
||||
import {
|
||||
MEDIA_FRAME_ACCESS_REQUIRED,
|
||||
@@ -238,6 +239,15 @@ let isNamespaceJoined = false;
|
||||
let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] };
|
||||
let localSeq = 0; // Monotonically increasing command sequence for this peer
|
||||
const lastSeqBySender = {}; // senderId → last received seq (stale command guard)
|
||||
const canonicalMediaStateTracker = createCanonicalMediaStateTracker();
|
||||
const CANONICAL_MEDIA_EVENTS = new Set([
|
||||
EVENTS.PLAY,
|
||||
EVENTS.PAUSE,
|
||||
EVENTS.SEEK,
|
||||
EVENTS.FORCE_SYNC_PREPARE,
|
||||
EVENTS.FORCE_SYNC_EXECUTE
|
||||
]);
|
||||
let flushedMediaControlsBeforeRoomData = false;
|
||||
|
||||
// --- Host Control Mode ---
|
||||
let controlMode = CONTROL_MODES.EVERYONE; // 'everyone' | 'host-only'
|
||||
@@ -258,6 +268,18 @@ function serverSupportsChat() {
|
||||
}
|
||||
const CLIENT_CAPABILITIES = Object.freeze([CAPABILITIES.CHAT_V1]);
|
||||
|
||||
function persistCanonicalMediaRecovery() {
|
||||
if (!storageInitialized) return;
|
||||
chrome.storage.session.set({
|
||||
canonicalMediaRecovery: canonicalMediaStateTracker.snapshot()
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function clearCanonicalMediaRecovery() {
|
||||
canonicalMediaStateTracker.clear();
|
||||
persistCanonicalMediaRecovery();
|
||||
}
|
||||
|
||||
function invalidateChatSession() {
|
||||
chatSessionGeneration++;
|
||||
chatReceiveQueue = Promise.resolve();
|
||||
@@ -274,6 +296,7 @@ function clearChatActivity() {
|
||||
async function clearFailedJoinCredentials() {
|
||||
webJoinCoordinator.invalidate();
|
||||
connectIntent = false;
|
||||
clearCanonicalMediaRecovery();
|
||||
chatSecretGuard = '';
|
||||
invalidateChatSession();
|
||||
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
|
||||
@@ -364,7 +387,7 @@ function ensureState() {
|
||||
'currentTargetFrameId', 'currentTargetDocumentId', 'currentTargetHasVideo',
|
||||
'selectedTabId', 'selectedTabTitle', 'selectionErrorTabId', 'selectionErrorMessage',
|
||||
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt',
|
||||
'hcmDesynced', 'chatActivityTimeline'
|
||||
'hcmDesynced', 'chatActivityTimeline', 'canonicalMediaRecovery'
|
||||
], (data) => {
|
||||
clearTimeout(storageTimeout);
|
||||
if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount;
|
||||
@@ -413,6 +436,10 @@ function ensureState() {
|
||||
// the host, or the room is in 'everyone'). Without this, the first heartbeat
|
||||
// after SW restart would broadcast a bogus Solo flag for up to 15s.
|
||||
hcmEnforceDesyncInvariant();
|
||||
canonicalMediaStateTracker.restore(
|
||||
data.canonicalMediaRecovery,
|
||||
currentRoom?.roomId || null
|
||||
);
|
||||
if (data.lastActionState) lastActionState = data.lastActionState;
|
||||
|
||||
if (data.eventQueue) eventQueue = [...eventQueue, ...data.eventQueue].slice(0, 50);
|
||||
@@ -883,7 +910,12 @@ function adoptReportingFrame(sender) {
|
||||
const senderFrameId = normalizeFrameId(sender.frameId);
|
||||
if (senderFrameId === normalizeFrameId(currentTargetFrameId)
|
||||
&& (!currentTargetDocumentId || sender.documentId === currentTargetDocumentId)) {
|
||||
return false;
|
||||
if (currentTargetHasVideo === true) return false;
|
||||
currentTargetHasVideo = true;
|
||||
stopMediaDiscoveryPoll();
|
||||
chrome.storage.session.set({ currentTargetHasVideo }).catch(() => {});
|
||||
tryApplyPendingCanonicalMediaState().catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
currentTargetFrameId = senderFrameId;
|
||||
@@ -897,6 +929,7 @@ function adoptReportingFrame(sender) {
|
||||
currentTargetDocumentId,
|
||||
currentTargetHasVideo
|
||||
}).catch(() => {});
|
||||
tryApplyPendingCanonicalMediaState().catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -998,6 +1031,7 @@ async function leaveRoomAfterIdleGrace(reason) {
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
forceDisconnect();
|
||||
currentRoom = null;
|
||||
clearCanonicalMediaRecovery();
|
||||
clearChatActivity();
|
||||
controlMode = CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = null;
|
||||
@@ -1104,6 +1138,8 @@ async function connect() {
|
||||
|
||||
// --- Phase 4: WebSocket Init ---
|
||||
try {
|
||||
canonicalMediaStateTracker.beginRecovery(settings.roomId || currentRoom?.roomId || null);
|
||||
persistCanonicalMediaRecovery();
|
||||
const url = new URL(finalUrl);
|
||||
url.pathname = '/socket.io/';
|
||||
url.searchParams.set('EIO', '4');
|
||||
@@ -1155,6 +1191,12 @@ async function connect() {
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
});
|
||||
}
|
||||
// Preserve the existing immediate queue-drain behavior. Remember
|
||||
// whether local media intent was flushed before ROOM_DATA so an
|
||||
// older snapshot cannot snap the player backward on reconnect.
|
||||
flushedMediaControlsBeforeRoomData = eventQueue.some(item =>
|
||||
item && CANONICAL_MEDIA_EVENTS.has(item.event)
|
||||
);
|
||||
flushEventQueue();
|
||||
} else if (msg.startsWith('42')) {
|
||||
try {
|
||||
@@ -1583,6 +1625,95 @@ function stopPing() {
|
||||
missedPongs = 0;
|
||||
}
|
||||
|
||||
function markCanonicalMediaStateHandled(roomId, revision) {
|
||||
if (!canonicalMediaStateTracker.markHandled(roomId, revision)) return false;
|
||||
persistCanonicalMediaRecovery();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function tryApplyPendingCanonicalMediaState() {
|
||||
const roomId = currentRoom?.roomId;
|
||||
const pending = canonicalMediaStateTracker.getPending(roomId);
|
||||
if (!pending || !roomId) return { status: 'none' };
|
||||
|
||||
const { mediaState } = pending;
|
||||
if (hcmDesynced) {
|
||||
markCanonicalMediaStateHandled(roomId, mediaState.revision);
|
||||
addLog(`Canonical media state r${mediaState.revision} skipped: local guest is desynced`, 'info');
|
||||
return { status: 'ignored_desynced' };
|
||||
}
|
||||
if (episodeLobby) {
|
||||
markCanonicalMediaStateHandled(roomId, mediaState.revision);
|
||||
addLog(`Canonical media state r${mediaState.revision} skipped: episode lobby is active`, 'info');
|
||||
return { status: 'ignored_episode_lobby' };
|
||||
}
|
||||
|
||||
const tabId = normalizeTabId(currentTabId);
|
||||
if (tabId === null || currentTargetHasVideo !== true) return { status: 'pending_no_target' };
|
||||
|
||||
try {
|
||||
const response = await sendMessageToContentTab(tabId, {
|
||||
type: 'APPLY_CANONICAL_MEDIA_STATE',
|
||||
mediaState
|
||||
});
|
||||
if (currentRoom?.roomId !== roomId) return { status: 'stale_room' };
|
||||
const latestPending = canonicalMediaStateTracker.getPending(roomId);
|
||||
if (latestPending?.mediaState.revision !== mediaState.revision) return { status: 'superseded' };
|
||||
|
||||
if (response?.status === 'applied') {
|
||||
markCanonicalMediaStateHandled(roomId, mediaState.revision);
|
||||
const drift = Number.isFinite(response.drift) ? ` (drift ${response.drift.toFixed(2)}s)` : '';
|
||||
addLog(`Applied canonical media state r${mediaState.revision}${drift}`, 'success');
|
||||
} else if (response?.status === 'ignored_desynced') {
|
||||
markCanonicalMediaStateHandled(roomId, mediaState.revision);
|
||||
addLog(`Canonical media state r${mediaState.revision} skipped: content is desynced`, 'info');
|
||||
} else if (response?.status === 'invalid') {
|
||||
markCanonicalMediaStateHandled(roomId, mediaState.revision);
|
||||
addLog(`Canonical media state r${mediaState.revision} rejected by content validation`, 'warn');
|
||||
}
|
||||
return response || { status: 'no_response' };
|
||||
} catch (error) {
|
||||
addLog(`Canonical media state r${mediaState.revision} pending: ${error.message}`, 'warn');
|
||||
return { status: 'pending_unreachable' };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCanonicalRoomData(data, hadQueuedMediaControls) {
|
||||
canonicalMediaStateTracker.adoptRoom(data?.roomId || null);
|
||||
const canonicalSnapshot = canonicalMediaStateFromRoomData(data);
|
||||
if (canonicalSnapshot.status === 'unsupported' || canonicalSnapshot.status === 'empty') {
|
||||
persistCanonicalMediaRecovery();
|
||||
return;
|
||||
}
|
||||
|
||||
if (canonicalSnapshot.status === 'invalid') {
|
||||
addLog('Ignored invalid canonical media state in ROOM_DATA', 'warn');
|
||||
persistCanonicalMediaRecovery();
|
||||
return;
|
||||
}
|
||||
const { mediaState } = canonicalSnapshot;
|
||||
|
||||
const received = canonicalMediaStateTracker.receive(data.roomId, mediaState);
|
||||
if (received.status === 'stale') {
|
||||
addLog(`Canonical media state ignored: stale revision ${mediaState.revision}`, 'info');
|
||||
return;
|
||||
}
|
||||
if (received.status !== 'pending') return;
|
||||
|
||||
persistCanonicalMediaRecovery();
|
||||
addLog(`Canonical media state received: r${mediaState.revision} ${mediaState.playbackState} @ ${mediaState.currentTime.toFixed(2)}s`, 'info');
|
||||
|
||||
if (hadQueuedMediaControls) {
|
||||
markCanonicalMediaStateHandled(data.roomId, mediaState.revision);
|
||||
addLog(`Canonical media state r${mediaState.revision} skipped: queued local media controls are authoritative for this reconnect`, 'info');
|
||||
return;
|
||||
}
|
||||
const result = await tryApplyPendingCanonicalMediaState();
|
||||
if (result.status === 'pending_no_target') {
|
||||
addLog(`Canonical media state r${mediaState.revision} pending: no media target`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event Handlers ---
|
||||
async function handleServerEvent(event, data) {
|
||||
if (!data) {
|
||||
@@ -1603,7 +1734,9 @@ async function handleServerEvent(event, data) {
|
||||
return;
|
||||
}
|
||||
switch (event) {
|
||||
case EVENTS.ROOM_DATA:
|
||||
case EVENTS.ROOM_DATA: {
|
||||
const hadQueuedMediaControls = flushedMediaControlsBeforeRoomData;
|
||||
flushedMediaControlsBeforeRoomData = false;
|
||||
if (currentRoom?.roomId !== data.roomId) {
|
||||
invalidateChatSession();
|
||||
clearChatActivity();
|
||||
@@ -1668,7 +1801,9 @@ async function handleServerEvent(event, data) {
|
||||
// Inform Website Bridge & Popup
|
||||
const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' };
|
||||
await broadcastJoinStatus(joinStatusMsg);
|
||||
await handleCanonicalRoomData(data, hadQueuedMediaControls);
|
||||
break;
|
||||
}
|
||||
case EVENTS.CONTROL_MODE:
|
||||
// Host Control Mode changed (toggle or host-leave fallback).
|
||||
controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
|
||||
@@ -3243,6 +3378,9 @@ async function activateTargetTab(tabId, tabTitle, {
|
||||
return { status: 'superseded' };
|
||||
}
|
||||
updateBadgeStatus();
|
||||
if (currentTargetHasVideo) {
|
||||
await tryApplyPendingCanonicalMediaState();
|
||||
}
|
||||
return {
|
||||
status: 'ok',
|
||||
tabId: selectedTabId,
|
||||
@@ -3656,6 +3794,7 @@ function leaveOldRoomIfSwitching(newRoomId) {
|
||||
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
|
||||
forceDisconnect();
|
||||
currentRoom = null;
|
||||
clearCanonicalMediaRecovery();
|
||||
clearChatActivity();
|
||||
controlMode = CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = null;
|
||||
@@ -4076,6 +4215,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
// heartbeat survives SW restarts (idle timeout, crash).
|
||||
hcmDesynced = !!message.desynced;
|
||||
if (storageInitialized) chrome.storage.session.set({ hcmDesynced });
|
||||
if (hcmDesynced) {
|
||||
const pending = canonicalMediaStateTracker.getPending(currentRoom?.roomId);
|
||||
if (pending) {
|
||||
markCanonicalMediaStateHandled(pending.roomId, pending.mediaState.revision);
|
||||
addLog(`Canonical media state r${pending.mediaState.revision} skipped: local guest chose desynced mode`, 'info');
|
||||
}
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'LEAVE_ROOM') {
|
||||
webJoinCoordinator.invalidate();
|
||||
@@ -4086,6 +4232,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
currentRoom = null;
|
||||
clearCanonicalMediaRecovery();
|
||||
clearChatActivity();
|
||||
controlMode = CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = null;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
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');
|
||||
|
||||
function functionBody(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}`);
|
||||
const end = source.indexOf(`function ${nextName}`, start + 1);
|
||||
return source.slice(start, end === -1 ? source.length : end);
|
||||
}
|
||||
|
||||
describe('canonical ROOM_DATA recovery contract', () => {
|
||||
it('is capability-gated and treats absent/null media state as the old-relay path', () => {
|
||||
const handler = functionBody(backgroundSource, 'handleCanonicalRoomData', 'handleServerEvent');
|
||||
expect(handler).toContain('canonicalMediaStateFromRoomData(data)');
|
||||
expect(handler).toContain("canonicalSnapshot.status === 'unsupported'");
|
||||
expect(handler).toContain("canonicalSnapshot.status === 'empty'");
|
||||
expect(handler.indexOf('canonicalMediaStateFromRoomData(data)'))
|
||||
.toBeLessThan(handler.indexOf('canonicalMediaStateTracker.receive'));
|
||||
});
|
||||
|
||||
it('keeps legacy PLAY/PAUSE/SEEK, Force Sync, Episode Lobby, and Host Control handlers independent of canonical recovery', () => {
|
||||
const serverHandler = functionBody(backgroundSource, 'handleServerEvent', 'executeForceSync');
|
||||
expect(serverHandler).toContain('case EVENTS.PLAY:');
|
||||
expect(serverHandler).toContain('case EVENTS.PAUSE:');
|
||||
expect(serverHandler).toContain('case EVENTS.SEEK:');
|
||||
expect(serverHandler).toContain('case EVENTS.FORCE_SYNC_PREPARE:');
|
||||
expect(serverHandler).toContain('case EVENTS.FORCE_SYNC_EXECUTE:');
|
||||
expect(serverHandler).toContain('case EVENTS.EPISODE_LOBBY:');
|
||||
expect(serverHandler).toContain('case EVENTS.CONTROL_MODE:');
|
||||
expect(serverHandler).not.toMatch(/case EVENTS\.(?:PLAY|PAUSE|SEEK):[\s\S]{0,500}MEDIA_STATE_V1/);
|
||||
});
|
||||
|
||||
it('uses a dedicated internal apply message without action/history/ACK machinery', () => {
|
||||
const apply = functionBody(backgroundSource, 'tryApplyPendingCanonicalMediaState', 'handleCanonicalRoomData');
|
||||
expect(apply).toContain("type: 'APPLY_CANONICAL_MEDIA_STATE'");
|
||||
expect(apply).not.toContain('routeToContent(');
|
||||
expect(apply).not.toContain('emit(');
|
||||
expect(apply).not.toContain('addToHistory(');
|
||||
expect(apply).not.toContain('EVENT_ACK');
|
||||
|
||||
const contentHandlerStart = contentSource.indexOf("message.type === 'APPLY_CANONICAL_MEDIA_STATE'");
|
||||
const serverCommandStart = contentSource.indexOf("message.type === 'SERVER_COMMAND'", contentHandlerStart);
|
||||
const internalHandler = contentSource.slice(contentHandlerStart, serverCommandStart);
|
||||
expect(internalHandler).toContain('applyCanonicalMediaState(message.mediaState)');
|
||||
expect(internalHandler).not.toContain('CMD_ACK');
|
||||
expect(internalHandler).not.toContain('CONTENT_EVENT');
|
||||
});
|
||||
|
||||
it('keeps pending recovery room-scoped and retries on target lifecycle signals', () => {
|
||||
expect(backgroundSource).toContain("'canonicalMediaRecovery'");
|
||||
expect(backgroundSource).toContain('canonicalMediaStateTracker.restore(');
|
||||
expect(backgroundSource).toContain('tryApplyPendingCanonicalMediaState().catch(() => {})');
|
||||
expect(backgroundSource).toMatch(/currentTargetHasVideo\) \{\s*await tryApplyPendingCanonicalMediaState\(\)/);
|
||||
expect(backgroundSource.match(/clearCanonicalMediaRecovery\(\)/g)?.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('protects intentional desync, active Episode Lobby and queued reconnect intent', () => {
|
||||
const apply = functionBody(backgroundSource, 'tryApplyPendingCanonicalMediaState', 'handleCanonicalRoomData');
|
||||
const roomData = functionBody(backgroundSource, 'handleCanonicalRoomData', 'handleServerEvent');
|
||||
expect(apply).toContain('if (hcmDesynced)');
|
||||
expect(apply).toContain('if (episodeLobby)');
|
||||
expect(roomData).toContain('if (hadQueuedMediaControls)');
|
||||
expect(backgroundSource).toContain('flushedMediaControlsBeforeRoomData = eventQueue.some');
|
||||
});
|
||||
|
||||
it('reuses existing seek abstractions, suppression and drift tolerance', () => {
|
||||
const apply = functionBody(contentSource, 'applyCanonicalMediaState', 'pollSeekReady');
|
||||
expect(apply).toContain('Math.abs(drift) >= MIN_SEEK_DELTA');
|
||||
expect(apply).toContain("_setSuppress('seek')");
|
||||
expect(apply).toContain('seekVideo(video, mediaState.currentTime)');
|
||||
expect(apply).toContain('tryMediaAction(EVENTS.PAUSE)');
|
||||
expect(apply).toContain('tryMediaAction(EVENTS.PLAY)');
|
||||
expect(apply).toContain('if (hcmDesynced)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { CAPABILITIES, MAX_MEDIA_TIME } from './shared/constants.js';
|
||||
|
||||
export function validateCanonicalMediaState(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
if (!Number.isSafeInteger(value.revision) || value.revision < 1) return null;
|
||||
if (value.playbackState !== 'playing' && value.playbackState !== 'paused') return null;
|
||||
if (typeof value.currentTime !== 'number'
|
||||
|| !Number.isFinite(value.currentTime)
|
||||
|| value.currentTime < 0
|
||||
|| value.currentTime > MAX_MEDIA_TIME) {
|
||||
return null;
|
||||
}
|
||||
const normalized = {
|
||||
revision: value.revision,
|
||||
playbackState: value.playbackState,
|
||||
currentTime: value.currentTime
|
||||
};
|
||||
if (typeof value.updatedBy === 'string' && value.updatedBy) {
|
||||
normalized.updatedBy = value.updatedBy.substring(0, 16);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function canonicalMediaStateFromRoomData(roomData) {
|
||||
const capabilities = Array.isArray(roomData?.capabilities) ? roomData.capabilities : [];
|
||||
if (!capabilities.includes(CAPABILITIES.MEDIA_STATE_V1)) {
|
||||
return { status: 'unsupported', mediaState: null };
|
||||
}
|
||||
if (roomData.mediaState === null || roomData.mediaState === undefined) {
|
||||
return { status: 'empty', mediaState: null };
|
||||
}
|
||||
const mediaState = validateCanonicalMediaState(roomData.mediaState);
|
||||
return mediaState
|
||||
? { status: 'available', mediaState }
|
||||
: { status: 'invalid', mediaState: null };
|
||||
}
|
||||
|
||||
function normalizeRoomId(roomId) {
|
||||
return typeof roomId === 'string' && roomId ? roomId : null;
|
||||
}
|
||||
|
||||
export function createCanonicalMediaStateTracker() {
|
||||
let roomId = null;
|
||||
let knownRevision = 0;
|
||||
let appliedRevision = 0;
|
||||
let pending = null;
|
||||
|
||||
function adoptRoom(nextRoomId) {
|
||||
const normalizedRoomId = normalizeRoomId(nextRoomId);
|
||||
if (normalizedRoomId === roomId) return false;
|
||||
roomId = normalizedRoomId;
|
||||
knownRevision = 0;
|
||||
appliedRevision = 0;
|
||||
pending = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
adoptRoom,
|
||||
|
||||
beginRecovery(nextRoomId) {
|
||||
adoptRoom(nextRoomId);
|
||||
appliedRevision = 0;
|
||||
pending = null;
|
||||
},
|
||||
|
||||
receive(nextRoomId, value) {
|
||||
adoptRoom(nextRoomId);
|
||||
const mediaState = validateCanonicalMediaState(value);
|
||||
if (!roomId || !mediaState) return { status: 'invalid' };
|
||||
if (mediaState.revision < knownRevision) return { status: 'stale' };
|
||||
if (mediaState.revision === knownRevision
|
||||
&& (appliedRevision === mediaState.revision || pending?.mediaState.revision === mediaState.revision)) {
|
||||
return { status: 'duplicate' };
|
||||
}
|
||||
knownRevision = Math.max(knownRevision, mediaState.revision);
|
||||
pending = { roomId, mediaState };
|
||||
return { status: 'pending', mediaState };
|
||||
},
|
||||
|
||||
getPending(nextRoomId = roomId) {
|
||||
return pending && pending.roomId === normalizeRoomId(nextRoomId)
|
||||
? { roomId: pending.roomId, mediaState: { ...pending.mediaState } }
|
||||
: null;
|
||||
},
|
||||
|
||||
markHandled(nextRoomId, revision) {
|
||||
if (normalizeRoomId(nextRoomId) !== roomId
|
||||
|| !Number.isSafeInteger(revision)
|
||||
|| revision < 1) {
|
||||
return false;
|
||||
}
|
||||
knownRevision = Math.max(knownRevision, revision);
|
||||
appliedRevision = Math.max(appliedRevision, revision);
|
||||
if (pending?.mediaState.revision <= revision) pending = null;
|
||||
return true;
|
||||
},
|
||||
|
||||
clear() {
|
||||
adoptRoom(null);
|
||||
},
|
||||
|
||||
restore(value, currentRoomId) {
|
||||
adoptRoom(currentRoomId);
|
||||
if (!value || typeof value !== 'object' || value.roomId !== roomId || !roomId) return false;
|
||||
knownRevision = Number.isSafeInteger(value.knownRevision) && value.knownRevision >= 0
|
||||
? value.knownRevision
|
||||
: 0;
|
||||
appliedRevision = Number.isSafeInteger(value.appliedRevision) && value.appliedRevision >= 0
|
||||
? Math.min(value.appliedRevision, knownRevision)
|
||||
: 0;
|
||||
const restoredPending = validateCanonicalMediaState(value.pending?.mediaState);
|
||||
if (value.pending?.roomId === roomId
|
||||
&& restoredPending
|
||||
&& restoredPending.revision >= appliedRevision
|
||||
&& restoredPending.revision >= knownRevision) {
|
||||
pending = { roomId, mediaState: restoredPending };
|
||||
knownRevision = restoredPending.revision;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
snapshot() {
|
||||
return {
|
||||
roomId,
|
||||
knownRevision,
|
||||
appliedRevision,
|
||||
pending: pending ? { roomId: pending.roomId, mediaState: { ...pending.mediaState } } : null
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canonicalMediaStateFromRoomData,
|
||||
createCanonicalMediaStateTracker,
|
||||
validateCanonicalMediaState
|
||||
} from './canonical-media-state.js';
|
||||
|
||||
const state = (revision, currentTime = revision * 10, playbackState = 'playing') => ({
|
||||
revision,
|
||||
currentTime,
|
||||
playbackState,
|
||||
updatedBy: 'peer-a'
|
||||
});
|
||||
|
||||
describe('canonical media state validation', () => {
|
||||
it('accepts a bounded canonical snapshot', () => {
|
||||
expect(validateCanonicalMediaState(state(2))).toEqual(state(2));
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
[],
|
||||
{},
|
||||
{ revision: 0, playbackState: 'playing', currentTime: 1 },
|
||||
{ revision: 1.5, playbackState: 'playing', currentTime: 1 },
|
||||
{ revision: 1, playbackState: 'buffering', currentTime: 1 },
|
||||
{ revision: 1, playbackState: 'playing', currentTime: NaN },
|
||||
{ revision: 1, playbackState: 'playing', currentTime: Infinity },
|
||||
{ revision: 1, playbackState: 'playing', currentTime: -1 },
|
||||
{ revision: 1, playbackState: 'playing', currentTime: 86401 },
|
||||
{ revision: 1, playbackState: 'playing', currentTime: '1' }
|
||||
])('rejects malformed snapshot %#', value => {
|
||||
expect(validateCanonicalMediaState(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ROOM_DATA capability compatibility', () => {
|
||||
const legacyRoomData = {
|
||||
roomId: 'room-a',
|
||||
peers: [],
|
||||
activeLobby: null,
|
||||
hostPeerId: 'peer-a',
|
||||
controlMode: 'everyone',
|
||||
controllers: ['peer-a'],
|
||||
capabilities: ['host-control', 'co-host', 'chat-v1']
|
||||
};
|
||||
|
||||
it('treats an old relay without capability or mediaState as the unchanged fallback', () => {
|
||||
expect(canonicalMediaStateFromRoomData(legacyRoomData)).toEqual({
|
||||
status: 'unsupported',
|
||||
mediaState: null
|
||||
});
|
||||
expect(canonicalMediaStateFromRoomData({ ...legacyRoomData, capabilities: undefined })).toEqual({
|
||||
status: 'unsupported',
|
||||
mediaState: null
|
||||
});
|
||||
});
|
||||
|
||||
it('does not consume a stray mediaState unless the relay advertises support', () => {
|
||||
expect(canonicalMediaStateFromRoomData({ ...legacyRoomData, mediaState: state(4) })).toEqual({
|
||||
status: 'unsupported',
|
||||
mediaState: null
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts null as a valid capable-relay state without creating pending recovery', () => {
|
||||
expect(canonicalMediaStateFromRoomData({
|
||||
...legacyRoomData,
|
||||
capabilities: [...legacyRoomData.capabilities, 'media-state-v1'],
|
||||
mediaState: null
|
||||
})).toEqual({ status: 'empty', mediaState: null });
|
||||
});
|
||||
|
||||
it('returns a validated snapshot only for a capable relay', () => {
|
||||
expect(canonicalMediaStateFromRoomData({
|
||||
...legacyRoomData,
|
||||
capabilities: [...legacyRoomData.capabilities, 'media-state-v1'],
|
||||
mediaState: state(4)
|
||||
})).toEqual({ status: 'available', mediaState: state(4) });
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonical media state tracker', () => {
|
||||
it('accepts a valid snapshot and applies each revision once', () => {
|
||||
const tracker = createCanonicalMediaStateTracker();
|
||||
expect(tracker.receive('room-a', state(1)).status).toBe('pending');
|
||||
expect(tracker.markHandled('room-a', 1)).toBe(true);
|
||||
expect(tracker.receive('room-a', state(1)).status).toBe('duplicate');
|
||||
});
|
||||
|
||||
it('ignores stale revisions and lets a newer snapshot replace pending state', () => {
|
||||
const tracker = createCanonicalMediaStateTracker();
|
||||
tracker.receive('room-a', state(3));
|
||||
expect(tracker.receive('room-a', state(2)).status).toBe('stale');
|
||||
expect(tracker.receive('room-a', state(4)).status).toBe('pending');
|
||||
expect(tracker.getPending('room-a').mediaState).toEqual(state(4));
|
||||
});
|
||||
|
||||
it('never exposes room A state after switching to room B or leaving', () => {
|
||||
const tracker = createCanonicalMediaStateTracker();
|
||||
tracker.receive('room-a', state(5));
|
||||
tracker.adoptRoom('room-b');
|
||||
expect(tracker.getPending('room-a')).toBeNull();
|
||||
expect(tracker.getPending('room-b')).toBeNull();
|
||||
tracker.receive('room-b', state(1));
|
||||
tracker.clear();
|
||||
expect(tracker.snapshot()).toEqual({ roomId: null, knownRevision: 0, appliedRevision: 0, pending: null });
|
||||
});
|
||||
|
||||
it('allows the same revision once in a new reconnect recovery cycle', () => {
|
||||
const tracker = createCanonicalMediaStateTracker();
|
||||
tracker.receive('room-a', state(8, 80));
|
||||
tracker.markHandled('room-a', 8);
|
||||
tracker.beginRecovery('room-a');
|
||||
expect(tracker.receive('room-a', state(8, 100)).status).toBe('pending');
|
||||
expect(tracker.getPending().mediaState.currentTime).toBe(100);
|
||||
});
|
||||
|
||||
it('restores only room-scoped session state', () => {
|
||||
const first = createCanonicalMediaStateTracker();
|
||||
first.receive('room-a', state(9));
|
||||
const stored = first.snapshot();
|
||||
|
||||
const sameRoom = createCanonicalMediaStateTracker();
|
||||
expect(sameRoom.restore(stored, 'room-a')).toBe(true);
|
||||
expect(sameRoom.getPending('room-a').mediaState.revision).toBe(9);
|
||||
|
||||
const otherRoom = createCanonicalMediaStateTracker();
|
||||
expect(otherRoom.restore(stored, 'room-b')).toBe(false);
|
||||
expect(otherRoom.getPending('room-b')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,7 @@
|
||||
EPISODE_LOBBY: "episode_lobby",
|
||||
EPISODE_READY: "episode_ready"
|
||||
};
|
||||
const MAX_MEDIA_TIME = 86400;
|
||||
// --- SHARED_EVENTS_INJECT_END ---
|
||||
|
||||
// Suppresses native event reporting after a programmatic action.
|
||||
@@ -1212,6 +1213,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
function applyCanonicalMediaState(mediaState) {
|
||||
if (!mediaState || typeof mediaState !== 'object'
|
||||
|| !Number.isSafeInteger(mediaState.revision) || mediaState.revision < 1
|
||||
|| (mediaState.playbackState !== 'playing' && mediaState.playbackState !== 'paused')
|
||||
|| typeof mediaState.currentTime !== 'number'
|
||||
|| !Number.isFinite(mediaState.currentTime)
|
||||
|| mediaState.currentTime < 0
|
||||
|| mediaState.currentTime > MAX_MEDIA_TIME) {
|
||||
return { status: 'invalid' };
|
||||
}
|
||||
if (hcmDesynced) return { status: 'ignored_desynced' };
|
||||
|
||||
const video = findVideo();
|
||||
if (!video) return { status: 'no_video' };
|
||||
|
||||
const currentTime = getSyncCurrentTime(video);
|
||||
const drift = currentTime === null ? null : mediaState.currentTime - currentTime;
|
||||
const shouldSeek = drift === null || Math.abs(drift) >= MIN_SEEK_DELTA;
|
||||
|
||||
try {
|
||||
// Paused recovery pauses before seeking; playing recovery seeks before
|
||||
// starting. Both paths reuse the same site/page-API abstractions and
|
||||
// native-event suppression as ordinary remote commands.
|
||||
if (mediaState.playbackState === 'paused' && !video.paused) {
|
||||
tryMediaAction(EVENTS.PAUSE);
|
||||
}
|
||||
if (shouldSeek) {
|
||||
_setSuppress('seek');
|
||||
seekVideo(video, mediaState.currentTime);
|
||||
}
|
||||
if (mediaState.playbackState === 'playing' && video.paused) {
|
||||
tryMediaAction(EVENTS.PLAY);
|
||||
}
|
||||
scheduleProactiveHeartbeat();
|
||||
return { status: 'applied', revision: mediaState.revision, drift, sought: shouldSeek };
|
||||
} catch (error) {
|
||||
reportLog(`Canonical media state apply failed: ${error.message}`, 'warn');
|
||||
return { status: 'apply_failed' };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper: Wait until video is ready for playback (buffered & seeked) ---
|
||||
function pollSeekReady(targetTime, timeoutMs = 8000) {
|
||||
return new Promise((resolve) => {
|
||||
@@ -1312,6 +1354,11 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'APPLY_CANONICAL_MEDIA_STATE') {
|
||||
sendResponse(applyCanonicalMediaState(message.mediaState));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'SERVER_COMMAND') {
|
||||
const { action, payload } = message;
|
||||
let actionCompleted = false;
|
||||
|
||||
Reference in New Issue
Block a user