docs: add host control mode branch overview, plan & edge-case log

Planning docs for the optional per-room Host Control Mode (Teleparty-style
host-only playback control) requested via GitHub issue.

- host-control-mode-EDGECASES.md: canonical branch entry-point — goals, scope,
  trust model, architecture summary, 21 edge cases, test matrix, open decisions.
- host-control-mode-plan.md: layered implementation plan with concrete code hooks
  (server room state, three-layer gate, snap-back, guest desync flow, UI/i18n).

Temporary working docs for this branch; to be folded in or removed before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
KoalaDev
2026-06-25 23:22:26 +02:00
parent 68027a21d5
commit 2995afe970
2 changed files with 334 additions and 0 deletions
+212
View File
@@ -0,0 +1,212 @@
# 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 in [`host-control-mode-plan.md`](./host-control-mode-plan.md).
> Temporary working doc — delete or fold into permanent docs before merging to `main`.
>
> Status legend: 🔴 open / unresolved · 🟡 idea, needs testing · 🟢 decided
---
## 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 `everyone` when 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`):
1. **Server** — doesn't relay them (robust chokepoint, kills spam regardless of client).
2. **Sender (guest)** — doesn't emit; shows confirm dialog / disables host-only buttons.
3. **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. Open decisions to lock before/while coding
- [ ] Intent-classifier signal set (EC-9) — design first.
- [ ] Host grace period on disconnect, yes/no + duration (EC-10).
- [ ] Desync semantics: full solo vs receive-only (EC-12).
- [ ] Snap-back cooldown duration + retry cap (EC-4).
- [ ] Does host-only apply to live streams or degrade (EC-15)?
+122
View File
@@ -0,0 +1,122 @@
# Host Control Mode — Implementierungsplan
Branch: `feature/host-control-mode`
Issue: GitHub feature request (wasserrutschentester) — nur Host darf den Raum steuern; Gäste werden zurückgesnappt oder gehen bewusst in Desync.
## Ziel
Ein Raum kann zwischen zwei Modi umgeschaltet werden:
- **`everyone`** (Default, heutiges Verhalten): jeder kann play/pause/seek für alle auslösen.
- **`host-only`**: nur der Host steuert den Raum. Pause/Seek eines Gasts wird **nicht** gebroadcastet; stattdessen snappt die eigene Extension den Gast zurück auf den Raum-Zustand — es sei denn, der Gast entscheidet sich bewusst für Desync.
Trust-Modell: client-seitig durchgesetzt. Kein Token, keine Auth. Es geht um versehentliches Stören, nicht um Angriffsschutz.
---
## Datenmodell
### Server (`server/index.js`, Room-Objekt ~Z.331)
Room bekommt zwei neue Felder:
```js
room = {
...,
hostPeerId: peerId, // gesetzt beim Anlegen = erster Joiner
controlMode: 'everyone', // 'everyone' | 'host-only'
}
```
- In `ROOM_DATA` (~Z.418) mitschicken: `hostPeerId`, `controlMode`.
- Neues Event `SET_CONTROL_MODE` (siehe unten): nur akzeptieren, wenn `senderPeerId === room.hostPeerId`. Server setzt `room.controlMode`, broadcastet die Änderung an alle.
- **Host-Migration:** in `removePeerFromRoom` (~Z.168) — wenn der gehende Peer `hostPeerId` war: entweder neuen Host bestimmen (nächster Peer) **oder** `controlMode` auf `everyone` zurückfallen lassen. → Entscheidung: **Fallback auf `everyone`** (simpel, nie verwaister gesperrter Raum). Optional später: Host-Transfer-Button.
### Shared Constants (`shared/constants.js`)
Neue Events im `EVENTS`-Objekt:
```js
SET_CONTROL_MODE: "set_control_mode", // Client->Server: Host ändert Modus
CONTROL_MODE: "control_mode", // Server->Client: Modus geändert { controlMode, hostPeerId }
```
⚠️ Danach `node scripts/build-extension.cjs` laufen lassen (Single Source of Truth propagieren). Ggf. `PROTOCOL_VERSION` bumpen — **nein, nur wenn alte Clients brechen würden**. Da alles additiv ist und alte Clients die neuen Felder/Events einfach ignorieren, ist KEIN Protokoll-Bump nötig. (Alter Client in host-only-Raum kennt den Modus nicht und sendet weiter → Host-Extensions ignorieren fremde Events nicht... → siehe Edge Case 7. Evtl. doch Bump erwägen.)
### Extension State (`background.js`)
```js
let controlMode = 'everyone';
let hostPeerId = null;
// abgeleitet: const amHost = () => hostPeerId === peerId;
```
Aus `ROOM_DATA` / `CONTROL_MODE` befüllen, in `chrome.storage.session` persistieren (wie `currentRoom`).
---
## Implementierung nach Schichten
### 1. Server (`server/index.js`)
- [ ] Room-Objekt um `hostPeerId` + `controlMode` erweitern (~Z.331).
- [ ] `ROOM_DATA`-Payload erweitern (~Z.418).
- [ ] Handler `SET_CONTROL_MODE`: validieren (Host-Check + Wert in {everyone, host-only}), setzen, `CONTROL_MODE` an Raum broadcasten.
- [ ] Host-Migration in `removePeerFromRoom`: Fallback auf `everyone` + neues `CONTROL_MODE` broadcasten, wenn Host geht.
- [ ] `SET_CONTROL_MODE` in die `relayEvents`-Liste? **Nein** — eigener Handler, da Sonderlogik + Host-Check. (relayEvents broadcastet blind.)
### 2. Shared / Build
- [ ] `EVENTS.SET_CONTROL_MODE`, `EVENTS.CONTROL_MODE` ergänzen.
- [ ] `node scripts/build-extension.cjs`.
### 3. background.js (Gast-Logik = Kern)
- [ ] `controlMode` / `hostPeerId` aus `ROOM_DATA` (~Z.875) und neuem `CONTROL_MODE`-Case übernehmen + persistieren + an Popup/Content pushen.
- [ ] **Emit-Gate** im SEND-Pfad (~Z.1786): bei `host-only && !amHost()` und action ∈ {play, pause, seek}:
- NICHT `emit`en.
- Stattdessen Content-Script anweisen: "snap back" ODER Desync-Confirm anzeigen.
- [ ] **Snap-Back-Zielzeit berechnen:** aus Host-Peer-State (`playbackState`, `currentTime`, `lastHeartbeat`) extrapolieren:
`targetTime = host.currentTime + (host.playbackState==='playing' ? (now - host.lastHeartbeat)/1000 : 0)`.
Genauigkeit ~±1s, für Watchparty ok. (Force-Sync-Maschinerie als Referenz für Ziel-Zeit-Koordination.)
- [ ] Nachricht an content.js: `{ type: 'HOST_BLOCK', action, targetTime, hostPlaybackState }`.
### 4. content.js (Player-Reaktion + Dialog)
- [ ] Handler für `HOST_BLOCK`: Confirm-Dialog im Player-Overlay rendern:
"Pause only your own player and desync from the group? [Yes] [No]".
- **No** (Default): Player via bestehende `_setSuppress`-Mechanik ([content.js:442](../extension/content.js:442)) wieder in Raum-Zustand zwingen (play + seek auf targetTime). Suppress verhindert Re-Broadcast.
- **Yes:** lokal pausiert lassen, `isDesynced = true` setzen, dezenten "Desynced — Resync"-Button zeigen.
- [ ] "Resync"-Button → snappt zurück auf aktuelle Raum-Zeit, `isDesynced = false`.
- [ ] **Loop-Schutz:** nach einer Snap-Back-Aktion kurzes Cooldown-Fenster (z.B. 600ms), in dem weitere lokale pause/seek-Events nicht erneut den Dialog triggern (verhindert pause→play→pause-Pingpong).
### 5. popup (Host-UI)
- [ ] Host-Toggle "Only I can control" (nur sichtbar wenn `amHost()`), sendet `SET_CONTROL_MODE`.
- [ ] Rollen-Badge: "Host" / "Guest" + aktueller Modus.
- [ ] Gast-Hinweis wenn host-only aktiv: "The host controls playback".
- [ ] i18n-Keys in `extension/_locales` / `locales` für ~15 Sprachen.
---
## Edge Cases (Test-Checkliste)
1. **Pause nicht verhinderbar, nur revidierbar** → kurzer Flicker (~½s) beim Gast ist erwartet/akzeptabel.
2. **Snap-Back-Zielzeit** aus Heartbeat extrapoliert, ±1s. Bei stark veraltetem Host-State (kein Heartbeat) → letzten bekannten Wert nehmen.
3. **Kampf-Loop pause/play/skip back/pause/play** → Cooldown-Fenster nach Snap-Back. Testen mit aggressivem Mashing.
4. **Desync-Escape:** Gast kann bewusst pausieren (Klo/Telefon) → "Yes" → solo, dann Resync.
5. **Host verlässt Raum** → Fallback auf `everyone`, alle bekommen `CONTROL_MODE`-Update. Testen: Host schließt Tab / Disconnect / Netzabbruch.
6. **host-only + Episode-Auto-Sync / Force-Sync** (server/index.js:503-516): **Gast darf NICHT initiieren.** Force-Sync trägt eine `targetTime` und zwingt ALLE darauf (background.js:1261) — ein Gast könnte seeken → Force-Sync spammen und damit host-only komplett aushebeln. Episode-Lobby pausiert ebenfalls alle. → Im host-only-Modus dürfen `FORCE_SYNC_PREPARE`/`FORCE_SYNC_EXECUTE` und `EPISODE_LOBBY` nur vom Host **initiiert** werden. Gäste dürfen weiterhin nur **reagieren**: `FORCE_SYNC_ACK`, `EPISODE_READY`. Gäste brauchen Force-Sync nicht — ihr legitimer Fall ist der "Resync"-Button (snappt nur sie selbst, nicht alle).
7. **Alter Client (ohne Feature) in host-only-Raum** → kennt Modus nicht, sendet weiter pause/seek → andere Extensions wenden es an. Mitigation: Empfänger-seitiges Gate (host-only-Clients ignorieren play/pause/seek von Nicht-Host) ODER `MIN_VERSION`/Protokoll-Bump. → **Empfehlung: zusätzlich Empfänger-seitig filtern** (robuster als nur Sender-Gate).
8. **Seek getrennt von Pause** → host-only blockt auch Gast-Seeks, nicht nur Pausen.
9. **Mehrere Tabs / Multi-Peer mit gleicher peerId** (Dedup, server:381) → Host-Identität bleibt an peerId hängen, ok.
10. **DAU-Verwirrung** "warum kann ich nicht mehr pausieren?" → klare UI-Botschaft + der Desync-Dialog erklärt sich selbst.
## Architektur-Entscheidung zu Edge Case 7 (wichtig)
Wir setzen das Gate **doppelt** und über **alle raum-verschiebenden Events**, nicht nur play/pause/seek:
Geblockte Initiierungen für Nicht-Host im host-only-Modus:
`PLAY`, `PAUSE`, `SEEK`, `FORCE_SYNC_PREPARE`, `FORCE_SYNC_EXECUTE`, `EPISODE_LOBBY`.
Weiterhin erlaubt für Gäste (reine Reaktion, verschiebt niemanden): `FORCE_SYNC_ACK`, `EPISODE_READY`, `PEER_STATUS`, `PING`/`PONG`.
- **Sender-seitig** (Gast sendet erst gar nicht) → saubere UX, Confirm-Dialog bei play/pause/seek; Force-Sync-/Episode-Lobby-Buttons im Gast-UI deaktiviert/ausgeblendet.
- **Empfänger-seitig** (in `handleServerEvent`, background.js:969 + Force-Sync-/Episode-Cases): wenn `host-only` und `data.senderId !== hostPeerId` → Event verwerfen (nicht an Content routen, keine State-Mutation).
So sind auch alte/buggy/manipulierte Clients abgedeckt, ohne harten Protokoll-Bump.
Optional zusätzlich **server-seitig** in den `relayEvents` (server/index.js:445): im host-only-Modus Initiierungs-Events von Nicht-Host gar nicht erst relayen. Spart Traffic + deckt alles zentral ab. Empfehlenswert, da der Server `hostPeerId`/`controlMode` ohnehin kennt.
---
## Reihenfolge der Umsetzung (kleine, testbare Schritte)
1. Constants + Build (Events da, nichts kaputt).
2. Server: hostPeerId/controlMode + ROOM_DATA + SET_CONTROL_MODE + Migration.
3. background.js: State übernehmen + Empfänger-seitiges Gate (Edge 7) — testbar ohne UI.
4. background.js: Sender-seitiges Gate + Snap-Back-Zielzeit.
5. content.js: Snap-Back-Apply + Confirm-Dialog + Loop-Cooldown.
6. popup: Host-Toggle + Badge + i18n.
7. Durchtesten der Edge-Case-Liste auf YT / Netflix / generischem HTML5-Player.