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