fix: make protocol docs match implementation

This commit is contained in:
Timo
2026-07-02 00:04:49 +02:00
parent e51e8ae436
commit 9fe83f89b2
2 changed files with 380 additions and 640 deletions
+282 -407
View File
@@ -1,495 +1,370 @@
# WebSocket Protocol Specification
# WebSocket Protocol Reference
## Overview
This document describes the relay behavior implemented by `server/index.js` and
the event names defined in `shared/constants.js`.
KoalaSync uses WebSocket for real-time communication between clients and the relay server. The protocol is based on Socket.IO events with a custom message format.
## Transport
- **Transport**: WebSocket only (no long-polling fallback)
- **Server URL**: `wss://syncserver.koalastuff.net` (official) or custom server
- **Protocol Version**: `1.0.0` (current), `MIN_VERSION` not enforced (backward compatible)
- **Authentication**: Optional server token for custom relays
- The relay uses Socket.IO v4 events over WebSocket.
- Long-polling is disabled (`transports: ['websocket']`, `allowUpgrades: false`).
- Messages are Socket.IO event packets whose payload is an event name plus an
object payload.
- The relay caps incoming Socket.IO message size at 4 KB.
## Protocol Basics
## Connection Handshake
### Message Format
The Socket.IO handshake must include:
All messages follow the Socket.IO v4 protocol format:
- Engine.IO packet type `42` (WEBSOCKET_MESSAGE)
- JSON-encoded array: `[eventName, payload]`
- `token`: must match `OFFICIAL_SERVER_TOKEN`.
- `version`: optional app version. If present, it must be a valid semver-like
string and not older than `MIN_VERSION` (default `1.0.0`).
Example: `42["play",{"currentTime":123.45}]`
If the token is invalid, the relay emits `error` and disconnects the socket.
If `version` is invalid or too old, the relay emits `error` and disconnects the
socket.
### Event Flow
After the socket is connected, `join_room` must include `protocolVersion`.
It must equal `PROTOCOL_VERSION` exactly. A mismatch emits `error` and rejects
the join attempt; it does not currently disconnect the socket.
```
Client → Server: JOIN_ROOM, PLAY, PAUSE, SEEK, etc.
Server → Client: ROOM_DATA, PEER_STATUS, CONTROL_MODE, etc.
Client ↔ Server: PING/PONG (latency measurement)
```
## Room Join
## Events Reference
### `join_room` (client -> server)
### Connection & Room Management
Payload:
#### JOIN_ROOM (Client → Server)
Join an existing room or create a new one.
**Payload:**
```json
{
"roomId": "string (alphanum + hyphens, 64 max)",
"peerId": "string (16 chars max)",
"username": "string (30 chars max)",
"password": "string (128 chars max, optional)",
"tabTitle": "string (100 chars max, optional)",
"mediaTitle": "string (100 chars max, optional)",
"protocolVersion": "string (16 chars max)"
"roomId": "string, sanitized to [A-Za-z0-9-], max 64",
"peerId": "string, max 16",
"username": "string, max 30",
"password": "string, max 128, optional",
"tabTitle": "string, max 100, optional",
"mediaTitle": "string, max 100, optional",
"protocolVersion": "string, max 16"
}
```
**Server Response:**
- Success: `ROOM_DATA` event with room state
- Error: `ERROR` event with message
Behavior:
**Rate Limit:** 10 attempts per IP per minute
- Creates the room if it does not exist and capacity allows it.
- The first peer becomes `hostPeerId`.
- Rooms may have an optional password hash.
- Joining with a duplicate `peerId` disconnects the previous socket for that peer.
- Joining the same room with the same socket and peer is ignored as a no-op.
- Switching rooms removes the socket from the old room first.
**Edge Cases:**
- Room ID sanitization (alphanumeric + hyphens only)
- Peer ID deduplication (kicks old socket for same peerId)
- Protocol version mismatch → disconnect
On success, the joining socket receives `room_data`.
Other room members receive `peer_status` with `status: "joined"`.
#### LEAVE_ROOM (Client → Server)
### `room_data` (server -> client)
Leave the current room.
Payload:
**Payload:** None
**Server Action:**
- Removes peer from room
- Broadcasts `PEER_STATUS` with `status: 'left'` to remaining peers
- If room becomes empty, deletes room
**Rate Limit:** 10 requests per socket per minute
**Edge Cases:**
- Host leaving → controlMode falls back to 'everyone'
- Last peer leaving → room deletion
#### ROOM_DATA (Server → Client)
Current room state snapshot sent on join or when room changes.
**Payload:**
```json
{
"roomId": "string",
"peers": [
{
"peerId": "string",
"username": "string",
"tabTitle": "string",
"mediaTitle": "string",
"playbackState": "playing" | "paused",
"currentTime": "number",
"volume": "number",
"muted": "boolean",
"lastHeartbeat": "timestamp"
}
],
"activeLobby": "object | null",
"hostPeerId": "string | null",
"controlMode": "everyone" | "host-only",
"controllers": ["string"],
"capabilities": ["string"]
"peers": ["peer state objects"],
"activeLobby": "object or null",
"hostPeerId": "string or null",
"controlMode": "everyone | host-only",
"controllers": ["peerId"],
"capabilities": ["host-control", "co-host"]
}
```
**Triggered by:**
- Successful JOIN_ROOM
- Room state changes (peer join/leave, mode change)
`room_data` is sent to the joining socket. It is not the general broadcast used
for every later room update.
#### ERROR (Server → Client)
## Room Leave
Error notification.
### `leave_room` (client -> server)
Payload: none.
Behavior:
- Rate-limited to 10 events per socket per minute.
- If the socket is mapped to a room, the relay removes it from that room.
- Remaining room members receive `peer_status` with `status: "left"` when the
peer is no longer represented by another socket.
- Empty rooms are deleted.
- If the host leaves and peers remain, the relay assigns the next peer as host,
falls back to `controlMode: "everyone"`, resets controllers to the new host,
and broadcasts `control_mode`.
Exceeding the `leave_room` limit is logged and ignored; the socket is not
disconnected for this specific limit.
## Relayed Room Events
The relay accepts and sanitizes these events, then emits the same event to other
peers in the room:
- `play`
- `pause`
- `seek`
- `peer_status`
- `force_sync_prepare`
- `force_sync_ack`
- `force_sync_execute`
- `episode_lobby`
- `episode_ready`
- `episode_lobby_cancel`
Relayed payload fields are sanitized and may include:
**Payload:**
```json
{
"message": "string"
}
```
**Common Errors:**
- "Incompatible protocol version"
- "Invalid password"
- "Room full"
- "Server capacity reached"
### Media Control Events
#### PLAY (Client → Server → Client)
Resume playback for all peers in the room.
**Payload:**
```json
{
"currentTime": "number (seconds)"
}
```
**Broadcast:** Relayed to all peers in room
**Gated in host-only mode:** Only controllers can initiate
#### PAUSE (Client → Server → Client)
Pause playback for all peers in the room.
**Payload:**
```json
{
"currentTime": "number (seconds)"
}
```
**Broadcast:** Relayed to all peers in room
**Gated in host-only mode:** Only controllers can initiate
#### SEEK (Client → Server → Client)
Seek to specific time in the media.
**Payload:**
```json
{
"targetTime": "number (seconds)"
}
```
**Broadcast:** Relayed to all peers in room
**Gated in host-only mode:** Only controllers can initiate
**Edge Cases:**
- Minimum seek delta (0.5s) to avoid spam
- Coalesced with subsequent seeks within 200ms
### Sync Coordination
#### PEER_STATUS (Client → Server → Client)
Heartbeat with current peer state.
**Payload:**
```json
{
"peerId": "string",
"username": "string",
"tabTitle": "string",
"mediaTitle": "string",
"playbackState": "playing" | "paused",
"currentTime": "number",
"volume": "number",
"senderId": "peerId of sender",
"seq": "number",
"currentTime": "number 0..86400 or null",
"targetTime": "number 0..86400",
"playbackState": "playing | paused",
"username": "string, max 30",
"tabTitle": "string, max 100 or null",
"mediaTitle": "string, max 100 or null",
"volume": "number 0..1",
"muted": "boolean",
"status": "joined" | "left" | "heartbeat"
"desynced": "boolean",
"peerId": "sender peerId",
"status": "string, max 16",
"expectedTitle": "string, max 100",
"title": "string, max 100",
"actionTimestamp": "number"
}
```
**Frequency:** Every 15 seconds (HEARTBEAT_INTERVAL)
Undefined fields are removed before relay. Raw client payloads are not forwarded.
**Purpose:**
- Keep-alive
- State synchronization
- Peer presence tracking
## Media Control
#### FORCE_SYNC_PREPARE (Client → Server → Client)
### `play`, `pause`, `seek`
Initiate force sync sequence.
These are room-moving actions. In `host-only` mode, the relay drops them unless
the sender is a controller.
Common payload fields:
- `currentTime` for `play`/`pause`.
- `targetTime` for `seek`.
- `seq` and `actionTimestamp` when the extension needs stale-command or ACK
handling.
The content script applies additional client-side filtering for noisy native
player events before it sends these events.
## Peer Status
### `peer_status`
Used for heartbeats and peer state updates. The extension sends it every
`HEARTBEAT_INTERVAL` while syncing is active.
Typical fields:
- `peerId`
- `username`
- `tabTitle`
- `mediaTitle`
- `playbackState`
- `currentTime`
- `volume`
- `muted`
- `desynced`
- `status`
The relay stores sanitized peer state and relays the sanitized update to other
peers.
## Force Sync
Force sync coordination is implemented primarily in the extension. The relay
sanitizes and relays the events.
### `force_sync_prepare`
Payload includes `targetTime`. The initiator waits for ACKs or for
`FORCE_SYNC_TIMEOUT` before sending `force_sync_execute`.
In `host-only` mode, only controllers may initiate it.
### `force_sync_ack`
The extension sends ACKs with peer identity and sequence data. The relay relays
them with the same sanitized relay envelope as other room events, including
`senderId`.
### `force_sync_execute`
Payload includes `targetTime`. 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.
## Episode Lobby
Episode lobby coordination is implemented primarily in the extension. The relay
tracks enough state to include `activeLobby` in `room_data` for later joiners.
### `episode_lobby`
Payload uses `expectedTitle`. The relay creates `activeLobby` when this field is
present and no lobby is already active.
In `host-only` mode, only controllers may initiate it.
### `episode_ready`
Payload may include `title`. The relay adds the sender to the active lobby's
ready list when a lobby exists.
### `episode_lobby_cancel`
Clears the active lobby and is relayed to peers. In `host-only` mode, only
controllers may initiate it.
## Host Control Mode
### `set_control_mode` (client -> server)
Payload:
**Payload:**
```json
{
"targetTime": "number (seconds)"
"controlMode": "everyone | host-only"
}
```
**Sequence:**
1. Initiator sends PREPARE
2. Server broadcasts PREPARE to all peers
3. Peers respond with FORCE_SYNC_ACK
4. Initiator sends FORCE_SYNC_EXECUTE after timeout or when all ACKs received
5. Server broadcasts EXECUTE to all peers
Only the room host may change the mode. Non-host attempts are ignored and the
sender receives the current `control_mode` snapshot.
**Timeout:** 8.5 seconds (FORCE_SYNC_TIMEOUT)
Mode changes are debounced per room with `CONTROL_MODE_MIN_INTERVAL_MS` (500 ms).
**Gated in host-only mode:** Only controllers can initiate
### `set_peer_role` (client -> server)
#### FORCE_SYNC_ACK (Client → Server)
Payload:
Acknowledgment of force sync preparation.
**Payload:** None
**Purpose:** Let initiator know peer is ready
#### FORCE_SYNC_EXECUTE (Client → Server → Client)
Execute the force sync (seek + play).
**Payload:**
```json
{
"targetTime": "number (seconds)"
}
```
**Broadcast:** Relayed to all peers
**Effect:** All peers seek to targetTime and play
### Episode Auto-Sync
#### EPISODE_LOBBY (Client → Server → Client)
Wait for all peers to load the next episode.
**Payload:**
```json
{
"episodeId": "string",
"targetTime": "number (seconds, usually 0)"
}
```
**Sequence:**
1. Initiator sends EPISODE_LOBBY
2. Server broadcasts to all peers
3. Peers load episode and send EPISODE_READY when paused at targetTime
4. When all peers ready or timeout, initiator sends EPISODE_LOBBY_CANCEL or room resumes
**Timeout:** 60 seconds (EPISODE_LOBBY_TIMEOUT)
**Gated in host-only mode:** Only controllers can initiate
#### EPISODE_READY (Client → Server)
Peer is ready for episode sync.
**Payload:**
```json
{
"episodeId": "string"
}
```
#### EPISODE_LOBBY_CANCEL (Client → Server → Client)
Cancel active episode lobby and resume playback.
**Payload:** None
### Host Control Mode
#### SET_CONTROL_MODE (Client → Server)
Host changes room control mode.
**Payload:**
```json
{
"controlMode": "everyone" | "host-only"
}
```
**Authorization:** Only host (room.hostPeerId) can send
**Server Action:**
- Validates sender is host
- Updates room.controlMode
- Broadcasts CONTROL_MODE to all peers
**Rate Limit:** 500ms debounce per room
#### CONTROL_MODE (Server → Client)
Control mode or role changed.
**Payload:**
```json
{
"controlMode": "everyone" | "host-only",
"hostPeerId": "string",
"controllers": ["string"]
}
```
**Triggered by:**
- SET_CONTROL_MODE from host
- Host leaving room (fallback to 'everyone')
- Controller promotion/demotion
#### SET_PEER_ROLE (Client → Server)
Owner promotes/demotes a peer to/from controller.
**Payload:**
```json
{
"peerId": "string",
"peerId": "string, max 16",
"controller": "boolean"
}
```
**Authorization:** Only owner (room.hostPeerId) can send
Only the room host may promote or demote controllers. The host cannot demote
themself. Role changes use the same 500 ms per-room debounce as mode changes.
**Server Action:**
- Updates room.controllers set
- Broadcasts CONTROL_MODE to all peers
### `control_mode` (server -> client)
**Rate Limit:** 500ms debounce per room
Payload:
### Ping / Latency Measurement
#### PING (Client → Server → Client)
Measure round-trip time.
**Payload:**
```json
{
"t": "timestamp (Date.now())",
"target": "peerId (optional, empty = server echo)"
"controlMode": "everyone | host-only",
"hostPeerId": "string or null",
"controllers": ["peerId"]
}
```
**Server Response:** PONG with same timestamp
Sent when mode or controller state changes, when host migration changes room
authority, and when unauthorized role/mode attempts need to resync the sender.
**Frequency:** Every 30 seconds
## Room List
#### PONG (Server → Client)
### `get_rooms` (client -> server)
Response to PING.
Payload: none.
**Payload:**
```json
{
"t": "timestamp (from PING)"
}
```
No admin token is required for this Socket.IO event.
### Administrative Events
Limits:
#### GET_ROOMS (Client → Server)
- Counts against the per-socket event limit.
- Also has a 10 second per-socket cooldown.
Request list of active rooms (admin only).
### `room_list` (server -> client)
**Payload:** None
Payload:
**Authorization:** Requires admin token
**Rate Limit:** 10 requests per IP per minute
**Server Response:** ROOM_LIST event
#### ROOM_LIST (Server → Client)
List of active rooms.
**Payload:**
```json
{
"rooms": [
{
"roomId": "string",
"peerCount": "number",
"createdAt": "timestamp",
"lastActivity": "timestamp"
"id": "room id",
"peerCount": 2,
"hasPassword": false
}
]
}
```
## Ping, Pong, and ACK
### `ping`
Payload:
```json
{
"t": 1234567890,
"target": "peerId, optional"
}
```
If `target` is omitted, the relay responds to the sender with `pong`.
If `target` is another peer in the same room, the relay sends `ping` to that peer
with `{ "t": ..., "sender": "senderPeerId" }`.
### `pong`
Payload:
```json
{
"t": 1234567890,
"target": "peerId, optional"
}
```
If `target` is a peer in the same room, the relay sends `pong` to that peer with
`{ "t": ... }`.
### `event_ack`
Client payload:
```json
{
"targetId": "peerId",
"actionTimestamp": 1234567890
}
```
If sender and target are still in the same room, the relay emits:
```json
{
"senderId": "sender peerId",
"actionTimestamp": 1234567890
}
```
## Rate Limits
### Connection Rate Limits
- Connections: 10 per IP per minute; excess connections are disconnected.
- Relayed/events: 50 per socket per 10 seconds; excess disconnects the socket.
- `get_rooms`: 10 second cooldown per socket plus the event limit.
- `leave_room`: 10 per socket per minute; excess is ignored.
- Invalid room passwords: tracked per IP and room. Five recent failures block
more password attempts for that room until the failure window ages out.
- HTTP health and admin-metrics endpoints have their own rate limits outside this
Socket.IO protocol.
- **Connections:** 10 per IP per minute
- **Authentication Attempts:** 5 per IP per room per minute
- **Event Rate:** 50 events per 10 seconds per socket
## Capabilities
### Specific Event Rate Limits
`room_data.capabilities` advertises server-backed features:
- **Health Checks:** 10 per IP per minute
- **Admin Metrics Auth:** 5 per IP per minute
- **Room List:** 10 per IP per minute (cooldown)
- **LEAVE_ROOM:** 10 per socket per minute
- `host-control`
- `co-host`
### Debounce Intervals
- **Control Mode Changes:** 500ms per room
- **Role Changes:** 500ms per room
## Server Capabilities
The server advertises supported features in ROOM_DATA.capabilities:
- `host-control`: Host Control Mode feature
- `co-host`: Co-host/promotion feature
Clients should check capabilities before using features.
## Protocol Versioning
- **PROTOCOL_VERSION**: "1.0.0" (current)
- **Backward Compatibility**: Older clients can connect but may not support all features
- **Version Check**: Server validates protocolVersion on JOIN_ROOM
## Edge Cases & Error Handling
### Connection Issues
- **Protocol Mismatch**: Client disconnected with ERROR message
- **Rate Limit Exceeded**: Socket disconnected immediately
- **Server Restart**: Clients auto-reconnect with exponential backoff
### Room Management
- **Host Leaves**: controlMode falls back to 'everyone'
- **Room Full**: Error response on JOIN_ROOM
- **Duplicate PeerId**: Old socket kicked on new join
### Media Sync
- **Seek Spam**: Coalesced within 200ms window
- **Force Sync Timeout**: Auto-executes after 8.5s if not all peers ACK
- **Episode Lobby Timeout**: Auto-cancels after 60s
### Host Control Mode
- **Non-host Attempts**: Event ignored, CONTROL_MODE sent to sync UI
- **Host-only Gating**: play/pause/seek/forceSync/episodeLobby blocked for guests
- **Desync Handling**: Guests can opt-out via dialog, resync button available
## Security Considerations
- **No Authentication**: Trust model is client-enforced (no tokens)
- **Rate Limiting**: Prevents abuse and DoS
- **Input Sanitization**: All strings truncated and validated
- **No Sensitive Data**: Only public room/peer metadata transmitted
## Testing Recommendations
1. **Connection Flow**: Join, leave, reconnect, room creation
2. **Media Sync**: Play/pause/seek propagation, force sync sequence
3. **Host Control**: Mode toggle, guest blocking, desync/resync
4. **Rate Limits**: Verify limits enforced, errors logged
5. **Edge Cases**: Host leave, network blips, seek spam
## Changelog
- **1.0.0**: Initial protocol specification (2026)
- Documented all events from shared/constants.js
- Added rate limit details and edge cases
Clients should treat a missing or unknown capabilities list as unsupported.
+98 -233
View File
@@ -1,286 +1,151 @@
# Host Control Mode
## Overview
Host Control Mode allows the room host to control playback for all participants. Guests can attempt to play/pause/seek, but their actions are not broadcast to the room unless they explicitly choose to desync and watch on their own.
This document describes the Host Control Mode implementation in the relay and
extension. It only covers behavior implemented in the current codebase.
## Modes
### `everyone` (Default)
- Anyone in the room can control playback
- All play/pause/seek actions are broadcast to all peers
- Traditional KoalaSync behavior
### `everyone`
- Default room mode.
- Any peer may send room-moving playback events.
### `host-only`
- Only the host (and promoted controllers) can control the room
- Guest actions are blocked and they are snapped back to the host's position
- Guests can choose to desync and watch independently
## Protocol Events
- Only controllers may send room-moving playback events.
- The host is always a controller.
- The host can promote additional peers to controllers.
- Guests can keep watching locally in solo/desynced mode, but their local actions
still do not drive the shared room.
### `SET_CONTROL_MODE` (Client → Server)
Room-moving events are:
Sent by the host to change the room's control mode.
- `play`
- `pause`
- `seek`
- `force_sync_prepare`
- `force_sync_execute`
- `episode_lobby`
- `episode_lobby_cancel`
**Payload:**
```json
Heartbeats, force-sync ACKs, episode-ready events, ping/pong, and command ACKs
remain allowed for guests.
## Server State
Rooms store Host Control state in memory:
```js
{
"controlMode": "everyone" | "host-only"
hostPeerId,
controlMode,
controllers,
lastControlModeChangeAt,
lastRoleChangeAt,
forceSyncInitiator
}
```
**Authorization:** Only the host (room.hostPeerId) can send this event.
`controllers` is a `Set` on the server and is serialized as an array in
`room_data` and `control_mode`.
**Server Response:** Broadcasts `CONTROL_MODE` to all peers in the room.
State is not persisted across relay restarts.
### `CONTROL_MODE` (Server → Client)
## Authority Rules
Broadcast when the control mode changes or when a peer joins a room.
### Changing mode
**Payload:**
```json
{
"controlMode": "everyone" | "host-only",
"hostPeerId": "string",
"controllers": ["string"]
}
```
Only `hostPeerId` may send `set_control_mode`.
**Triggered by:**
- Host changing the control mode
- Host leaving the room (fallback to 'everyone')
- Controller promotion/demotion
Valid values:
### `SET_PEER_ROLE` (Client → Server)
- `everyone`
- `host-only`
Sent by the host to promote or demote a peer to/from controller status.
Invalid values are ignored. Non-host attempts are ignored and the sender receives
the current `control_mode` snapshot so optimistic UI can revert.
**Payload:**
```json
{
"peerId": "string",
"controller": "boolean"
}
```
Mode changes are debounced per room for 500 ms.
**Authorization:** Only the host (room.hostPeerId) can send this event.
### Promoting and demoting controllers
**Server Response:** Broadcasts `CONTROL_MODE` to all peers in the room.
Only `hostPeerId` may send `set_peer_role`.
## Server-Side Implementation
The host cannot demote themself. No-op role changes are ignored. Role changes are
debounced per room for 500 ms.
### Room Object
### Host leaving
```javascript
room = {
hostPeerId: "peer-id", // First peer to join the room
controlMode: "everyone", // 'everyone' | 'host-only'
controllers: ["peer-id"], // Peers allowed to control in host-only mode
lastControlModeChangeAt: 0, // Timestamp for rate limiting
lastRoleChangeAt: 0 // Timestamp for rate limiting
}
```
When the host leaves and peers remain:
### Key Behaviors
- the next peer becomes `hostPeerId`;
- `controlMode` falls back to `everyone`;
- `controllers` is reset to the new host;
- the relay broadcasts `control_mode`.
1. **Host Migration:** When the host leaves, the room falls back to 'everyone' mode
2. **Controller Set:** Always includes the host, plus any promoted peers
3. **Rate Limiting:** Control mode changes are debounced to 500ms per room
When a non-host controller leaves, the relay removes that peer from
`controllers` and broadcasts `control_mode`.
## Client-Side Implementation
## Enforcement
### State Management (background.js)
The implementation has two enforcement points:
```javascript
let controlMode = CONTROL_MODES.EVERYONE;
let hostPeerId = null;
let controllers = [];
- The extension background script blocks local guest attempts in `host-only` and
sends `HOST_BLOCKED` to the content script for local UX.
- The relay drops room-moving events from non-controllers in `host-only`, so old
or modified clients cannot drive the room.
function amHost() { return hostPeerId === peerId; }
function amController() { return amHost() || controllers.includes(peerId); }
```
The relay is the authority for room-wide effects.
### Event Gating
## Guest UX
**Sender-side gate (background.js):**
- Blocks `PLAY`, `PAUSE`, `SEEK`, `FORCE_SYNC_*`, `EPISODE_LOBBY_*` from non-controllers
- Sends `HOST_BLOCKED` message to content script instead
When a guest action is blocked locally, the content script classifies it:
**Receiver-side gate (background.js):**
- Ignores gated events from non-controllers in host-only mode
- Prevents malicious or outdated clients from bypassing controls
- deliberate user action: show the host-control dialog;
- likely involuntary player action (buffering, tab refocus, no recent gesture):
silently snap back when safe;
- live/DVR stream: degrade without forcing snap-back.
### Snap-Back Logic (content.js)
The dialog offers:
When a guest's action is blocked:
1. Show dialog: "Stay in sync" (default) or "Watch on my own"
2. If "Stay in sync": snap player back to host's position
3. If "Watch on my own": set `hcmDesynced = true`, show resync button
- stay in sync: resync to the host;
- watch on my own: enter solo/desynced mode.
### Host Sync Target Calculation
In solo/desynced mode:
```javascript
function getHostSyncTarget() {
const host = currentRoom.peers.find(p => p.peerId === hostPeerId);
if (!host) return null;
- the guest can control their local video;
- host room commands are ignored locally, except force-sync preparation is ACKed
so the host's flow can continue;
- the guest can resync to the host.
let targetTime = host.currentTime;
The extension reports `desynced` in peer status so the host UI can show that a
guest is watching solo.
// Extrapolate if host is playing
if (host.playbackState === 'playing' && host.lastHeartbeat) {
const elapsedSec = (Date.now() - host.lastHeartbeat) / 1000;
if (elapsedSec > 0 && elapsedSec <= 30) { // Max 30s extrapolation
targetTime += elapsedSec;
}
}
## Force Sync Edge Case
return { playbackState: host.playbackState, targetTime };
}
```
The relay tracks `forceSyncInitiator` after a controller sends
`force_sync_prepare`.
## Edge Cases
This allows that same initiator's `force_sync_execute` through even if their
controller role changes before execute arrives. Without this, a demotion in the
middle of a force-sync flow could leave peers waiting after prepare.
### 1. Host Leaves Room
- **Behavior:** Room falls back to 'everyone' mode
- **Implementation:** `removePeerFromRoom` checks if leaving peer is host
- **Broadcast:** `CONTROL_MODE` event sent to all remaining peers
The relay clears `forceSyncInitiator` after execute or when the initiator leaves.
### 2. Controller Promoted During Force Sync
- **Problem:** Controller's `FORCE_SYNC_EXECUTE` would be blocked
- **Solution:** `forceSyncInitiator` field tracks who started the sync
- **Behavior:** Initiator's EXECUTE is allowed even if demoted mid-sync
## Capabilities
### 3. Guest Seek Spam
- **Problem:** Guest could spam seeks to disrupt
- **Solution:** Cooldown period after snap-back (1000ms)
- **Behavior:** Subsequent actions within cooldown are ignored
The relay advertises Host Control support in `room_data.capabilities`:
### 4. Host State Stale
- **Problem:** Host heartbeat hasn't arrived recently
- **Solution:** Max 30s extrapolation, then use last known position
- **Behavior:** Prevents overshooting if host paused but heartbeat delayed
- `host-control`
- `co-host`
### 5. Old Client Without Feature
- **Problem:** Client doesn't know about host-only mode
- **Solution:** Receiver-side gate in all clients
- **Behavior:** Modern clients ignore events from non-controllers
The extension hides or disables matching UI when capabilities are missing.
## User Experience
## Related Events
### Host UI
- Toggle: "Only I can control" (visible only to host)
- Role badges: "Host", "Controller", or "Guest"
- Guest notice: "The host controls playback" (when host-only active)
- Controller management: Promote/demote peers in participant list
See [PROTOCOL.md](PROTOCOL.md) for payloads and relay behavior for:
### Guest UI
- **Blocked Action:** Dialog with choices:
- "Stay in sync" (default, auto-closes after 8s)
- "Watch on my own" (enters desync mode)
- **Desync Mode:** "Solo" badge with "Resync" button
- **Resync:** Returns to synchronized state with host
### Co-Host UI
- Same controls as host (can play/pause/seek/force-sync)
- "Controller" badge instead of "Host"
- Cannot promote/demote other controllers
## Testing Checklist
### Basic Functionality
1. ✅ Host can toggle between 'everyone' and 'host-only'
2. ✅ Guests see "host controls playback" notice in host-only
3. ✅ Guest play/pause/seek blocked in host-only
4. ✅ Guest snapped back to host position when blocked
5. ✅ Guest can choose "watch on my own" and desync
6. ✅ Desynced guest can resync with host
### Edge Cases
7. ✅ Host leaving room falls back to 'everyone'
8. ✅ Multiple rapid guest actions don't cause loop
9. ✅ Guest can desync during buffering/throttling
10. ✅ Old client events ignored by modern clients
11. ✅ Force sync initiated by controller works
12. ✅ Episode lobby initiated by controller works
### UI/UX
13. ✅ Host toggle only visible to host
14. ✅ Role badges show correct roles
15. ✅ Desync dialog auto-closes after timeout
16. ✅ Resync button visible when desynced
17. ✅ Controller promotion UI visible to host
## Architecture Decisions
### Why Double Gating?
**Sender-side + Receiver-side gates provide defense in depth:**
- Sender-side: Clean UX with dialog for guests
- Receiver-side: Protects against old/buggy/malicious clients
- Server-side: Central enforcement point (optional but recommended)
### Why Extrapolation?
**Linear extrapolation from last heartbeat provides ~±1s accuracy:**
- Better than freezing at last known position
- Handles minor network jitter gracefully
- Capped at 30s to avoid overshooting on stale data
### Why Cooldown?
**600-1000ms cooldown prevents control loops:**
- Guest pause → snap-back → pause → snap-back...
- Allows legitimate desync after cooldown expires
- Doesn't block deliberate user actions
## Migration Path
### From 'everyone' to 'host-only'
1. Host toggles mode to 'host-only'
2. Server validates host authority
3. Server broadcasts `CONTROL_MODE` to all peers
4. All clients update local state
5. Existing playback continues uninterrupted
6. Future guest actions are gated
### From 'host-only' to 'everyone'
1. Host toggles mode to 'everyone'
2. Server validates host authority
3. Server broadcasts `CONTROL_MODE` to all peers
4. All clients update local state
5. All peers regain control immediately
## Performance Considerations
- **Memory:** Minimal overhead (~100 bytes per room for HCM state)
- **CPU:** Snap-back calculation is O(1), negligible impact
- **Network:** No additional traffic beyond existing heartbeats
- **Storage:** Control mode persisted in room state, no additional storage
## Security Considerations
- **No Authentication:** Trust model is client-enforced (no tokens)
- **No Encryption:** Feature doesn't handle sensitive data
- **Rate Limiting:** Control mode changes debounced to prevent spam
- **Validation:** All mode changes validated server-side
## Future Enhancements
### Planned
- Host transfer button (manual host migration)
- Temporary controller promotion (time-limited)
- Guest request control (notification to host)
### Considered but Rejected
- Password-protected host transfer (too complex)
- Vote-based control mode (social complexity)
- Per-action permissions (UI overload)
## Changelog
- **2.5.0**: Initial implementation (July 2026)
- **2.5.1**: Added co-host support and controller promotion
- **2.5.2**: Improved desync UI with resync button
- **2.6.0**: Added server-side rate limiting for mode changes
## See Also
- [Protocol Specification](PROTOCOL.md) — Technical event details
- [Architecture](ARCHITECTURE.md) — System overview
- `set_control_mode`
- `control_mode`
- `set_peer_role`
- host-only gated relay events