## Added - **WebSocket Protocol Specification** (docs/PROTOCOL.md) - Complete reference for all 20+ events with payload schemas, rate limits, and edge cases - **LEAVE_ROOM Rate Limiting** - 10 requests/socket/minute to prevent abuse - **Vitest Testing Framework** - Modern test setup with coverage reporting - **Host Control Mode Documentation** - Consolidated EN documentation (docs/host-control-mode.md) with references to internal implementation ## Changed - **Graceful Shutdown** - Socket.IO clients properly disconnected during server shutdown (server/index.js) - **CI Workflow** - Added test step with continue-on-error for gradual rollout - **README** - Added protocol documentation link - **CHANGELOG** - Updated with all new features and improvements ## Moved - Internal documentation moved to docs/internal/ for better organization - Host Control Mode docs consolidated from 4 files to 1 comprehensive guide ## Technical Details - Protocol spec: 495 lines, covers all events from shared/constants.js - Rate limiter: Follows existing pattern (checkAuthRate, checkEventRate) - Vitest: 6 tests for rate limiter, all passing - Documentation: Host Control Mode doc includes edge cases, testing checklist, architecture decisions
9.0 KiB
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.
Modes
everyone (Default)
- Anyone in the room can control playback
- All play/pause/seek actions are broadcast to all peers
- Traditional KoalaSync behavior
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
SET_CONTROL_MODE (Client → Server)
Sent by the host to change the room's control mode.
Payload:
{
"controlMode": "everyone" | "host-only"
}
Authorization: Only the host (room.hostPeerId) can send this event.
Server Response: Broadcasts CONTROL_MODE to all peers in the room.
CONTROL_MODE (Server → Client)
Broadcast when the control mode changes or when a peer joins a room.
Payload:
{
"controlMode": "everyone" | "host-only",
"hostPeerId": "string",
"controllers": ["string"]
}
Triggered by:
- Host changing the control mode
- Host leaving the room (fallback to 'everyone')
- Controller promotion/demotion
SET_PEER_ROLE (Client → Server)
Sent by the host to promote or demote a peer to/from controller status.
Payload:
{
"peerId": "string",
"controller": "boolean"
}
Authorization: Only the host (room.hostPeerId) can send this event.
Server Response: Broadcasts CONTROL_MODE to all peers in the room.
Server-Side Implementation
Room Object
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
}
Key Behaviors
- Host Migration: When the host leaves, the room falls back to 'everyone' mode
- Controller Set: Always includes the host, plus any promoted peers
- Rate Limiting: Control mode changes are debounced to 500ms per room
Client-Side Implementation
State Management (background.js)
let controlMode = CONTROL_MODES.EVERYONE;
let hostPeerId = null;
let controllers = [];
function amHost() { return hostPeerId === peerId; }
function amController() { return amHost() || controllers.includes(peerId); }
Event Gating
Sender-side gate (background.js):
- Blocks
PLAY,PAUSE,SEEK,FORCE_SYNC_*,EPISODE_LOBBY_*from non-controllers - Sends
HOST_BLOCKEDmessage to content script instead
Receiver-side gate (background.js):
- Ignores gated events from non-controllers in host-only mode
- Prevents malicious or outdated clients from bypassing controls
Snap-Back Logic (content.js)
When a guest's action is blocked:
- Show dialog: "Stay in sync" (default) or "Watch on my own"
- If "Stay in sync": snap player back to host's position
- If "Watch on my own": set
hcmDesynced = true, show resync button
Host Sync Target Calculation
function getHostSyncTarget() {
const host = currentRoom.peers.find(p => p.peerId === hostPeerId);
if (!host) return null;
let targetTime = host.currentTime;
// 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;
}
}
return { playbackState: host.playbackState, targetTime };
}
Edge Cases
1. Host Leaves Room
- Behavior: Room falls back to 'everyone' mode
- Implementation:
removePeerFromRoomchecks if leaving peer is host - Broadcast:
CONTROL_MODEevent sent to all remaining peers
2. Controller Promoted During Force Sync
- Problem: Controller's
FORCE_SYNC_EXECUTEwould be blocked - Solution:
forceSyncInitiatorfield tracks who started the sync - Behavior: Initiator's EXECUTE is allowed even if demoted mid-sync
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
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
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
User Experience
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
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
- ✅ Host can toggle between 'everyone' and 'host-only'
- ✅ Guests see "host controls playback" notice in host-only
- ✅ Guest play/pause/seek blocked in host-only
- ✅ Guest snapped back to host position when blocked
- ✅ Guest can choose "watch on my own" and desync
- ✅ Desynced guest can resync with host
Edge Cases
- ✅ Host leaving room falls back to 'everyone'
- ✅ Multiple rapid guest actions don't cause loop
- ✅ Guest can desync during buffering/throttling
- ✅ Old client events ignored by modern clients
- ✅ Force sync initiated by controller works
- ✅ Episode lobby initiated by controller works
UI/UX
- ✅ Host toggle only visible to host
- ✅ Role badges show correct roles
- ✅ Desync dialog auto-closes after timeout
- ✅ Resync button visible when desynced
- ✅ 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'
- Host toggles mode to 'host-only'
- Server validates host authority
- Server broadcasts
CONTROL_MODEto all peers - All clients update local state
- Existing playback continues uninterrupted
- Future guest actions are gated
From 'host-only' to 'everyone'
- Host toggles mode to 'everyone'
- Server validates host authority
- Server broadcasts
CONTROL_MODEto all peers - All clients update local state
- 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 — Technical event details
- Architecture — System overview
- Internal Documentation — Original implementation plan (German)