feat(sync): add canonical room media state

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