The host-control desync dialog and the "watching on your own" badge were hardcoded English — content.js has no i18n loader. Background does, so it now resolves the strings (GET_HCM_STRINGS: loadLocale + getMessage) and content fetches them on init with English fallbacks, re-rendering the badge if it was already shown. The dialog body is now generic (dropped the paused/jumped verb) to keep translation clean across languages. Adds 6 keys (HCM_DIALOG_TITLE/BODY/STAY/SOLO, HCM_BADGE_SOLO/RESYNC) translated across all 15 popup locales. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
15 KiB
Host Control Mode — Branch Overview, Goals & Edge Cases
This is the canonical entry-point doc for branch
feature/host-control-mode. If you're an agent or contributor picking this up: read this file first, then the implementation plan inhost-control-mode-plan.md. Temporary working doc — delete or fold into permanent docs before merging tomain.Status legend: 🔴 open / unresolved · 🟡 idea, needs testing · 🟢 decided
0. Implementation status
All five layers are implemented & pushed (server + background + content + popup + i18n). Automated checks green: ESLint, WS integration tests (incl. host-only gate, toggle reject, host-leave fallback), content video-finder, locale consistency (15 langs), full release verification.
Still needs real-device testing — the EC test matrix in §7 (YouTube/Netflix/ Twitch/Disney+/Jellyfin): involuntary-pause classification (EC-1/EC-5/EC-8), snap-back reliability and fight-loops (EC-4), and the desync/resync flow across players. The intent classifier (EC-9) and snap-back cooldown are first-pass heuristics tuned by reading the code, not yet by watching them behave on each site.
Deferred by decision (see §8): host grace period on disconnect (EC-10).
Pre-test self-audit (fixed)
- Popup remote buttons froze for guests — in host-only a guest's Play/Pause/SYNC click was gated server-side but the button stuck on "Playing"/disabled with no feedback. Now the remote controls are locked (disabled + tooltip) for guests, with backstop guards in the handlers. (popup.js)
- Desync dialog could break under strict CSP — it used
innerHTMLwith inlinestyle=""attributes, which Netflix/YouTube/Disney+ strip viastyle-src. Rebuilt with the DOM API (CSSOM.styleis CSP-safe) inside a Shadow DOM so page CSS can't restyle/hide it. (content.js) - Live-DVR not detected (EC-15) —
duration === Infinitymisses Twitch/YouTube live-DVR (finite, sliding duration). Added aseekable.start(0) > 1sliding-window heuristic inhcmIsLive(). (content.js)
Pre-test self-audit (open, watch during device testing)
- EC-4/EC-1 snap-back thrash: for involuntary events we still actively seek+play, which can fight a buffering player for the duration of the stall. NOTE: "just let catch-up re-sync" is NOT a valid fix — sync is event-driven, there is no continuous catch-up loop, so skipping the snap-back risks leaving the guest stuck paused/behind until the host next acts. Correct fix is a buffer-aware deferred snap-back: when involuntary + buffering, wait for readyState>=3 (à la pollSeekReady) then seek+play once — avoids thrash AND guarantees re-sync. Build after device testing reveals which players fire pause() vs only 'waiting'.
Control-mode race at join:FIXED —hcmHandleBlockednow treatsHOST_BLOCKEDas authoritative (adopts host-only/guest role) instead of re-checking local mode, since background only sends it to gated guests.Dialog/badge text is English-onlyFIXED — background resolves the strings via GET_HCM_STRINGS (it has the i18n loader); content fetches them on init with English fallback. 6 new keys (HCM_DIALOG_/HCM_BADGE_) across all 15 locales.
1. What this branch is for
Origin: a GitHub feature request. When watching with larger groups, anyone can pause or seek and disrupt everyone else. The requester wants the room creator to optionally restrict control to a single host, the way Teleparty works — guests who try to pause get asked whether they want to pause only their own player (and desync), and otherwise get snapped back to the room's position.
Goal: Add an optional per-room Host Control Mode. A room can be switched between:
everyone(default, current behavior): anyone can play/pause/seek for the room.host-only: only the host drives the room. A guest's deliberate play/pause/seek is not broadcast; instead they're snapped back to the room position — unless they explicitly choose to desync (go solo) with a "Resync" escape hatch.
2. Trust model (read this before over-engineering)
This is client-side trust, by design. It's a watch party, not a security boundary.
The point is preventing accidental and casual disruption, not stopping someone
determined to patch their own extension. We do not add auth, tokens, or
cryptographic host identity. peerId is unauthenticated and that's fine here.
(We still gate server-side as the robust chokepoint — see plan — but that's about killing spam reliably, not about defeating a hostile client.)
3. Scope / non-goals
In scope:
- Host designation (first joiner = host), mode toggle, host-only gating of all room-moving events, guest snap-back, deliberate-desync flow + resync, host UI.
Explicit non-goals (for this branch):
- Authenticated / spoof-proof host identity.
- Persistent host across server restarts (room state is in-memory).
- Syncing around personalized ad breaks.
- Host transfer UI (auto-fallback to
everyonewhen host leaves; manual transfer is a possible later add).
4. Architecture summary
Three-layer gate for room-moving events from a non-host in host-only mode
(PLAY, PAUSE, SEEK, FORCE_SYNC_PREPARE, FORCE_SYNC_EXECUTE, EPISODE_LOBBY):
- Server — doesn't relay them (robust chokepoint, kills spam regardless of client).
- Sender (guest) — doesn't emit; shows confirm dialog / disables host-only buttons.
- Receiver — drops any that slip through (covers old/buggy/modified clients).
Snap-back reuses the existing _setSuppress mechanism (content.js:442) so applying
the room state programmatically doesn't echo back as a new event. Target position is
extrapolated from the host's last known state (±1s). Full detail + code hooks in the
plan doc.
5. The central challenge
Everything hard about this feature reduces to one question (see EC-9):
How do we reliably tell a deliberate guest pause/seek from an involuntary player/browser event (buffering, ads, tab throttling, source swaps, DRM hiccups)?
If we get this wrong, guests get spammed with desync dialogs and snap-back loops for things they never did. The host/role plumbing is the easy part; this classifier is the real work. Design the intent-classifier before writing the gate.
6. Edge cases
EC-1 🔴 Buffering / loading fires a pause event
content.js listens to play/pause/seeked/loadeddata only (content.js:1000-1003),
not waiting/stalled. Pure HTML5 buffering fires waiting → harmless. But custom
players (Netflix/YouTube/Twitch/JW) often call video.pause() during buffering/ads →
real pause → guest gate would mis-classify as deliberate. Sub-cases: (a) initial load
sits paused, no event, fine; (b) mid-stream stall, player-dependent; (c) seek-induced
re-buffering may outlast the suppress window and leak. Mitigation: isBuffering flag
from waiting/playing, or grace window; in host-only the guest's own state is
irrelevant so just ignore involuntary pauses and let catch-up logic (content.js:489)
re-sync them.
EC-2 🟢 Force-Sync / Episode-Lobby abuse by guests
Guest could seek + spam Force-Sync to drag everyone, or spam Episode-Lobby to pause
everyone. Decision: host-only blocks guest initiation of FORCE_SYNC_* and
EPISODE_LOBBY; guests may only respond (FORCE_SYNC_ACK, EPISODE_READY). Guests'
legitimate path is the personal "Resync" button.
EC-3 🟢 Host leaves the room
Fall back to controlMode = 'everyone', broadcast CONTROL_MODE. Never a stuck locked
room. (Auto-promote next peer deferred.)
EC-4 🔴 Snap-back fight loop (pause/play/skip back/pause/play)
Mashing controls or a janky player → each snap-back may emit events → ping-pong. Mitigation: cooldown (~600ms) after snap-back; ensure snap-back runs fully under suppress. Also: if target is unreachable (seek past buffered range), retry K times then give up — no infinite loop.
EC-5 🔴 Ad breaks (YouTube/Twitch/…)
Mid-roll ads pause/swap the media element, differ per peer → desync is unavoidable and must NOT spam the dialog. Probably covered by EC-1 buffering grace; flag for explicit testing.
EC-6 🟡 Snap-back target accuracy
No continuous room clock; extrapolate from host's currentTime + lastHeartbeat
(±1s, worse if stale). The follow-up host correction must also be suppressed so it
doesn't read as guest input.
EC-7 🟢 Old / buggy / modified guest client
Covered by receiver-side + server-side gates.
EC-8 🔴 Tab throttling / background tab
Backgrounding throttles timers and may pause media. There's existing
visibilityGraceUntil handling for seeks (content.js:892). Confirm a
background-induced pause isn't treated as deliberate in host-only; reuse the grace flag.
EC-9 🔴 What counts as "deliberate" — the central unresolved question
Collapses EC-1/EC-5/EC-8. Candidate signals: readyState/networkState/video.seeking
at event time; recent waiting; recent user gesture (navigator.userActivation,
keydown/click); visibility/focus. Build one shared intent-classifier helper in
content.js that all host-only gating flows through.
EC-10 🔴 Host brief disconnect / reconnect (network blip)
Host's wifi drops for 3s and reconnects. With "host leaves → fallback to everyone",
a blip would silently unlock the room and demote the host (peerId persists in
chrome.storage so they rejoin with the same id, but the server already cleared
hostPeerId). Mitigation idea: short host grace period (e.g. keep hostPeerId
reserved for ~30s after disconnect; if the same peerId rejoins, restore host + mode).
Needs the server reaper (server:644) and removePeerFromRoom (server:168) to cooperate.
EC-11 🔴 New guest joins mid-session in host-only mode
On join they must (a) immediately sync to the host's current position without the host
doing anything, and (b) see they're a guest in the UI. ROOM_DATA already carries peers;
add hostPeerId/controlMode so a fresh client knows its role instantly. Verify the
existing "newcomer syncs without waiting" path (content.js:542) still fires.
EC-12 🔴 Desync semantics — what does "solo" actually mean?
When a guest chooses "pause only me", do they (a) fully ignore all subsequent host events until they Resync, or (b) keep receiving but not auto-applying? Define clearly. Proposed: full solo — ignore host play/pause/seek while desynced; Resync re-attaches and snaps to current host position. Also: what state does Resync land them in if the host is currently paused vs playing?
EC-13 🔴 Race: host flips to host-only exactly as a guest pauses
Event ordering between SET_CONTROL_MODE/CONTROL_MODE and an in-flight guest PAUSE.
The seq ordering helps, but define the tie-break. Likely: server is authoritative —
once it has host-only, it drops the guest event regardless of client-side timing.
EC-14 🟡 Volume / mute / audio-options must NOT be gated
Those are per-peer, not room control. The gate must target only play/pause/seek +
forcesync/episode — not PEER_STATUS volume/mute fields. Easy to over-block; add a test.
EC-15 🔴 Live streams (Twitch live, live DVR)
"Room timestamp" is fuzzy on live edge; seeking semantics differ. Decide whether host-only even makes sense for live, or degrade gracefully. Low priority but log it.
EC-16 🟡 Host's own involuntary events still drive the room
If the host buffers and the player auto-pauses, that pause is "allowed" and pauses everyone. That's existing behavior, but in host-only it means the host's buffering stalls the whole room. Acceptable? Probably yes (host is authoritative), but note it.
EC-17 🟡 Server restart drops room state
hostPeerId/controlMode are in-memory. After a server restart, whoever rejoins first
becomes the new host and mode resets to everyone. Acceptable for now (non-goal), but
document so it's not a surprise.
EC-18 🟡 Dialog dismissed without choosing
Guest clicks away / presses Esc on the desync prompt. Default = treat as "No" → snap back. Make sure an un-answered dialog can't leave them in limbo (paused + not desynced + no dialog).
EC-19 🟡 Multiple video elements / element swap (SPA, ad → content)
Players that swap the <video> element mid-session: re-attach handlers (content.js
already re-binds on loadeddata) and make sure host-only gating follows the new element.
EC-20 🟡 Peer list shape (object vs legacy string)
Throughout background.js peers may be objects or bare peerId strings
(typeof p === 'object' ? p.peerId : p). All new hostPeerId comparisons must handle
both forms, or we get a host that's never recognized.
EC-21 🟡 Mode toggle spam / rate limiting
Host hammering the toggle → many SET_CONTROL_MODE. Covered by existing
checkEventRate (server), but debounce in the UI and ignore no-op transitions.
7. Test matrix (fill in during dev)
| Player | Buffer→pause? |
Ad behavior | Snap-back works? | Element swap? | Notes |
|---|---|---|---|---|---|
| Generic HTML5 | |||||
| YouTube | |||||
| Netflix | |||||
| Twitch (VOD) | |||||
| Twitch (live) | |||||
| Disney+ / DRM | |||||
| Jellyfin / Emby |
8. Decisions (audited)
- Intent-classifier (EC-9): A
pause/seekis involuntary if ANY of:readyState < 3,video.seeking, awaitingfired < ~1500ms ago (isBufferingflag), insidevisibilityGraceUntil, OR no own-tracked user gesture (Date.now() - lastUserGestureAt < 1000, via capturing keydown/pointerdown — do NOT use stickynavigator.userActivation.hasBeenActive). Bias: only clearly involuntary is ignored; everything else = deliberate. Note: in host-only the guest never broadcasts anyway, so this only decides dialog-vs-silent — a UX call, not a room-integrity call. Start simple, tune later. - Host grace on disconnect (EC-10): NOT in v1. Immediate fallback to
everyone(EC-3). A grace window risks a multi-second hard-lock if the host never returns. Revisit as polish once the core flow works. - Desync semantics (EC-12): full solo. Ignore host play/pause/seek while desynced; Resync snaps to host position + adopts host play/pause state. Desync auto-clears on new media/episode. Requires a persistent, obvious "You are desynced" UI.
- Snap-back cooldown (EC-4): until-settled, not fixed. Suppress re-trigger
until
readyState>=3 && playing && |Δt|<tol, hard-cap ~1500ms. Retry target up to 3×, then give up (no infinite loop). - Live streams (EC-15): degrade. Disable the gate when
video.duration === Infinity. Caveat: live-DVR may report finite duration and slip through — acceptable for v1.