diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8f894cf..2d64677 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,6 +32,27 @@ Ensures all peers are buffered and synchronized before resuming: > **Network Transit Buffer Rule**: The orchestrator (`background.js`) must always use a timeout at least 500ms longer than the worker (`content.js`) to account for IPC and network transit time. Never align them exactly 1:1, as this will introduce a race condition on slow connections. 4. **Resume**: All peers call `play()` simultaneously. +## 3.1 Canonical Media State v1 + +The relay keeps one optional, in-memory canonical playback state per active room. +Accepted `PLAY`, `PAUSE`, and `SEEK` commands advance a server-owned revision. +Playing positions advance lazily from the server update time; paused positions do +not. Heartbeats remain observational and do not mutate canonical state. + +`ROOM_DATA` materializes the playing position at snapshot creation and advertises +the optional `media-state-v1` capability. A joining/reconnecting client validates +the room/revision, respects Host Control solo mode and Episode Lobby, then sends an +internal `APPLY_CANONICAL_MEDIA_STATE` message to the existing content/video path. +That path reuses frame election, Netflix/Disney page-API seeks, native play/pause, +the 2-second drift tolerance, and programmatic-event suppression. The apply is +one-shot recovery: it creates no action history, notification, command ACK, or +relay media event. + +Force Sync remains a two-phase ACK protocol. `PREPARE` is temporary choreography; +the matching `EXECUTE` commits its validated target to canonical state. Per-sender +`seq`, peer heartbeats, and the existing reconnect event queue remain separate. +Offline media-command compaction is intentionally deferred. + ## 4. Episode Auto-Sync Maintains continuous synchronized viewing when watching series: 1. **Detection**: `content.js` monitors the Media Session API for title changes. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 9d119eb..6088b5a 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -83,13 +83,67 @@ Payload: "hostPeerId": "string or null", "controlMode": "everyone | host-only", "controllers": ["peerId"], - "capabilities": ["host-control", "co-host", "chat", "chat-v1"] + "mediaState": "canonical media state object or null", + "capabilities": ["host-control", "co-host", "chat", "chat-v1", "media-state-v1"] } ``` `room_data` is sent to the joining socket. It is not the general broadcast used for every later room update. +## Canonical Media State v1 + +Relays advertise this optional recovery primitive with `"media-state-v1"` in +`room_data.capabilities`. Each active room stores at most one state: + +```json +{ + "revision": 42, + "playbackState": "playing", + "currentTime": 1234.5, + "updatedAt": 1787234425123, + "updatedBy": "peer-id" +} +``` + +The internal `currentTime` is the media position at server-owned `updatedAt`. +Playing state advances lazily when a snapshot is requested; paused state stays +fixed. `revision`, `updatedAt`, and `updatedBy` are server-owned. Clients cannot +spoof them. The wire snapshot contains the already-projected `currentTime`, +`revision`, `playbackState`, and `updatedBy`, so clients never compare client and +server wall clocks. + +Only accepted, sanitized room controls update canonical state: + +- `play` uses its valid `currentTime`, or an existing effective canonical position. +- `pause` uses its valid `currentTime`, or freezes an existing effective position. +- `seek` prefers `targetTime` (with `currentTime` compatibility) and preserves the + established playback state. +- `force_sync_prepare` records only temporary coordination state. Its matching + `force_sync_execute` commits the prepared target as playing. + +`peer_status` heartbeats are observations and never rewrite canonical intent. +Per-sender `seq` still orders commands from one sender; canonical `revision` +orders server-accepted room transitions. Neither replaces the other. + +On join/reconnect, a capable extension applies a valid snapshot once through an +extension-internal recovery message. Existing seek/page-API and native-event +suppression prevent `play`, `pause`, or `seek` echoes. Pending recovery is scoped +to the room/revision in `chrome.storage.session`, waits for the selected media +target lifecycle, and is cleared on leave/switch. Intentional host-only guest +desync and an active Episode Lobby take precedence over snapshot recovery. + +Compatibility is additive: new clients use old behavior with a relay that omits +the capability; old clients ignore the extra `room_data` field from a new relay. +A new relay canonicalizes every accepted legacy `play`, `pause`, `seek`, and +matching Force Sync command regardless of `join_room.clientCapabilities`, while +relaying the established event names, payloads, and order unchanged. This makes +server-first rollout safe: old clients populate recovery state without needing to +understand or acknowledge it, and new clients consume it only when the relay +advertises the capability. No protocol-version or minimum-version bump is +required. Offline `play`/`pause`/`seek` compaction is planned separately and is +not part of Media State v1. + ## Ephemeral encrypted chat Relays advertise chat support with `"chat-v1"` in `room_data.capabilities` and keep @@ -253,7 +307,9 @@ them with the same sanitized relay envelope as other room events, including ### `force_sync_execute` -Payload includes `targetTime`. In `host-only` mode, only controllers may send it. +The current extension sends sequence/action metadata but no target; the relay uses +the validated target retained from the matching `force_sync_prepare`. In +`host-only` mode, only controllers may send it. The relay also allows a matching initiator's execute event after that initiator started the prepare step, even if their controller state changed before execute. diff --git a/extension/background.js b/extension/background.js index 01ed4ff..cacf35a 100644 --- a/extension/background.js +++ b/extension/background.js @@ -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; diff --git a/extension/canonical-media-state-background.test.mjs b/extension/canonical-media-state-background.test.mjs new file mode 100644 index 0000000..97083a4 --- /dev/null +++ b/extension/canonical-media-state-background.test.mjs @@ -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)'); + }); +}); diff --git a/extension/canonical-media-state.js b/extension/canonical-media-state.js new file mode 100644 index 0000000..6993585 --- /dev/null +++ b/extension/canonical-media-state.js @@ -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 + }; + } + }; +} diff --git a/extension/canonical-media-state.test.mjs b/extension/canonical-media-state.test.mjs new file mode 100644 index 0000000..de556a6 --- /dev/null +++ b/extension/canonical-media-state.test.mjs @@ -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(); + }); +}); diff --git a/extension/content.js b/extension/content.js index f1d8995..e56a6cb 100644 --- a/extension/content.js +++ b/extension/content.js @@ -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; diff --git a/scripts/build-extension.cjs b/scripts/build-extension.cjs index 3146fda..b523fec 100644 --- a/scripts/build-extension.cjs +++ b/scripts/build-extension.cjs @@ -107,6 +107,7 @@ function copyExtensionFiles(targetDir, browserName) { // Robust Extraction using flexible regex const eventsMatch = constantsContent.match(/export const EVENTS\s*=\s*({[\s\S]+?});/); const heartbeatMatch = constantsContent.match(/export const HEARTBEAT_INTERVAL\s*=\s*(\d+);/); + const maxMediaTimeMatch = constantsContent.match(/export const MAX_MEDIA_TIME\s*=\s*(\d+);/); if (!eventsMatch) { throw new Error('CRITICAL: Could not find EVENTS object in shared/constants.js'); @@ -114,9 +115,13 @@ function copyExtensionFiles(targetDir, browserName) { if (!heartbeatMatch) { throw new Error('CRITICAL: Could not find HEARTBEAT_INTERVAL in shared/constants.js'); } + if (!maxMediaTimeMatch) { + throw new Error('CRITICAL: Could not find MAX_MEDIA_TIME in shared/constants.js'); + } const eventsObject = eventsMatch[1]; const heartbeatVal = heartbeatMatch[1]; + const maxMediaTimeVal = maxMediaTimeMatch[1]; const items = fs.readdirSync(extDir); for (const item of items) { @@ -136,7 +141,7 @@ function copyExtensionFiles(targetDir, browserName) { const eStart = '// --- SHARED_EVENTS_INJECT_START ---'; const eEnd = '// --- SHARED_EVENTS_INJECT_END ---'; const ePattern = new RegExp(`${eStart}[\\s\\S]+?${eEnd}`); - const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n const EVENTS = ${eventsObject};\n ${eEnd}`; + const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n const EVENTS = ${eventsObject};\n const MAX_MEDIA_TIME = ${maxMediaTimeVal};\n ${eEnd}`; content = replaceRequiredBlock(content, ePattern, eRep, 'Event injection'); diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index 3b7a509..a8ca1c3 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -22,11 +22,14 @@ async function c() { } function s(ws, evt, d={}) { ws.send(`42${JSON.stringify([evt,d])}`); } function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith('42') ? JSON.parse(r.substring(2)) : r; } return new Promise((resolve, reject) => { const t=setTimeout(()=>reject(Error('timeout')),3e3); const h=(d)=>{clearTimeout(t);ws.removeListener('message',h);const r=d.toString();resolve(r.startsWith('42')?JSON.parse(r.substring(2)):r);};ws.on('message',h);}); } -async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-stsetTimeout(r,50));} throw Error(`wait:${evt}`); } +async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-stsetTimeout(r,50));} throw Error(`wait:${evt}`); } async function j(ws, rid, pid, pw=null, clientCapabilities=undefined) { s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0',clientCapabilities}); - assert.equal((await a(ws))[0],'room_data'); + const [event, data] = await a(ws); + assert.equal(event,'room_data'); + return data; } +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; } // Test suite opens >10 connections/min — clear the IP connection counter so the // connection rate limiter doesn't mask test failures (test-only, never at runtime). @@ -76,10 +79,282 @@ try { 'ROOM_DATA advertises the host-control capability'); assert.ok(capData.capabilities.includes('chat'), 'ROOM_DATA advertises the chat capability'); assert.ok(capData.capabilities.includes('chat-v1'), 'ROOM_DATA advertises the versioned chat capability'); + assert.ok(capData.capabilities.includes('media-state-v1'), 'ROOM_DATA advertises canonical media state v1'); + assert.equal(capData.mediaState, null, 'a new room starts without invented canonical media state'); assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history'); close(); resetConnectionRate(); + // --- Mixed-version rollout: pre-media-state extension + current extension --- + // Legacy intentionally omits clientCapabilities entirely and uses only the + // pre-feature JOIN/PLAY/PAUSE/SEEK/Force Sync wire contract. The current + // client advertises only its existing chat capability; media-state support + // is a relay capability and is never required for server acquisition. + const mixedMediaRid = 'mixed-media-'+Date.now(); + const legacyMedia = await c(), currentMedia = await c(); + const legacyRoomData = await j(legacyMedia, mixedMediaRid, 'legacy-media'); + const currentRoomData = await j(currentMedia, mixedMediaRid, 'current-media', null, ['chat-v1']); + assert.equal(legacyRoomData.roomId, mixedMediaRid, 'legacy ROOM_DATA keeps roomId type/meaning'); + assert.ok(Array.isArray(legacyRoomData.peers), 'legacy ROOM_DATA keeps peers array'); + assert.equal(typeof legacyRoomData.controlMode, 'string', 'legacy ROOM_DATA keeps controlMode type'); + assert.ok(Array.isArray(legacyRoomData.controllers), 'legacy ROOM_DATA keeps controllers array'); + assert.equal(legacyRoomData.mediaState, null, 'new ROOM_DATA field is additive and initially null'); + assert.ok(currentRoomData.capabilities.includes('media-state-v1'), 'current client sees relay capability'); + legacyMedia._m.length = currentMedia._m.length = 0; + + const assertUnchangedMediaWire = (payload, label) => { + for (const field of ['revision', 'mediaState', 'updatedAt', 'updatedBy']) { + assert.equal(payload[field], undefined, `${label} does not add canonical field ${field}`); + } + }; + + // Legacy -> current: ordinary live relay is unchanged while internal state advances. + s(legacyMedia, 'play', { currentTime: 100, seq: 1, actionTimestamp: 1001 }); + const legacyPlayRelay = await w(currentMedia, 'play'); + assert.equal(legacyPlayRelay.currentTime, 100); + assert.equal(legacyPlayRelay.senderId, 'legacy-media'); + assertUnchangedMediaWire(legacyPlayRelay, 'legacy PLAY relay'); + assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, { + revision: 1, + playbackState: 'playing', + currentTime: 100, + updatedAt: mod.rooms.get(mixedMediaRid).mediaState.updatedAt, + updatedBy: 'legacy-media' + }, 'legacy PLAY canonicalizes without a media-state client capability'); + + s(legacyMedia, 'seek', { currentTime: 1200, targetTime: 1200, seq: 2, actionTimestamp: 1002 }); + const legacySeekRelay = await w(currentMedia, 'seek'); + assert.equal(legacySeekRelay.currentTime, 1200); + assert.equal(legacySeekRelay.targetTime, 1200); + assertUnchangedMediaWire(legacySeekRelay, 'legacy SEEK relay'); + assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, { + revision: 2, + playbackState: 'playing', + currentTime: 1200, + updatedAt: mod.rooms.get(mixedMediaRid).mediaState.updatedAt, + updatedBy: 'legacy-media' + }); + + s(legacyMedia, 'pause', { currentTime: 1200, seq: 3, actionTimestamp: 1003 }); + const legacyPauseRelay = await w(currentMedia, 'pause'); + assert.equal(legacyPauseRelay.currentTime, 1200); + assertUnchangedMediaWire(legacyPauseRelay, 'legacy PAUSE relay'); + assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, { + revision: 3, + playbackState: 'paused', + currentTime: 1200, + updatedAt: mod.rooms.get(mixedMediaRid).mediaState.updatedAt, + updatedBy: 'legacy-media' + }); + + // Current -> legacy: old receive path sees the same ordinary events and fields. + s(currentMedia, 'play', { currentTime: 1300, seq: 1, actionTimestamp: 2001 }); + const currentPlayRelay = await w(legacyMedia, 'play'); + assert.equal(currentPlayRelay.currentTime, 1300); + assertUnchangedMediaWire(currentPlayRelay, 'current PLAY relay to legacy client'); + s(currentMedia, 'seek', { currentTime: 1400, targetTime: 1400, seq: 2, actionTimestamp: 2002 }); + const currentSeekRelay = await w(legacyMedia, 'seek'); + assert.equal(currentSeekRelay.targetTime, 1400); + assertUnchangedMediaWire(currentSeekRelay, 'current SEEK relay to legacy client'); + + // Legacy Force Sync needs no new target on EXECUTE, event or ACK. Canonical + // bookkeeping remains internal and commits only after the existing execute. + const beforeLegacyPrepare = { ...mod.rooms.get(mixedMediaRid).mediaState }; + s(legacyMedia, 'force_sync_prepare', { targetTime: 1600, seq: 4, actionTimestamp: 1004 }); + const legacyPrepareRelay = await w(currentMedia, 'force_sync_prepare'); + assert.equal(legacyPrepareRelay.targetTime, 1600); + assertUnchangedMediaWire(legacyPrepareRelay, 'legacy Force Sync PREPARE relay'); + assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, beforeLegacyPrepare, + 'legacy PREPARE remains choreography and does not commit canonical state'); + s(currentMedia, 'force_sync_ack', { seq: 3 }); + const currentAckRelay = await w(legacyMedia, 'force_sync_ack'); + assert.equal(currentAckRelay.senderId, 'current-media'); + assertUnchangedMediaWire(currentAckRelay, 'existing Force Sync ACK relay'); + s(legacyMedia, 'force_sync_execute', { seq: 5, actionTimestamp: 1005 }); + const legacyExecuteRelay = await w(currentMedia, 'force_sync_execute'); + assert.equal(legacyExecuteRelay.targetTime, undefined, 'legacy EXECUTE still requires no target field'); + assertUnchangedMediaWire(legacyExecuteRelay, 'legacy Force Sync EXECUTE relay'); + assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, { + revision: beforeLegacyPrepare.revision + 1, + playbackState: 'playing', + currentTime: 1600, + updatedAt: mod.rooms.get(mixedMediaRid).mediaState.updatedAt, + updatedBy: 'legacy-media' + }, 'legacy Force Sync EXECUTE commits only internal canonical state'); + + // Host Control remains the sole authorization chokepoint, independent of + // media-state client knowledge. + s(legacyMedia, 'set_control_mode', { controlMode: 'host-only' }); + await w(legacyMedia, 'control_mode'); + await w(currentMedia, 'control_mode'); + legacyMedia._m.length = currentMedia._m.length = 0; + const mixedHostBaseline = { ...mod.rooms.get(mixedMediaRid).mediaState }; + s(currentMedia, 'seek', { currentTime: 1650, targetTime: 1650, seq: 4, actionTimestamp: 2004 }); + let mixedGuestRejected = false; + try { await w(legacyMedia, 'seek', 600); } catch { mixedGuestRejected = true; } + assert.ok(mixedGuestRejected, 'current guest command remains rejected in mixed host-only room'); + assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, mixedHostBaseline, + 'rejected mixed-version guest command does not mutate or revise canonical state'); + + s(legacyMedia, 'seek', { currentTime: 1700, targetTime: 1700, seq: 6, actionTimestamp: 1006 }); + const legacyHostSeek = await w(currentMedia, 'seek'); + assert.equal(legacyHostSeek.targetTime, 1700, 'allowed legacy controller command relays normally'); + assert.equal(mod.rooms.get(mixedMediaRid).mediaState.updatedBy, 'legacy-media'); + assert.equal(mod.rooms.get(mixedMediaRid).mediaState.currentTime, 1700); + + // Make the final stable intent legacy-owned, then reconnect only the current client. + s(legacyMedia, 'pause', { currentTime: 1700, seq: 7, actionTimestamp: 1007 }); + await w(currentMedia, 'pause'); + const legacyFinalRevision = mod.rooms.get(mixedMediaRid).mediaState.revision; + const currentMediaRejoin = await c(); + const mixedRejoinData = await j(currentMediaRejoin, mixedMediaRid, 'current-media', null, ['chat-v1']); + assert.equal(mixedRejoinData.mediaState.revision, legacyFinalRevision); + assert.equal(mixedRejoinData.mediaState.playbackState, 'paused'); + assert.equal(mixedRejoinData.mediaState.currentTime, 1700); + assert.equal(mixedRejoinData.mediaState.updatedBy, 'legacy-media', + 'current reconnect snapshot reflects the legacy client latest accepted intent'); + assert.equal(legacyMedia._m.some(raw => raw.includes('media_state')), false, + 'legacy client receives no new canonical event or ACK requirement'); + close(); + resetConnectionRate(); + + // --- Canonical Media State v1: late join, pause, seek and reconnect --- + const msrid = 'media-state-'+Date.now(); + const msa = await c(); + const initialMediaRoom = await j(msa, msrid, 'msa'); + assert.equal(initialMediaRoom.mediaState, null, 'media state is initially null'); + + s(msa, 'play', { currentTime: 100, revision: 999, updatedBy: 'spoofed' }); + await delay(120); + const msb = await c(); + const playingJoin = await j(msb, msrid, 'msb'); + assert.equal(playingJoin.mediaState.revision, 1, 'first accepted PLAY creates revision 1'); + assert.equal(playingJoin.mediaState.playbackState, 'playing'); + assert.ok(playingJoin.mediaState.currentTime >= 100.08 && playingJoin.mediaState.currentTime < 101, + `playing late join receives projected position (${playingJoin.mediaState.currentTime})`); + assert.equal(playingJoin.mediaState.updatedBy, 'msa', 'updatedBy is server-tracked identity'); + + s(msa, 'pause', { currentTime: 150 }); + await delay(40); + const pausedRevision = mod.rooms.get(msrid).mediaState.revision; + await delay(100); + const msc = await c(); + const pausedJoin = await j(msc, msrid, 'msc'); + assert.equal(pausedJoin.mediaState.revision, pausedRevision); + assert.equal(pausedJoin.mediaState.playbackState, 'paused'); + assert.equal(pausedJoin.mediaState.currentTime, 150, 'paused late join position does not advance'); + + s(msa, 'seek', { currentTime: 500, targetTime: 600 }); + await delay(40); + const seekState = mod.rooms.get(msrid).mediaState; + assert.equal(seekState.currentTime, 600, 'SEEK uses targetTime rather than currentTime'); + assert.equal(seekState.playbackState, 'paused', 'SEEK preserves canonical playback state'); + + s(msa, 'play', { currentTime: 1800 }); + await delay(60); + const reconnect1 = await c(); + const reconnectFirst = await j(reconnect1, msrid, 'reconnect'); + reconnect1.close(); + await delay(120); + const beforePeerDedupe = { ...mod.rooms.get(msrid).mediaState }; + const reconnect2 = await c(); + const reconnectSecond = await j(reconnect2, msrid, 'reconnect'); + assert.equal(reconnectSecond.mediaState.revision, reconnectFirst.mediaState.revision, + 'lazy clock projection does not increment revision'); + assert.ok(reconnectSecond.mediaState.currentTime > reconnectFirst.mediaState.currentTime, + 'reconnect receives a newly projected playing position'); + assert.deepEqual(mod.rooms.get(msrid).mediaState, beforePeerDedupe, + 'peer dedupe/reconnect does not reset or revise canonical state'); + + const beforeOrderingRevision = mod.rooms.get(msrid).mediaState.revision; + s(msa, 'seek', { targetTime: 100 }); + await delay(30); + s(msb, 'seek', { targetTime: 200 }); + await delay(40); + const orderedState = mod.rooms.get(msrid).mediaState; + assert.equal(orderedState.revision, beforeOrderingRevision + 2, 'accepted controllers increment revision in server order'); + assert.equal(orderedState.currentTime, 200, 'last accepted controller wins'); + assert.equal(orderedState.updatedBy, 'msb'); + s(msb, 'seek', { targetTime: 300 }); + await delay(30); + s(msa, 'seek', { targetTime: 400 }); + await delay(40); + const reverseOrderedState = mod.rooms.get(msrid).mediaState; + assert.equal(reverseOrderedState.revision, orderedState.revision + 2); + assert.equal(reverseOrderedState.currentTime, 400, 'reversing server order reverses the winning controller'); + assert.equal(reverseOrderedState.updatedBy, 'msa'); + + // Heartbeats remain observations and cannot rewrite canonical intent. + const beforeHeartbeat = { ...reverseOrderedState }; + s(msa, 'peer_status', { playbackState: 'paused', currentTime: 999 }); + await delay(40); + assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeHeartbeat, 'PEER_STATUS does not mutate canonical state'); + + // Force Sync PREPARE is choreography; matching EXECUTE commits its final target. + s(msa, 'force_sync_prepare', { targetTime: 700 }); + await delay(40); + assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeHeartbeat, 'Force Sync PREPARE does not mutate canonical state'); + s(msa, 'force_sync_execute', {}); + await delay(40); + const forceSyncState = mod.rooms.get(msrid).mediaState; + assert.equal(forceSyncState.revision, beforeHeartbeat.revision + 1); + assert.equal(forceSyncState.playbackState, 'playing'); + assert.equal(forceSyncState.currentTime, 700); + + // Active Episode Lobby is additive ROOM_DATA state and does not rewrite mediaState. + s(msa, 'episode_lobby', { expectedTitle: 'S02E03' }); + await delay(40); + const beforeLobbyJoin = { ...mod.rooms.get(msrid).mediaState }; + const msLobbyJoiner = await c(); + const lobbyRoomData = await j(msLobbyJoiner, msrid, 'ms-lobby'); + assert.equal(lobbyRoomData.activeLobby.expectedTitle, 'S02E03'); + assert.equal(lobbyRoomData.mediaState.revision, beforeLobbyJoin.revision); + assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeLobbyJoin, 'Episode Lobby does not mutate canonical state'); + s(msa, 'leave_room', {}); + await delay(50); + assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeLobbyJoin, + 'host disconnect/reassignment preserves canonical state and revision'); + close(); + resetConnectionRate(); + + // --- Canonical Media State v1: Host Control and validation chokepoints --- + const msgateRid = 'media-gate-'+Date.now(); + const msgHost = await c(), msgGuest = await c(), msgUnjoined = await c(); + await j(msgHost, msgateRid, 'msg-host'); + await j(msgGuest, msgateRid, 'msg-guest'); + s(msgHost, 'play', { currentTime: 10 }); + await delay(40); + s(msgHost, 'set_control_mode', { controlMode: 'host-only' }); + await w(msgGuest, 'control_mode'); + msgHost._m.length = msgGuest._m.length = 0; + const gatedBaseline = { ...mod.rooms.get(msgateRid).mediaState }; + + s(msgGuest, 'seek', { targetTime: 900 }); + await delay(80); + assert.deepEqual(mod.rooms.get(msgateRid).mediaState, gatedBaseline, + 'host-only rejected guest cannot mutate canonical state or revision'); + + s(msgHost, 'seek', { targetTime: 800 }); + await delay(40); + assert.equal(mod.rooms.get(msgateRid).mediaState.revision, gatedBaseline.revision + 1); + assert.equal(mod.rooms.get(msgateRid).mediaState.currentTime, 800); + + const validationBaseline = { ...mod.rooms.get(msgateRid).mediaState }; + for (const invalidPayload of [{ targetTime: null }, { targetTime: '50' }, { targetTime: {} }, {}]) { + s(msgHost, 'seek', invalidPayload); + } + s(msgUnjoined, 'seek', { roomId: msgateRid, targetTime: 999, updatedBy: 'msg-host', revision: 999999 }); + await delay(80); + assert.deepEqual(mod.rooms.get(msgateRid).mediaState, validationBaseline, + 'invalid and unjoined/cross-room payloads cannot corrupt canonical state'); + + s(msgHost, 'leave_room', {}); + s(msgGuest, 'leave_room', {}); + await delay(80); + assert.equal(mod.rooms.has(msgateRid), false, 'empty-room cleanup removes canonical state with the room'); + close(); + resetConnectionRate(); + // --- Encrypted chat is a live-only canonical relay --- const chatRoom = 'chat-'+Date.now(); const chat1 = await c(), chat2 = await c(); diff --git a/server/README.md b/server/README.md index 320db91..545e73e 100644 --- a/server/README.md +++ b/server/README.md @@ -104,5 +104,6 @@ For focused server checks, see `scripts/test-server-ops.mjs`, `scripts/test-serv | File | Purpose | |---|---| | `index.js` | Express + Socket.IO server: room management, relay loop, graceful shutdown | +| `media-state.js` | Canonical room media clock, control updates, and projected ROOM_DATA snapshots | | `rate-limiter.js` | Connection, event, health, and auth rate limiting with 6 functions + cleanup intervals | | `ops.js` | Health endpoint helpers, metrics payload builder, auth validation | diff --git a/server/index.js b/server/index.js index e98da03..48500de 100644 --- a/server/index.js +++ b/server/index.js @@ -4,8 +4,13 @@ import { fileURLToPath } from 'url'; import { Server } from 'socket.io'; import crypto from 'crypto'; import dotenv from 'dotenv'; -import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js'; +import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, MAX_MEDIA_TIME } from '../shared/constants.js'; import { createChatEnvelope } from './chat.js'; +import { + commitForceSyncMediaState, + snapshotMediaState, + updateMediaStateFromControl +} from './media-state.js'; import { buildHealthPayload, checkCooldown, @@ -178,7 +183,8 @@ const SERVER_CAPABILITIES = [ CAPABILITIES.HOST_CONTROL, CAPABILITIES.CO_HOST, CAPABILITIES.CHAT, - CAPABILITIES.CHAT_V1 + CAPABILITIES.CHAT_V1, + CAPABILITIES.MEDIA_STATE_V1 ]; function normalizeClientCapabilities(value) { @@ -283,6 +289,7 @@ function removePeerFromRoom(socketId, roomId, reason) { // H-1: a leaving initiator strands the room's force-sync — release the // slot so a future controller's PREPARE can take over cleanly. if (room.forceSyncInitiator === peerId) room.forceSyncInitiator = null; + if (room.forceSyncTarget?.initiatorPeerId === peerId) room.forceSyncTarget = null; if (room.hostPeerId === peerId) { // Owner left → reassign owner + fall back to 'everyone' so the room is // never stuck locked, and reset the controller set to just the new owner. @@ -445,7 +452,13 @@ io.on('connection', (socket) => { // controller's FORCE_SYNC_EXECUTE through the host-only gate — without // it, demoting a co-host mid-force-sync would drop their EXECUTE and // leave every peer stuck paused. - forceSyncInitiator: null + forceSyncInitiator: null, + // Canonical Media State v1 is lazy, room-local and absent until + // the first accepted command establishes a trustworthy position. + mediaState: null, + // PREPARE is choreography, not stable room intent. Retain its + // validated target only so the matching EXECUTE can commit it. + forceSyncTarget: null }; rooms.set(roomId, room); createdByMe = true; @@ -528,6 +541,7 @@ io.on('connection', (socket) => { peerToSocket.set(peerId, socket.id); socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, username: username || null, tabTitle: tabTitle || null, mediaTitle: mediaTitle || null, status: 'joined' }); + const snapshotAt = Date.now(); socket.emit(EVENTS.ROOM_DATA, { roomId, peers: Array.from(room.peers).map(sid => room.peerData.get(sid)), @@ -535,6 +549,7 @@ io.on('connection', (socket) => { hostPeerId: room.hostPeerId || null, controlMode: room.controlMode || CONTROL_MODES.EVERYONE, controllers: room.controllers ? Array.from(room.controllers) : [], + mediaState: snapshotMediaState(room.mediaState, snapshotAt), capabilities: SERVER_CAPABILITIES }); log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`); @@ -617,7 +632,7 @@ io.on('connection', (socket) => { tabTitle: data.tabTitle === null ? null : (data.tabTitle !== undefined ? (clamp(data.tabTitle, 100) ?? existing.tabTitle) : existing.tabTitle), mediaTitle: data.mediaTitle === null ? null : (data.mediaTitle !== undefined ? (clamp(data.mediaTitle, 100) ?? existing.mediaTitle) : existing.mediaTitle), playbackState: data.playbackState !== undefined ? (validState(data.playbackState) ?? existing.playbackState) : existing.playbackState, - currentTime: data.currentTime === null ? null : (data.currentTime !== undefined ? (clampNum(data.currentTime, 0, 86400) ?? existing.currentTime) : existing.currentTime), + currentTime: data.currentTime === null ? null : (data.currentTime !== undefined ? (clampNum(data.currentTime, 0, MAX_MEDIA_TIME) ?? existing.currentTime) : existing.currentTime), volume: data.volume !== undefined ? (clampNum(data.volume, 0, 1) ?? existing.volume) : existing.volume, muted: data.muted !== undefined ? (validBool(data.muted) ?? existing.muted) : existing.muted, desynced: data.desynced !== undefined ? (validBool(data.desynced) === true) : (existing.desynced || false), @@ -628,8 +643,8 @@ io.on('connection', (socket) => { const relayPayload = { senderId: mapping.peerId, seq: clampNum(data.seq, 0, Number.MAX_SAFE_INTEGER), - currentTime: data.currentTime === null ? null : clampNum(data.currentTime, 0, 86400), - targetTime: clampNum(data.targetTime, 0, 86400), + currentTime: data.currentTime === null ? null : clampNum(data.currentTime, 0, MAX_MEDIA_TIME), + targetTime: clampNum(data.targetTime, 0, MAX_MEDIA_TIME), playbackState: validState(data.playbackState), username: clamp(data.username, 30), tabTitle: data.tabTitle === null ? null : clamp(data.tabTitle, 100), @@ -645,6 +660,32 @@ io.on('connection', (socket) => { }; // Strip undefined keys for clean wire format Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]); + + // Canonical Media State v1: mutate only after rate limiting, + // room mapping, Host Control authorization and sanitization. + // Heartbeats remain observational and never enter this path. + const mediaStateNow = Date.now(); + updateMediaStateFromControl(room, eventName, relayPayload, mapping.peerId, { + now: mediaStateNow, + senderPlaybackState: existing.playbackState + }); + if (eventName === EVENTS.FORCE_SYNC_PREPARE) { + room.forceSyncTarget = Number.isFinite(relayPayload.targetTime) + ? { initiatorPeerId: mapping.peerId, targetTime: relayPayload.targetTime } + : null; + } else if (eventName === EVENTS.FORCE_SYNC_EXECUTE) { + const forceSyncTarget = room.forceSyncTarget; + if (forceSyncTarget?.initiatorPeerId === mapping.peerId) { + commitForceSyncMediaState( + room, + forceSyncTarget.targetTime, + mapping.peerId, + mediaStateNow + ); + } + room.forceSyncTarget = null; + } + socket.to(mapping.roomId).emit(eventName, relayPayload); // --- Side-effects: Server-side Episode Lobby Tracking --- diff --git a/server/media-state.js b/server/media-state.js new file mode 100644 index 0000000..3acd5fd --- /dev/null +++ b/server/media-state.js @@ -0,0 +1,87 @@ +import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js'; + +function clampMediaTime(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + return Math.max(0, Math.min(MAX_MEDIA_TIME, value)); +} + +export function effectiveMediaPosition(mediaState, now = Date.now()) { + if (!mediaState) return null; + const currentTime = clampMediaTime(mediaState.currentTime); + if (currentTime === null) return null; + if (mediaState.playbackState !== 'playing') return currentTime; + const updatedAt = typeof mediaState.updatedAt === 'number' && Number.isFinite(mediaState.updatedAt) + ? mediaState.updatedAt + : now; + return clampMediaTime(currentTime + Math.max(0, now - updatedAt) / 1000); +} + +export function snapshotMediaState(mediaState, now = Date.now()) { + if (!mediaState) return null; + const currentTime = effectiveMediaPosition(mediaState, now); + if (currentTime === null + || !Number.isSafeInteger(mediaState.revision) + || mediaState.revision < 1 + || (mediaState.playbackState !== 'playing' && mediaState.playbackState !== 'paused')) { + return null; + } + return { + revision: mediaState.revision, + playbackState: mediaState.playbackState, + currentTime, + updatedBy: mediaState.updatedBy + }; +} + +function commitMediaState(room, playbackState, currentTime, updatedBy, now) { + const normalizedTime = clampMediaTime(currentTime); + if (normalizedTime === null + || (playbackState !== 'playing' && playbackState !== 'paused') + || typeof updatedBy !== 'string' + || !updatedBy) { + return false; + } + room.mediaState = { + revision: (room.mediaState?.revision || 0) + 1, + playbackState, + currentTime: normalizedTime, + updatedAt: now, + updatedBy + }; + return true; +} + +export function updateMediaStateFromControl(room, eventName, payload, senderPeerId, { + now = Date.now(), + senderPlaybackState = null +} = {}) { + if (!room || !payload || typeof payload !== 'object') return false; + + if (eventName === EVENTS.PLAY || eventName === EVENTS.PAUSE) { + const eventPosition = clampMediaTime(payload.currentTime); + const currentTime = eventPosition ?? effectiveMediaPosition(room.mediaState, now); + if (currentTime === null) return false; + return commitMediaState( + room, + eventName === EVENTS.PLAY ? 'playing' : 'paused', + currentTime, + senderPeerId, + now + ); + } + + if (eventName === EVENTS.SEEK) { + const targetTime = clampMediaTime(payload.targetTime) ?? clampMediaTime(payload.currentTime); + const playbackState = payload.playbackState === 'playing' || payload.playbackState === 'paused' + ? payload.playbackState + : (room.mediaState?.playbackState || senderPlaybackState); + if (targetTime === null) return false; + return commitMediaState(room, playbackState, targetTime, senderPeerId, now); + } + + return false; +} + +export function commitForceSyncMediaState(room, targetTime, senderPeerId, now = Date.now()) { + return commitMediaState(room, 'playing', targetTime, senderPeerId, now); +} diff --git a/server/media-state.test.mjs b/server/media-state.test.mjs new file mode 100644 index 0000000..ce84a17 --- /dev/null +++ b/server/media-state.test.mjs @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js'; +import { + commitForceSyncMediaState, + effectiveMediaPosition, + snapshotMediaState, + updateMediaStateFromControl +} from './media-state.js'; + +function room(mediaState = null) { + return { mediaState }; +} + +describe('canonical media state', () => { + it('starts null and snapshots null', () => { + expect(snapshotMediaState(null, 1000)).toBeNull(); + }); + + it('projects playing state and clamps elapsed time', () => { + const state = { revision: 1, playbackState: 'playing', currentTime: 10, updatedAt: 1000, updatedBy: 'a' }; + expect(effectiveMediaPosition(state, 3500)).toBe(12.5); + expect(effectiveMediaPosition(state, 500)).toBe(10); + expect(effectiveMediaPosition({ ...state, currentTime: MAX_MEDIA_TIME }, 3500)).toBe(MAX_MEDIA_TIME); + }); + + it('keeps paused state fixed', () => { + const state = { revision: 1, playbackState: 'paused', currentTime: 10, updatedAt: 1000, updatedBy: 'a' }; + expect(effectiveMediaPosition(state, 601000)).toBe(10); + expect(snapshotMediaState(state, 601000)).toEqual({ + revision: 1, + playbackState: 'paused', + currentTime: 10, + updatedBy: 'a' + }); + }); + + it('initializes and updates PLAY with server-owned revisions', () => { + const target = room(); + expect(updateMediaStateFromControl(target, EVENTS.PLAY, { currentTime: 12, revision: 999 }, 'a', { now: 1000 })).toBe(true); + expect(target.mediaState).toEqual({ revision: 1, playbackState: 'playing', currentTime: 12, updatedAt: 1000, updatedBy: 'a' }); + expect(updateMediaStateFromControl(target, EVENTS.PLAY, { currentTime: 20 }, 'b', { now: 2000 })).toBe(true); + expect(target.mediaState.revision).toBe(2); + expect(target.mediaState.updatedBy).toBe('b'); + }); + + it('does not invent an initial PLAY position and preserves a known effective position', () => { + const target = room(); + expect(updateMediaStateFromControl(target, EVENTS.PLAY, {}, 'a', { now: 1000 })).toBe(false); + expect(target.mediaState).toBeNull(); + target.mediaState = { revision: 1, playbackState: 'playing', currentTime: 5, updatedAt: 1000, updatedBy: 'a' }; + expect(updateMediaStateFromControl(target, EVENTS.PLAY, {}, 'a', { now: 3000 })).toBe(true); + expect(target.mediaState.currentTime).toBe(7); + }); + + it('freezes PAUSE at its event or effective canonical position', () => { + const target = room({ revision: 1, playbackState: 'playing', currentTime: 10, updatedAt: 1000, updatedBy: 'a' }); + expect(updateMediaStateFromControl(target, EVENTS.PAUSE, {}, 'a', { now: 4000 })).toBe(true); + expect(target.mediaState).toEqual({ revision: 2, playbackState: 'paused', currentTime: 13, updatedAt: 4000, updatedBy: 'a' }); + expect(effectiveMediaPosition(target.mediaState, 9000)).toBe(13); + }); + + it('uses SEEK targetTime, preserves playback state, and lets the second controller win', () => { + const target = room({ revision: 3, playbackState: 'playing', currentTime: 10, updatedAt: 1000, updatedBy: 'a' }); + expect(updateMediaStateFromControl(target, EVENTS.SEEK, { currentTime: 50, targetTime: 100 }, 'a', { now: 2000 })).toBe(true); + expect(target.mediaState).toMatchObject({ revision: 4, playbackState: 'playing', currentTime: 100, updatedBy: 'a' }); + expect(updateMediaStateFromControl(target, EVENTS.SEEK, { targetTime: 200 }, 'b', { now: 2001 })).toBe(true); + expect(target.mediaState).toMatchObject({ revision: 5, currentTime: 200, updatedBy: 'b' }); + }); + + it('uses an observed sender state only to establish an otherwise ambiguous first SEEK', () => { + const target = room(); + expect(updateMediaStateFromControl(target, EVENTS.SEEK, { targetTime: 50 }, 'a', { now: 1000 })).toBe(false); + expect(updateMediaStateFromControl(target, EVENTS.SEEK, { targetTime: 50 }, 'a', { now: 1000, senderPlaybackState: 'paused' })).toBe(true); + expect(target.mediaState).toMatchObject({ revision: 1, playbackState: 'paused', currentTime: 50 }); + }); + + it('rejects non-finite/missing controls without corruption and clamps existing protocol bounds', () => { + const original = { revision: 2, playbackState: 'paused', currentTime: 30, updatedAt: 1000, updatedBy: 'a' }; + for (const payload of [{ targetTime: NaN }, { targetTime: Infinity }, { targetTime: '50' }, {}]) { + const target = room({ ...original }); + expect(updateMediaStateFromControl(target, EVENTS.SEEK, payload, 'b', { now: 2000 })).toBe(false); + expect(target.mediaState).toEqual(original); + } + const low = room({ ...original }); + updateMediaStateFromControl(low, EVENTS.SEEK, { targetTime: -5 }, 'b', { now: 2000 }); + expect(low.mediaState.currentTime).toBe(0); + const high = room({ ...original }); + updateMediaStateFromControl(high, EVENTS.SEEK, { targetTime: MAX_MEDIA_TIME + 5 }, 'b', { now: 2000 }); + expect(high.mediaState.currentTime).toBe(MAX_MEDIA_TIME); + }); + + it('commits Force Sync only at execute time', () => { + const target = room({ revision: 4, playbackState: 'paused', currentTime: 90, updatedAt: 1000, updatedBy: 'a' }); + expect(commitForceSyncMediaState(target, 500, 'b', 2000)).toBe(true); + expect(target.mediaState).toEqual({ revision: 5, playbackState: 'playing', currentTime: 500, updatedAt: 2000, updatedBy: 'b' }); + }); +}); diff --git a/shared/README.md b/shared/README.md index d90bd79..8b45e3a 100644 --- a/shared/README.md +++ b/shared/README.md @@ -31,6 +31,7 @@ Browser extensions cannot import files outside their own root directory, so the - `CONTROL_MODES.HOST_ONLY`: only the host and promoted controllers may move playback. - `CAPABILITIES.HOST_CONTROL`: relay supports host-only room authority. - `CAPABILITIES.CO_HOST`: relay supports promoted controller peers. +- `CAPABILITIES.MEDIA_STATE_V1`: relay exposes canonical room playback recovery snapshots. Clients should enable capability-gated UI only when the relay advertises the matching flag in `room_data.capabilities`. @@ -64,6 +65,7 @@ For the complete event list, read the `EVENTS` object in [`constants.js`](consta - `HEARTBEAT_INTERVAL`: content heartbeat interval in milliseconds. - `FORCE_SYNC_TIMEOUT`: max wait for force-sync ACKs. - `EPISODE_LOBBY_TIMEOUT`: max wait for episode-lobby readiness. +- `MAX_MEDIA_TIME`: shared relay/extension upper bound for synchronized media positions. ## Do Not Break diff --git a/shared/constants.js b/shared/constants.js index 5ed3a66..7165ff7 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -82,9 +82,13 @@ export const CAPABILITIES = { HOST_CONTROL: 'host-control', CO_HOST: 'co-host', // owner promotes guests to additional controllers CHAT: 'chat', // legacy server capability used by the first chat beta - CHAT_V1: 'chat-v1' // versioned client/server chat wire contract + CHAT_V1: 'chat-v1', // versioned client/server chat wire contract + MEDIA_STATE_V1: 'media-state-v1' // server-authoritative room playback recovery snapshot }; +// Relay and extension media-time validation must use the same upper bound. +export const MAX_MEDIA_TIME = 86400; + export const HEARTBEAT_INTERVAL = 15000; // 15s export const FORCE_SYNC_TIMEOUT = 8500; // 8.5s timeout for force sync ACKs (must be > content.js poll timeout of 8s) export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 62e719e..a2b75cd 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -54,6 +54,15 @@ async function getExtensionState(context, extensionId, message) { )); } +async function applyCanonicalMediaState(context, extensionId, tabId, mediaState) { + return withExtensionPage(context, extensionId, page => page.evaluate(async ({ tabId, mediaState }) => { + return chrome.tabs.sendMessage(tabId, { + type: 'APPLY_CANONICAL_MEDIA_STATE', + mediaState + }); + }, { tabId, mediaState })); +} + async function setAudioSettings(context, extensionId, settings) { return withExtensionPage(context, extensionId, page => page.evaluate( value => chrome.storage.local.set({ audioSettings: value }), @@ -263,6 +272,46 @@ test('applies remote play, pause and seek to the framed player', async ({ contex ).toBeGreaterThan(5); }); +test('applies canonical recovery without echoing media commands or activity', async ({ context, extensionId, baseURL }) => { + const url = `${baseURL}/pages/simple-player.html`; + const page = await context.newPage(); + await page.goto(url); + await page.waitForFunction(() => window.__fixtureReady === true); + + const { tabId } = await selectTargetTab(context, extensionId, url); + await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true'); + const historyBefore = await getExtensionState(context, extensionId, { type: 'GET_HISTORY' }); + + const playingApply = await applyCanonicalMediaState(context, extensionId, tabId, { + revision: 10, + playbackState: 'playing', + currentTime: 6, + updatedBy: 'peer-a' + }); + expect(playingApply).toMatchObject({ status: 'applied', revision: 10 }); + await expect.poll(() => page.locator('#player').evaluate(video => ({ + paused: video.paused, + currentTime: video.currentTime + }))).toMatchObject({ paused: false }); + await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(5); + + const pausedApply = await applyCanonicalMediaState(context, extensionId, tabId, { + revision: 11, + playbackState: 'paused', + currentTime: 10, + updatedBy: 'peer-a' + }); + expect(pausedApply).toMatchObject({ status: 'applied', revision: 11 }); + await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(true); + await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(9); + + // Wait past seek debounce and play/pause coalescing windows. A leaked native + // echo would have reached background.js and appeared as user activity by now. + await page.waitForTimeout(700); + const historyAfter = await getExtensionState(context, extensionId, { type: 'GET_HISTORY' }); + expect(historyAfter).toEqual(historyBefore); +}); + test('reinjects after the target tab navigates', async ({ context, extensionId, baseURL }) => { const first = `${baseURL}/pages/iframe-player.html`; const page = await context.newPage();