feat(sync): add canonical room media state

This commit is contained in:
KoalaDev
2026-08-20 17:27:08 +02:00
parent 1e41d98c2f
commit c064953cca
16 changed files with 1191 additions and 15 deletions
+150 -3
View File
@@ -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)');
});
});
+132
View File
@@ -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
};
}
};
}
+132
View File
@@ -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();
});
});
+47
View File
@@ -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;