mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
Merge pull request #14 from Shik3i/feature/host-control-mode
Prepare v2.5.0 host control release
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
name: Beta Server Image
|
||||
|
||||
# Publishes the relay server as a Docker image under NON-production tags so a
|
||||
# feature branch can be deployed to a staging/backup server and used as a custom
|
||||
# server, without ever touching the ':latest' tag the official relay tracks.
|
||||
#
|
||||
# Tags produced (on ghcr.io/<owner>/<repo>):
|
||||
# - beta moving channel pointer to the newest build
|
||||
# - <branch-slug> e.g. feature-host-control-mode
|
||||
# - sha-<short-commit> immutable, pin to an exact build
|
||||
# - <custom> only on manual run, e.g. hostcontrol01
|
||||
# Never ':latest' (flavor: latest=false).
|
||||
#
|
||||
# Remove this workflow once the feature is merged & released the normal way.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- feature/host-control-mode
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Extra immutable tag for this build (e.g. hostcontrol01). Optional.'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
concurrency:
|
||||
group: beta-image-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-beta-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
# Never publish ':latest' from a beta build.
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=beta
|
||||
type=ref,event=branch
|
||||
type=sha,prefix=sha-
|
||||
type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag != '' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: server/Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -27,6 +27,7 @@ Thumbs.db
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
|
||||
@@ -82,6 +82,12 @@ KoalaSync's security is grounded in its architecture:
|
||||
|
||||
If you find a way to bypass any of these protections, we want to know about it.
|
||||
|
||||
> [!NOTE]
|
||||
> Some frequently-reported "issues" are **intentional and out of scope** for our
|
||||
> threat model (ephemeral, account-less rooms of invited participants) — e.g. an
|
||||
> unauthenticated `peerId` or non-constant-time room-password compare. Before
|
||||
> reporting, please read **[`docs/KNOWN_LIMITATIONS.md`](docs/KNOWN_LIMITATIONS.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Responsible Disclosure
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# KoalaSync — Marketing Copy Kit
|
||||
|
||||
Ready-to-paste copy for product listings, launch pages, directory submissions, and anywhere else you keep re-typing the same pitch. Three lengths, one consistent message. Pick the one that fits the field limit.
|
||||
|
||||
---
|
||||
|
||||
## 1. One-Sentence Pitch
|
||||
|
||||
> KoalaSync is a privacy-first browser extension that synchronizes video playback across almost any website so you can watch with friends in real time — no accounts, no tracking, and your video never passes through anyone's server but the original site's.
|
||||
|
||||
**Shorter alternative** (for tight tagline fields):
|
||||
|
||||
> Private, universal watch parties on any website — no accounts, no tracking, no media proxying.
|
||||
|
||||
---
|
||||
|
||||
## 2. Three-Sentence Overview
|
||||
|
||||
> KoalaSync is a lightweight browser extension that keeps you and your friends perfectly in sync on YouTube, Netflix, Twitch, Prime Video, Jellyfin, Emby, and almost any other site with an HTML5 video player — press play once and everyone stays together. It's built privacy-first: no accounts, no telemetry, and the official relay server runs entirely in volatile RAM with zero persistence, so nothing about your sessions is ever stored. Open source under the MIT license and fully self-hostable with a single Docker command, KoalaSync is a transparent watch-party tool that works everywhere and respects your data sovereignty.
|
||||
|
||||
---
|
||||
|
||||
## 3. Full Description
|
||||
|
||||
### Watch together — on any site, on your terms.
|
||||
|
||||
Counting down "3, 2, 1, play" over voice chat doesn't scale past two people. KoalaSync fixes that with a tiny browser extension that synchronizes play, pause, and seeking across everyone in the room, on almost any website with a `<video>` element. Create a room, share a link, press play — that's it.
|
||||
|
||||
### What makes KoalaSync different
|
||||
|
||||
Most watch-party tools fall into one of two traps: they only work on a short allowlist of sites (site-specific extensions that need a separate build for every platform), or they route your video through their own player and servers. KoalaSync was built around three principles that break that mold.
|
||||
|
||||
**Universal by design.** If the site has an HTML5 `<video>` element, KoalaSync can usually sync it. YouTube, Netflix, Twitch, Prime Video, Disney+, Jellyfin, Emby, and countless niche sites work out of the box — no per-site integration to wait for, no extension swap when your friends want to switch services.
|
||||
|
||||
**Your video never touches our servers.** KoalaSync only relays tiny timing messages — play, pause, seek position, readiness — over a hand-rolled WebSocket protocol. The actual video keeps streaming directly from the original site to each viewer's browser. KoalaSync never proxies, transcodes, uploads, or redistributes a single frame, which also means there is no legal gray zone around redistribution.
|
||||
|
||||
**Privacy is the default, not an upgrade.** No accounts, no emails, no telemetry, no analytics, no behavior profiling. The official relay server runs entirely in volatile RAM and keeps zero persistent state — when the room closes, the data is gone. Pick a nickname or let KoalaSync generate one for you and you're in.
|
||||
|
||||
### Built for people who actually want to read the code
|
||||
|
||||
KoalaSync is MIT-licensed open source, built by a solo developer. Audit it, fork it, change it. The extension is dependency-free with a direct Socket.IO wire implementation — no opaque libraries, no framework bloat, no surprise third-party SDKs. Want full sovereignty? Self-host your own relay with a single Docker command and keep all watch-party coordination traffic inside your own infrastructure. The official public relay is there when you don't care, self-hosting is there when you do.
|
||||
|
||||
### Little touches you'll notice
|
||||
|
||||
- **Episode Auto-Sync** pauses the room when someone loads the next episode and resumes only when everyone is ready — no spoilers, no one left behind on the previous cliffhanger.
|
||||
- **Smart Audio Compressor** tames the modern "whisper dialogue, deafening explosion" mix with one click. Three presets or full manual control over threshold, ratio, attack, and release.
|
||||
- **One-click invite links** auto-configure the server and room for your friends — they just click the link and they're in. No fumbling with server URLs or room IDs.
|
||||
- **Dual-heartbeat architecture** kills ghost rooms and stale connections before they desync your session.
|
||||
- **15 languages** fully translated and switchable in real time from the settings panel — English, German, French, Spanish, Portuguese (Brazil + European), Russian, Italian, Polish, Turkish, Dutch, Japanese, Korean, Chinese, Ukrainian.
|
||||
|
||||
### Install and start in under a minute
|
||||
|
||||
Install KoalaSync from the Chrome Web Store or Firefox Add-ons, click "Create Room," share the invite link, and pick a video. The official relay is ready out of the box — no setup required unless you want to self-host.
|
||||
|
||||
- Website: https://sync.koalastuff.net
|
||||
- GitHub: https://github.com/Shik3i/KoalaSync
|
||||
|
||||
---
|
||||
|
||||
## Bonus: Taglines (for hero headlines, social bios, meta descriptions)
|
||||
|
||||
- Watch together. Anywhere. Privately.
|
||||
- The watch-party tool that works on every site and tracks none of them.
|
||||
- Sync play, pause, and seek on any video — no accounts, no logs, no lock-in.
|
||||
- Self-hostable, open-source watch parties for the post-"3, 2, 1, play" era.
|
||||
- Press play once. Stay together anywhere.
|
||||
@@ -4,6 +4,19 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [v2.5.0] — 2026-06-29
|
||||
|
||||
### Added
|
||||
- **Extension + Relay: Host Control Mode** — Room owners can now switch a room between open playback control and host-controlled playback. In host-only mode, guests stay synchronized but their local play, pause, and seek actions are not rebroadcast to the room.
|
||||
- **Backward-compatible Host Control rollout** — The extension only shows Host Control when the connected relay supports it, so users on older self-hosted servers do not see controls that cannot work yet.
|
||||
- **Extension: Clear host and guest states** — The popup shows the current control mode, host status, peer roles, and localized guest guidance so participants understand when playback is controlled by the host.
|
||||
- **Website: FAQ clarification for streaming access** — The landing page and FAQ structured data now state clearly that KoalaSync does not stream, host, share, or bypass access to video content. Every participant watches locally and needs their own access to services such as Netflix.
|
||||
|
||||
### Changed
|
||||
- **Playback sync now follows the room's control setting** — When Host Control is enabled, only the host can drive room-wide playback changes; guests can still watch in sync without accidentally changing playback for everyone.
|
||||
|
||||
---
|
||||
|
||||
## [v2.4.6] — 2026-06-23
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Known Limitations & Accepted Non-Issues (NOFIX)
|
||||
|
||||
This file records design decisions that are **intentional** and **out of scope** for
|
||||
KoalaSync's threat model. They are frequently re-discovered by reviewers, linters, and
|
||||
AI agents and re-reported as "bugs" or "vulnerabilities." They are not. Please read the
|
||||
threat model below before flagging any of them.
|
||||
|
||||
> **For reviewers & agents:** Do **not** re-report the NOFIX items below. They are
|
||||
> deliberate. If you think the *threat model itself* should change (e.g. KoalaSync
|
||||
> starts handling accounts, payments, or sensitive data), raise **that** discussion
|
||||
> first — these items only stand because the threat model holds.
|
||||
|
||||
---
|
||||
|
||||
## Threat model
|
||||
|
||||
KoalaSync is a **privacy-preserving watch-party sync tool**, not a security product.
|
||||
|
||||
- **Rooms are ephemeral.** They exist for a few hours and are auto-reaped. There are no
|
||||
accounts, no persistent storage, no money, and no sensitive content on the relay.
|
||||
- **The relay is a dumb, stateless message bus.** It forwards play/pause/seek between
|
||||
peers who *chose* to watch together and joined via an invite link shared out-of-band.
|
||||
- **Participants are invited.** Anyone in a room was let in. The social contract is
|
||||
"we're watching a video together," not "mutually distrusting parties."
|
||||
|
||||
### What we DO defend against
|
||||
- **Accidental disruption** — the entire point of Host Control Mode.
|
||||
- **Spam / DoS** that degrades the relay for everyone — rate limits, 4 KB payload cap,
|
||||
server-side gating, lazy-cached health responses.
|
||||
- **Resource exhaustion / memory leaks** — bounded maps, periodic cleanup, room/peer reaping.
|
||||
- **Crashes from malformed input** — strict sanitization and clamping of every field.
|
||||
- **Genuine boundary breaches** — admin-metrics auth (constant-time), CORS, WSS upgrade,
|
||||
invite-hash isolation, strict CSP. Reports here are very welcome (see `SECURITY.md`).
|
||||
|
||||
### What we explicitly DO NOT defend against
|
||||
A **determined participant who modifies their own client to misbehave inside a room they
|
||||
were invited to.** The worst they achieve is sending playback commands or seizing the
|
||||
"host" role in a temporary room they could already disrupt by other means. That is a
|
||||
**social** problem, solved socially: kick them, or start a new room. Engineering real
|
||||
identity/auth to prevent it would destroy the account-less, frictionless, privacy-first
|
||||
design — a bad trade for an ad-hoc movie night.
|
||||
|
||||
---
|
||||
|
||||
## NOFIX entries
|
||||
|
||||
### NOFIX-1 — `peerId` is unauthenticated; a crafted client can impersonate or seize the host
|
||||
**Flag:** `peerId` is client-asserted and broadcast to every peer (in `ROOM_DATA` /
|
||||
`PEER_STATUS`). A modified client can join with the host's `peerId`, dedupe-kick the real
|
||||
host, and become host — controlling or locking `host-only` mode.
|
||||
|
||||
**Why NOFIX:** Requires a *modified client* + an *invited* participant + a `peerId` that is
|
||||
only meaningful inside that one *temporary* room. The payoff is sending play/pause or
|
||||
locking a room the attacker is already in — pure trolling, instantly reversible (kick /
|
||||
new room). Cryptographic per-user identity is wildly disproportionate for an ad-hoc,
|
||||
account-less, ephemeral watch party. **Out of threat model.**
|
||||
Do **not** "fix" with accounts, signed peerIds, or per-user tokens — that breaks the
|
||||
core design.
|
||||
|
||||
### NOFIX-2 — Room-password comparison is not constant-time
|
||||
**Flag:** room password hashes are compared with `!==` (`server/index.js`), so the compare
|
||||
is theoretically timing-attackable.
|
||||
|
||||
**Why NOFIX:** The compared value is an **HMAC-SHA256 hash that never leaves the server** —
|
||||
an attacker cannot observe it to mount a timing attack. Even a hypothetical success only
|
||||
lets someone join a *temporary* room to send playback commands. Not worth defending.
|
||||
(The admin-metrics bearer token — a real boundary — **does** use `crypto.timingSafeEqual`.
|
||||
That is the line we actually guard.)
|
||||
|
||||
### NOFIX-3 — `OFFICIAL_SERVER_TOKEN` is public in the repo
|
||||
**Flag:** the connection token in `shared/constants.js` is committed, so anyone can connect.
|
||||
|
||||
**Why NOFIX:** It is a **coarse filter** to keep random scanners off the relay, **not
|
||||
authentication**. The relay is a public message bus by design; rate limits and per-room
|
||||
behavior are the real protections.
|
||||
|
||||
### NOFIX-4 — Room IDs are enumerable via `GET_ROOMS`
|
||||
**Flag:** any connected client can list all room IDs (and whether each has a password).
|
||||
|
||||
**Why NOFIX:** This is the intended **"Public Rooms"** feature. Rooms wanting privacy set a
|
||||
password; listing the IDs of password-less rooms only lets someone join a watch party —
|
||||
the same as being handed the invite link.
|
||||
|
||||
### NOFIX-5 — A pause/seek can only be reverted, not prevented
|
||||
**Flag:** in `host-only` mode a guest's pause still fires locally before the extension can
|
||||
react, so there is a brief flicker before snap-back.
|
||||
|
||||
**Why NOFIX:** A content script cannot intercept a `<video>` event before the element
|
||||
acts. Reacting (snap-back) is the only option and is by design; the ~½s flicker is
|
||||
acceptable. Not a bug.
|
||||
|
||||
---
|
||||
|
||||
## Not NOFIX — just deferred (may be revisited)
|
||||
|
||||
These are *not* accepted-forever; they are scoped out of v1 and tracked separately
|
||||
(see the host-control-mode design docs in `docs/`):
|
||||
|
||||
- **Host grace on a long disconnect (EC-10).** A brief reconnect/second-tab keeps the host
|
||||
(handled), but a long real disconnect still falls back to `everyone`. A ~30s host-reserve
|
||||
grace could be added later.
|
||||
- **Intent-classifier / snap-back tuning.** Thresholds are first-pass; real-device testing
|
||||
may adjust them.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Co-Host (Multi-Controller) — Implementation Plan
|
||||
|
||||
Branch base: `feature/host-control-mode` (builds directly on it).
|
||||
Goal: let the room owner grant **playback control to several peers** (co-hosts), not
|
||||
just one — e.g. 4 of N people in a room may drive play/pause/seek, the rest are guests.
|
||||
|
||||
This is the second server-gated feature the `capabilities` hook was designed for
|
||||
(`CAPABILITIES.CO_HOST = 'co-host'`, already stubbed in `shared/constants.js`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Roles
|
||||
|
||||
| Role | Can drive (play/pause/seek/force-sync/episode-lobby) | Can promote/demote + toggle mode |
|
||||
|------|------|------|
|
||||
| **Owner** (room creator, = today's "host") | yes (always a controller) | **yes** |
|
||||
| **Controller** (co-host) | yes (in `host-only` mode) | no |
|
||||
| **Guest** | only in `everyone` mode | no |
|
||||
|
||||
The single-host feature is just the special case `controllers = { owner }`.
|
||||
|
||||
## 2. Data model
|
||||
|
||||
### Server (`room` object)
|
||||
- `ownerPeerId` — the creator / manager. Keep `hostPeerId` as an **alias** (= ownerPeerId)
|
||||
so older clients keep working.
|
||||
- `controllers: Set<peerId>` — peers allowed to drive. **Always contains ownerPeerId.**
|
||||
- `controlMode: 'everyone' | 'host-only'` — unchanged wire values (`'host-only'` now means
|
||||
"restricted to controllers", not "single host").
|
||||
- `MAX_CONTROLLERS` cap (e.g. 10) to bound the set + payload.
|
||||
|
||||
### Shared constants
|
||||
- `CAPABILITIES.CO_HOST = 'co-host'` (un-stub it) → add to `SERVER_CAPABILITIES`.
|
||||
- New events:
|
||||
- `SET_PEER_ROLE` (client→server): `{ peerId, controller: boolean }` — owner promotes/demotes.
|
||||
- Extend `CONTROL_MODE` (server→client) payload: `{ controlMode, ownerPeerId, hostPeerId, controllers: [peerId...] }`.
|
||||
- `ROOM_DATA` gains `ownerPeerId` + `controllers`.
|
||||
|
||||
## 3. Gate generalization (the core change)
|
||||
|
||||
Today the gate compares against a single `hostPeerId`. Generalize to set membership:
|
||||
|
||||
- **Server relay gate** (`server/index.js`): `controlMode === 'host-only' && !room.controllers.has(mapping.peerId)` → drop. (Was `mapping.peerId !== room.hostPeerId`.)
|
||||
- **Background gates** (sender + receiver): replace `amHost()` / `senderId !== hostPeerId`
|
||||
with controller-set membership: `controllers.includes(myPeerId)` / `senderId ∈ controllers`.
|
||||
- **Helpers:** split `amHost()` into `amOwner()` (manage rights) and `amController()`
|
||||
(drive rights). The desync/snap-back path keys on `!amController()` instead of `!amHost()`.
|
||||
|
||||
`SET_PEER_ROLE` handler (server): validate sender is owner, target is a current peer in the
|
||||
room, enforce `MAX_CONTROLLERS`, always keep owner in the set, then broadcast `CONTROL_MODE`
|
||||
with the new `controllers`.
|
||||
|
||||
## 4. Client + UI
|
||||
|
||||
- **Owner** sees the peer list with a per-peer **"Controller" toggle** (promote/demote) plus
|
||||
the existing mode toggle.
|
||||
- **Controllers** see a "Controller" badge and are NOT locked out of the remote-control buttons.
|
||||
- **Guests** see "Guest" + the host-only notice (unchanged).
|
||||
- The promote UI + co-host badges render only when the relay advertises the `co-host`
|
||||
capability (feature detection, same pattern as `hostControlSupported`).
|
||||
- i18n: new keys (`ROLE_CONTROLLER`, `BTN_PROMOTE`, `BTN_DEMOTE`, …) across all locales.
|
||||
|
||||
## 5. Backwards compatibility
|
||||
|
||||
- **New client + old server** (host-control only, no `co-host` capability): no co-host UI;
|
||||
behaves as today's single-host. ✓
|
||||
- **Old client + new server**: ignores `controllers` / `SET_PEER_ROLE`. An old client that
|
||||
the owner promotes still **gates itself** (its sender-gate only knows `!amHost`), so it
|
||||
can't drive — it degrades to a guest. Co-host requires a client that understands
|
||||
`controllers`. Document this; not a crash. ✓
|
||||
- No `PROTOCOL_VERSION` bump needed — purely additive, same as host-control.
|
||||
|
||||
## 6. Edge cases
|
||||
- **Controller leaves** → `removePeerFromRoom` also does `room.controllers.delete(peerId)`.
|
||||
- **Owner leaves** → fallback: promote the earliest remaining **controller** to owner (prefer
|
||||
a controller over a random peer); if none, earliest peer; keep the rest of the set. Reuse
|
||||
the `peerJoinLocks` guard so a reconnect/second-tab doesn't demote (same fix as host).
|
||||
- **Promote a peer not in the room** → server rejects (target must be a live peer).
|
||||
- **Promote beyond `MAX_CONTROLLERS`** → server rejects, re-syncs the owner's UI.
|
||||
- **`everyone` mode** → the `controllers` set is still maintained (so flipping to `host-only`
|
||||
keeps the chosen co-hosts), it just isn't enforced while in `everyone`.
|
||||
- **peerId spoofing** → unchanged accepted limitation (see `docs/KNOWN_LIMITATIONS.md`);
|
||||
co-host doesn't widen it materially (still bounded to a temporary room).
|
||||
|
||||
## 7. Scale: the "4 of 510 people" part — read this
|
||||
|
||||
The role change above is moderate. **Putting 510 people in one room is a separate, larger
|
||||
problem** and should be its own track:
|
||||
|
||||
- `MAX_PEERS_PER_ROOM` is **25** today. 510 needs a large raise + load testing.
|
||||
- **The real bottleneck at scale is heartbeat fan-out, not control events.** Every peer
|
||||
heartbeats and the relay broadcasts each to all peers → O(N²) per interval. At 510 that's
|
||||
~510×509 / 15s ≈ **17k msg/s just for heartbeats** — the scaling wall.
|
||||
- **Co-host actually *helps* the control-event side:** in `host-only` mode only the few
|
||||
controllers emit play/pause/seek, so event *sources* drop from N to K (e.g. 4). Restricting
|
||||
who can drive is synergistic with big rooms.
|
||||
- Large rooms therefore need (independent of co-host):
|
||||
- **Heartbeat fan-out reduction** — e.g. only relay controller/owner heartbeats to everyone,
|
||||
relay guest heartbeats only to the owner/controllers (for the UI), or server-side
|
||||
aggregation into periodic snapshots instead of per-peer relay.
|
||||
- **`ROOM_DATA` payload trimming** — a 510-entry peer list is large; send counts + controller
|
||||
details, lazy-load the full roster.
|
||||
- Possibly the **socket.io Redis adapter** for horizontal scaling, and broadcast tuning.
|
||||
|
||||
## 8. Effort estimate
|
||||
- **Co-host roles** (server gate generalization + `SET_PEER_ROLE` + owner-leave fallback +
|
||||
client gates + promote UI + i18n), at the current ≤25-peer scale: **~3–4 dev days** (same
|
||||
shape as host-control itself — mostly generalizing host→controller-set).
|
||||
- **Large-room scaling (510)**: a **separate ~1–2 week** track (heartbeat redesign + payload
|
||||
trimming + cap raise + load testing), independent of co-host. Recommend shipping co-host at
|
||||
the current cap first, then scaling rooms as its own project.
|
||||
|
||||
## 9. Suggested sequencing
|
||||
1. `CAPABILITIES.CO_HOST` + `controllers`/`ownerPeerId` in room state + `ROOM_DATA` (additive).
|
||||
2. Server `SET_PEER_ROLE` + gate generalization + owner-leave fallback + WS tests.
|
||||
3. Background: controller-set membership in both gates + `amOwner`/`amController`.
|
||||
4. Popup: promote/demote toggles (owner) + Controller badge + i18n.
|
||||
5. (Separate track) large-room scaling.
|
||||
@@ -0,0 +1,283 @@
|
||||
# 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
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
|
||||
### Capability detection (forward-compat hook)
|
||||
The relay advertises `capabilities: ['host-control']` in `ROOM_DATA`
|
||||
(`SERVER_CAPABILITIES` in server, `CAPABILITIES` in shared/constants). The client
|
||||
enables host-control UI/behavior only when the flag is present, so the feature
|
||||
degrades cleanly on an older relay (absent → off) and old clients ignore the
|
||||
field. This is the extensible hook for the planned **co-host** feature (owner
|
||||
promotes guests to additional controllers): it will add a `'co-host'` capability
|
||||
+ events without a protocol bump or breaking older relays/clients. Add new flags
|
||||
to `CAPABILITIES` / `SERVER_CAPABILITIES` as features land.
|
||||
|
||||
### 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 `innerHTML` with inline
|
||||
`style=""` attributes, which Netflix/YouTube/Disney+ strip via `style-src`. Rebuilt
|
||||
with the DOM API (CSSOM `.style` is CSP-safe) inside a **Shadow DOM** so page CSS
|
||||
can't restyle/hide it. (content.js)
|
||||
- **Live-DVR not detected (EC-15)** — `duration === Infinity` misses Twitch/YouTube
|
||||
live-DVR (finite, sliding duration). Added a `seekable.start(0) > 1` sliding-window
|
||||
heuristic in `hcmIsLive()`. (content.js)
|
||||
|
||||
### Pre-test self-audit
|
||||
- ~~**EC-4/EC-1 snap-back thrash:**~~ FIXED — implemented the **buffer-aware deferred
|
||||
snap-back** (`hcmDeferredSnapBack`): on an involuntary event, if the player isn't
|
||||
ready (`readyState<3` or seeking) we wait (poll, 8s cap) until it can play, then snap
|
||||
ONCE to the host's re-queried position instead of repeatedly fighting the buffer.
|
||||
Done defensively/player-agnostically — we can't enumerate every site, so this is safe
|
||||
whether a player fires `pause()` or only `waiting`. Aborts if the user goes solo or is
|
||||
no longer a gated guest; single pending poll (no stacking).
|
||||
- ~~**Control-mode race at join:**~~ FIXED — `hcmHandleBlocked` now treats `HOST_BLOCKED`
|
||||
as 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-only**~~ FIXED — 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 `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. Decisions (audited)
|
||||
- [x] **Intent-classifier (EC-9):** A `pause`/`seek` is **involuntary** if ANY of:
|
||||
`readyState < 3`, `video.seeking`, a `waiting` fired < ~1500ms ago (`isBuffering`
|
||||
flag), inside `visibilityGraceUntil`, OR no own-tracked user gesture
|
||||
(`Date.now() - lastUserGestureAt < 1000`, via capturing keydown/pointerdown — do
|
||||
NOT use sticky `navigator.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.
|
||||
- [x] **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.
|
||||
- [x] **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.
|
||||
- [x] **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).
|
||||
- [x] **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.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Host Control Mode — Beta Testing Guide
|
||||
|
||||
> Temporary doc for branch `feature/host-control-mode`. Remove before merge.
|
||||
> The feature needs the **server** half too — the official relay
|
||||
> (`wss://syncserver.koalastuff.net`) doesn't run it yet, so test against a beta
|
||||
> server first.
|
||||
|
||||
## 1. Run the beta relay (Docker)
|
||||
|
||||
The branch publishes the server image to GHCR under non-production tags
|
||||
(`:beta` = newest branch build, `:sha-<commit>` = immutable pin). `:latest` is
|
||||
never touched.
|
||||
|
||||
```bash
|
||||
# Log in (the package is private → PAT with read:packages)
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u <your-gh-user> --password-stdin
|
||||
|
||||
# Pull + (re)create — NOTE: `docker restart` does NOT pick up a new image,
|
||||
# you must remove and re-run (or use compose / Watchtower).
|
||||
docker pull ghcr.io/shik3i/koalasync:beta
|
||||
docker rm -f koala-beta 2>/dev/null || true
|
||||
docker run -d --name koala-beta -p 3000:3000 \
|
||||
-e SERVER_SALT='choose-your-own-salt' \
|
||||
ghcr.io/shik3i/koalasync:beta
|
||||
```
|
||||
|
||||
To always run the newest beta automatically, point **Watchtower** at the
|
||||
container — it does the pull → recreate whenever `:beta` moves.
|
||||
|
||||
The `OFFICIAL_SERVER_TOKEN` is baked into `shared/constants.js`, so no token env
|
||||
is needed. Set `SERVER_SALT` (used for room-password hashing).
|
||||
|
||||
## 2. Connect the extension to it
|
||||
|
||||
⚠️ The client **force-upgrades `ws://` to `wss://` for any non-localhost host**
|
||||
(see background.js "Upgraded to wss:// for remote host"). So a bare
|
||||
`ws://your-server:3000` will fail without TLS. Two options:
|
||||
|
||||
- **Quick (no TLS):** SSH-tunnel the port so it counts as local:
|
||||
```bash
|
||||
ssh -L 3000:localhost:3000 your-beta-server
|
||||
```
|
||||
then in the popup → **Manual Connect / Advanced → Custom →** `ws://localhost:3000`.
|
||||
- **Proper:** put the container behind a TLS reverse proxy (Caddy does automatic
|
||||
HTTPS) → `wss://beta.yourdomain`.
|
||||
|
||||
Then create/join a room.
|
||||
|
||||
## 3. ⚠️ Use two *new* clients
|
||||
|
||||
Test with **two browser profiles both running this branch build** (load unpacked
|
||||
from `extension/`, or install the built zip). A stock release client as a guest
|
||||
will be correctly gated by the server but has none of the content-side code, so
|
||||
it silently desyncs with no dialog/snap-back — that's expected degradation, not a
|
||||
bug, but it looks like one during testing.
|
||||
|
||||
## 4. Verification checklist
|
||||
|
||||
| # | Step | Expect |
|
||||
|---|------|--------|
|
||||
| 1 | Create a room (host) | Host Control card shows **Host** + the toggle |
|
||||
| 2 | Second profile joins | Guest sees no card while mode is "everyone" |
|
||||
| 3 | Host enables "Only I can control" | Guest's Play/Pause/SYNC buttons lock; card shows **Guest** |
|
||||
| 4 | Guest presses pause/space on the video | Brief flicker, snaps back to host position |
|
||||
| 5 | Guest pauses again | Dialog: "Stay in sync" / "Watch on my own" |
|
||||
| 6 | Guest → "Watch on my own" | Persistent "Solo" badge; host's peer list shows **Solo** |
|
||||
| 7 | Guest → "Resync" | Snaps back to host; Solo badge clears on both sides |
|
||||
| 8 | Guest tries Force-Sync / seek spam | Nothing propagates to the room |
|
||||
| 9 | Host disables host-only | Card hides for guest; controls unlock |
|
||||
| 10 | Host leaves the room | Room falls back to "everyone"; a remaining peer becomes host |
|
||||
| 11 | Reload the guest's page while desynced | Still shows Solo (state survives reload) |
|
||||
| 12 | Switch the popup language | Dialog/badge text is localized |
|
||||
|
||||
## 5. Capability detection
|
||||
|
||||
The relay advertises `capabilities: ['host-control']` in `ROOM_DATA`. The client
|
||||
only enables the feature when that flag is present, so:
|
||||
- against this beta server → feature on;
|
||||
- against the old official server → feature cleanly hidden (no errors).
|
||||
|
||||
This is the extensible hook for the planned **co-host** feature (owner promotes
|
||||
guests to additional controllers) — it'll add a `'co-host'` capability + events
|
||||
without breaking older relays/clients.
|
||||
@@ -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.
|
||||
+285
-15
@@ -1,4 +1,4 @@
|
||||
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
|
||||
import { EVENTS, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
|
||||
import { generateUsername } from './shared/names.js';
|
||||
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { sameEpisode } from './episode-utils.js';
|
||||
@@ -70,6 +70,48 @@ 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)
|
||||
|
||||
// --- Host Control Mode ---
|
||||
let controlMode = CONTROL_MODES.EVERYONE; // 'everyone' | 'host-only'
|
||||
let hostPeerId = null; // peerId of the room host (creator / fallback)
|
||||
// Features the connected relay advertises in ROOM_DATA. Empty against an older
|
||||
// relay (no capabilities field) → host-control UI/behavior stays unavailable.
|
||||
let serverCapabilities = [];
|
||||
function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); }
|
||||
// Local peer's desync state (content.js reports it via HCM_DESYNC_STATE). Relayed
|
||||
// in heartbeats so the host's popup UI can show "Solo" instead of silently
|
||||
// appearing un-ACK'd.
|
||||
let hcmDesynced = false;
|
||||
// Co-Host: peerIds allowed to drive in host-only (always includes the owner).
|
||||
let controllers = [];
|
||||
function amHost() { return !!peerId && hostPeerId === peerId; } // owner: can toggle mode / promote
|
||||
function amController() { return amHost() || (!!peerId && controllers.includes(peerId)); } // can drive the room
|
||||
// Room-moving actions a guest may not initiate while in host-only mode.
|
||||
const HOST_ONLY_GATED_ACTIONS = [
|
||||
EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
|
||||
EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE,
|
||||
EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_LOBBY_CANCEL
|
||||
];
|
||||
// Best-effort estimate of where the room (host) is right now, for guest snap-back.
|
||||
// Extrapolates from the host peer's last known state. Used by content.js.
|
||||
function getHostSyncTarget() {
|
||||
if (!currentRoom || !Array.isArray(currentRoom.peers)) return null;
|
||||
const host = currentRoom.peers.find(p => (typeof p === 'object' ? p.peerId : p) === hostPeerId);
|
||||
if (!host || typeof host !== 'object') return null;
|
||||
let targetTime = typeof host.currentTime === 'number' ? host.currentTime : null;
|
||||
if (targetTime !== null && host.playbackState === 'playing' && host.lastHeartbeat) {
|
||||
// M-4: clamp extrapolation. lastHeartbeat is the *arrival* time of the host's
|
||||
// last heartbeat — beyond ~2 heartbeat intervals the host's true state is too
|
||||
// stale (they may have paused without the next heartbeat landing yet) and the
|
||||
// linear extrapolation would overshoot by tens of seconds. Cap it so the
|
||||
// guest snaps to a position within plausibility; the next heartbeat corrects.
|
||||
const elapsedSec = (Date.now() - host.lastHeartbeat) / 1000;
|
||||
if (elapsedSec > 0 && elapsedSec <= 2 * HEARTBEAT_INTERVAL / 1000) {
|
||||
targetTime += elapsedSec;
|
||||
}
|
||||
}
|
||||
return { playbackState: host.playbackState || null, targetTime };
|
||||
}
|
||||
const activePorts = new Set(); // New: track active content ports for keep-alive
|
||||
let expectedAcksCount = 0; // Snapshot of peerCount when initiating Force Sync
|
||||
|
||||
@@ -90,8 +132,14 @@ chrome.runtime.onConnect.addListener((port) => {
|
||||
}
|
||||
});
|
||||
|
||||
let _persistLastSeqTimer = null;
|
||||
function _persistLastSeq() {
|
||||
if (storageInitialized) chrome.storage.session.set({ lastSeqBySender });
|
||||
if (!storageInitialized) return;
|
||||
if (_persistLastSeqTimer) clearTimeout(_persistLastSeqTimer);
|
||||
_persistLastSeqTimer = setTimeout(() => {
|
||||
_persistLastSeqTimer = null;
|
||||
chrome.storage.session.set({ lastSeqBySender });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// --- Boot Sequence Lock ---
|
||||
@@ -113,7 +161,8 @@ function ensureState() {
|
||||
'logs', 'history', 'currentRoom', 'lastActionState',
|
||||
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
|
||||
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle',
|
||||
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt'
|
||||
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt',
|
||||
'hcmDesynced'
|
||||
], (data) => {
|
||||
clearTimeout(storageTimeout);
|
||||
if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount;
|
||||
@@ -123,7 +172,20 @@ function ensureState() {
|
||||
// New entries (added during boot) must stay at the top (index 0)
|
||||
if (data.logs) logs = [...logs, ...data.logs].slice(0, 200);
|
||||
if (data.history) history = [...history, ...data.history].slice(0, 20);
|
||||
if (data.currentRoom) currentRoom = data.currentRoom;
|
||||
if (data.currentRoom) {
|
||||
currentRoom = data.currentRoom;
|
||||
// Host Control Mode: restore role/mode/capabilities from persisted room.
|
||||
controlMode = currentRoom.controlMode || CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = currentRoom.hostPeerId || null;
|
||||
controllers = Array.isArray(currentRoom.controllers) ? currentRoom.controllers : [];
|
||||
serverCapabilities = Array.isArray(currentRoom.capabilities) ? currentRoom.capabilities : [];
|
||||
}
|
||||
if (data.hcmDesynced !== undefined) hcmDesynced = data.hcmDesynced;
|
||||
// L-2: enforce the desync invariant on restore — a persisted hcmDesynced=true
|
||||
// is stale if our restored role is no longer "gated guest" (e.g. we became
|
||||
// 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();
|
||||
if (data.lastActionState) lastActionState = data.lastActionState;
|
||||
|
||||
if (data.eventQueue) eventQueue = [...eventQueue, ...data.eventQueue].slice(0, 50);
|
||||
@@ -253,6 +315,7 @@ function createPeerData(raw) {
|
||||
currentTime: raw.currentTime != null ? raw.currentTime : null,
|
||||
volume: raw.volume != null ? raw.volume : null,
|
||||
muted: raw.muted != null ? raw.muted : null,
|
||||
desynced: raw.desynced === true, // HCM: peer is watching on their own
|
||||
lastHeartbeat: Date.now()
|
||||
};
|
||||
}
|
||||
@@ -282,7 +345,14 @@ function updateLocalPeerState(targetPeerId, updates) {
|
||||
async function getPeerId() {
|
||||
const data = await chrome.storage.local.get(['peerId']);
|
||||
if (data.peerId) return data.peerId;
|
||||
const newId = self.crypto.randomUUID().substring(0, 8);
|
||||
// 16 hex chars = 64 bits. At a busy relay (25k concurrent peers) the 32-bit
|
||||
// (8-hex) generation would hit ~7% collision probability per snapshot —
|
||||
// and a same-room collision triggers our dedup path, kicking the older
|
||||
// session with a confusing error. 16 hex chars drops the probability to
|
||||
// ~1e-10 even at a million peers, and the server already clamps peerId to
|
||||
// 16 chars (server/index.js JOIN_ROOM sanitizer). Existing persisted 8-char
|
||||
// IDs continue to work — this only affects newly-generated IDs.
|
||||
const newId = self.crypto.randomUUID().replace(/-/g, '').substring(0, 16);
|
||||
await chrome.storage.local.set({ peerId: newId });
|
||||
return newId;
|
||||
}
|
||||
@@ -435,6 +505,14 @@ async function leaveRoomAfterIdleGrace(reason) {
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
forceDisconnect();
|
||||
currentRoom = null;
|
||||
controlMode = CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = null;
|
||||
controllers = [];
|
||||
serverCapabilities = [];
|
||||
hcmDesynced = false;
|
||||
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset
|
||||
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
|
||||
broadcastControlMode();
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
roomIdleSince = null;
|
||||
@@ -446,7 +524,8 @@ async function leaveRoomAfterIdleGrace(reason) {
|
||||
currentTabTitle: null,
|
||||
roomIdleSince: null,
|
||||
lastContentHeartbeatAt: null,
|
||||
episodeLobby: null
|
||||
episodeLobby: null,
|
||||
hcmDesynced: false
|
||||
}).catch(() => {});
|
||||
await chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {});
|
||||
addLog(reason, 'info');
|
||||
@@ -638,6 +717,29 @@ async function connect() {
|
||||
}
|
||||
|
||||
|
||||
// Invariant: only a gated guest (host-only room AND not the host) can be
|
||||
// "desynced". Any role/mode change that makes us the host, or switches the room
|
||||
// to 'everyone', must clear the persisted flag — otherwise a stale value would
|
||||
// mislabel us as "Solo" to peers and (in content) keep us ignoring host commands
|
||||
// after the reason to is gone. Call after any controlMode/hostPeerId change.
|
||||
function hcmEnforceDesyncInvariant() {
|
||||
if (hcmDesynced && !(controlMode === CONTROL_MODES.HOST_ONLY && !amController())) {
|
||||
hcmDesynced = false;
|
||||
if (storageInitialized) chrome.storage.session.set({ hcmDesynced: false });
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastControlMode() {
|
||||
// Notify popup (role badge / host toggle) and the active content tab
|
||||
// (so it can enable/disable the host-only guest gate).
|
||||
const payload = { type: 'CONTROL_MODE', controlMode, hostPeerId, controllers, amHost: amHost(), amController: amController(), hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL), coHostSupported: serverSupports(CAPABILITIES.CO_HOST) };
|
||||
chrome.runtime.sendMessage(payload).catch(() => {});
|
||||
if (currentTabId) {
|
||||
const tabId = parseInt(currentTabId);
|
||||
if (!isNaN(tabId)) chrome.tabs.sendMessage(tabId, payload).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastConnectionStatus(status) {
|
||||
// No room and no intent to connect → this isn't a failure, it's the normal
|
||||
// resting state. Surface a distinct 'idle' status so the UI can say
|
||||
@@ -871,9 +973,29 @@ function handleServerEvent(event, data) {
|
||||
addLog(`Ignored server event ${event} due to empty payload`, 'warn');
|
||||
return;
|
||||
}
|
||||
// Host Control Mode (receiver-side backstop): in host-only mode, ignore
|
||||
// room-moving events from any non-controller. The server already drops these,
|
||||
// so this covers old/buggy/modified clients that slipped through.
|
||||
// Defensive: require a known hostPeerId — if the server ever sends host-only
|
||||
// without a host (state inconsistency), gate-everyone would lock the owner
|
||||
// out of their own room (L-6).
|
||||
if (controlMode === CONTROL_MODES.HOST_ONLY &&
|
||||
hostPeerId &&
|
||||
HOST_ONLY_GATED_ACTIONS.includes(event) &&
|
||||
data.senderId && data.senderId !== hostPeerId && !controllers.includes(data.senderId)) {
|
||||
addLog(`Ignored ${event} from non-controller ${data.senderId} (host-only)`, 'warn');
|
||||
return;
|
||||
}
|
||||
switch (event) {
|
||||
case EVENTS.ROOM_DATA:
|
||||
currentRoom = data;
|
||||
// Host Control Mode: adopt room role/mode on (re)join.
|
||||
controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = data.hostPeerId || null;
|
||||
controllers = Array.isArray(data.controllers) ? data.controllers : [];
|
||||
serverCapabilities = Array.isArray(data.capabilities) ? data.capabilities : [];
|
||||
hcmEnforceDesyncInvariant();
|
||||
broadcastControlMode();
|
||||
markRoomPotentiallyIdle();
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
currentRoom.peers = currentRoom.peers.map(p => typeof p === 'object' ? createPeerData(p) : { peerId: p, username: null, tabTitle: null, mediaTitle: null, playbackState: null, currentTime: null, volume: null, muted: null, lastHeartbeat: Date.now() });
|
||||
@@ -931,6 +1053,21 @@ function handleServerEvent(event, data) {
|
||||
});
|
||||
});
|
||||
break;
|
||||
case EVENTS.CONTROL_MODE:
|
||||
// Host Control Mode changed (toggle or host-leave fallback).
|
||||
controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = data.hostPeerId || null;
|
||||
controllers = Array.isArray(data.controllers) ? data.controllers : [];
|
||||
hcmEnforceDesyncInvariant();
|
||||
if (currentRoom) {
|
||||
currentRoom.controlMode = controlMode;
|
||||
currentRoom.hostPeerId = hostPeerId;
|
||||
currentRoom.controllers = controllers;
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
}
|
||||
addLog(`Control mode: ${controlMode}${amHost() ? ' (you are owner)' : (amController() ? ' (you are controller)' : '')}`, 'info');
|
||||
broadcastControlMode();
|
||||
break;
|
||||
case EVENTS.ROOM_LIST:
|
||||
chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {});
|
||||
break;
|
||||
@@ -1133,6 +1270,11 @@ function handleServerEvent(event, data) {
|
||||
peer.mediaTitle = data.mediaTitle !== undefined ? data.mediaTitle : peer.mediaTitle;
|
||||
peer.volume = data.volume !== undefined ? data.volume : peer.volume;
|
||||
peer.muted = data.muted !== undefined ? data.muted : peer.muted;
|
||||
// Only update when present. Our own heartbeats now carry
|
||||
// 'desynced', but other PEER_STATUS variants (server join
|
||||
// broadcast, future/old clients) omit it — and clobbering it
|
||||
// to false there would flicker the host's "Solo" badge.
|
||||
if (data.desynced !== undefined) peer.desynced = data.desynced === true;
|
||||
|
||||
const timeSinceReactive = peer.lastReactiveUpdate ? (Date.now() - peer.lastReactiveUpdate) : Infinity;
|
||||
const ignoreStatus = timeSinceReactive < 300;
|
||||
@@ -1366,8 +1508,12 @@ function executeEpisodeLobby() {
|
||||
|
||||
function checkEpisodeLobbyCompletion() {
|
||||
if (!episodeLobby || !currentRoom) return;
|
||||
const peerCount = currentRoom.peers ? currentRoom.peers.length : 1;
|
||||
if (episodeLobby.readyPeers.length >= peerCount) {
|
||||
const peers = Array.isArray(currentRoom.peers) ? currentRoom.peers : [];
|
||||
// M-3: desynced peers (watching on their own) sit out the lobby — their content
|
||||
// script ignores EPISODE_LOBBY and never reports ready. Don't let them block
|
||||
// completion: count only peers who actually participate.
|
||||
const participatingCount = peers.filter(p => !(typeof p === 'object' && p.desynced)).length;
|
||||
if (episodeLobby.readyPeers.length >= participatingCount) {
|
||||
executeEpisodeLobby();
|
||||
}
|
||||
}
|
||||
@@ -1473,7 +1619,8 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
peerId,
|
||||
status: 'heartbeat',
|
||||
username: settings.username,
|
||||
tabTitle: currentTabTitle
|
||||
tabTitle: currentTabTitle,
|
||||
desynced: hcmDesynced
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1485,7 +1632,15 @@ function leaveOldRoomIfSwitching(newRoomId) {
|
||||
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
|
||||
forceDisconnect();
|
||||
currentRoom = null;
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom: null });
|
||||
controlMode = CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = null;
|
||||
controllers = [];
|
||||
serverCapabilities = [];
|
||||
hcmDesynced = false;
|
||||
// Notify content.js/popup so they drop any guest-side HCM state from the
|
||||
// previous room (badge/dialog/desync) — H-2/H-3.
|
||||
broadcastControlMode();
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom: null, hcmDesynced: false });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
|
||||
// Reset force sync states
|
||||
@@ -1595,9 +1750,83 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
serverUrl: currentServerUrl,
|
||||
version: chrome.runtime.getManifest().version,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
roomPassword: currentRoom ? currentRoom.password : null,
|
||||
ping: currentPingMs
|
||||
ping: currentPingMs,
|
||||
controlMode,
|
||||
hostPeerId,
|
||||
controllers,
|
||||
amHost: amHost(),
|
||||
amController: amController(),
|
||||
hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL),
|
||||
coHostSupported: serverSupports(CAPABILITIES.CO_HOST)
|
||||
});
|
||||
} else if (message.type === 'SET_CONTROL_MODE') {
|
||||
// Popup (host) toggles the room control mode. Server validates host authority
|
||||
// and broadcasts CONTROL_MODE back, which updates our local state + UI.
|
||||
const mode = message.controlMode;
|
||||
if (mode !== CONTROL_MODES.EVERYONE && mode !== CONTROL_MODES.HOST_ONLY) {
|
||||
sendResponse({ status: 'invalid' });
|
||||
return;
|
||||
}
|
||||
if (!amHost()) {
|
||||
sendResponse({ status: 'not_host' });
|
||||
return;
|
||||
}
|
||||
emit(EVENTS.SET_CONTROL_MODE, { controlMode: mode });
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'SET_PEER_ROLE') {
|
||||
// Popup (owner) promotes/demotes a peer to/from controller. Server validates
|
||||
// owner authority and broadcasts CONTROL_MODE back, refreshing all clients.
|
||||
const targetPeerId = typeof message.peerId === 'string' ? message.peerId : null;
|
||||
if (!targetPeerId) {
|
||||
sendResponse({ status: 'invalid' });
|
||||
return;
|
||||
}
|
||||
if (!amHost()) {
|
||||
sendResponse({ status: 'not_owner' });
|
||||
return;
|
||||
}
|
||||
emit(EVENTS.SET_PEER_ROLE, { peerId: targetPeerId, controller: message.controller === true });
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'GET_CONTROL_MODE') {
|
||||
// content.js asks for current mode/role on (re)injection. Include the
|
||||
// persisted desync state so a page reload re-adopts it — otherwise a fresh
|
||||
// content script would start synced while background keeps relaying us as
|
||||
// "Solo" to the host (stale-badge split-brain).
|
||||
sendResponse({ controlMode, hostPeerId, controllers, amHost: amHost(), amController: amController(), desynced: hcmDesynced, hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL), coHostSupported: serverSupports(CAPABILITIES.CO_HOST) });
|
||||
} else if (message.type === 'REQUEST_HOST_SYNC') {
|
||||
// content.js resync: hand back the host's extrapolated current position.
|
||||
sendResponse({ target: getHostSyncTarget() });
|
||||
} else if (message.type === 'GET_HCM_STRINGS') {
|
||||
// Localized strings for the in-page host-control dialog/badge. content.js
|
||||
// has no i18n loader of its own, so background resolves them here.
|
||||
const settings = await chrome.storage.local.get(['locale']);
|
||||
const lang = settings.locale || getSystemLanguage();
|
||||
await loadLocale(lang);
|
||||
// getMessage returns the key name itself if the dictionary failed to load.
|
||||
// Return undefined in that case so content keeps its English fallback rather
|
||||
// than rendering a raw key like "HCM_DIALOG_TITLE".
|
||||
const m = (k) => { const v = getMessage(k); return v === k ? undefined : v; };
|
||||
sendResponse({
|
||||
title: m('HCM_DIALOG_TITLE'),
|
||||
body: m('HCM_DIALOG_BODY'),
|
||||
stay: m('HCM_DIALOG_STAY'),
|
||||
solo: m('HCM_DIALOG_SOLO'),
|
||||
badge: m('HCM_BADGE_SOLO'),
|
||||
resync: m('HCM_BADGE_RESYNC')
|
||||
});
|
||||
} else if (message.type === 'HCM_DESYNC_STATE') {
|
||||
// content.js tells us whether the local user chose to watch on their own.
|
||||
// Only accept from the currently selected tab.
|
||||
if (sender.tab && currentTabId && currentTabId !== sender.tab.id) {
|
||||
sendResponse({ status: 'ignored_unselected_tab' });
|
||||
return;
|
||||
}
|
||||
// Mirrored into heartbeats so the host's UI can show "Solo" instead of
|
||||
// silently waiting for ACKs that will never come. Persisted so the
|
||||
// heartbeat survives SW restarts (idle timeout, crash).
|
||||
hcmDesynced = !!message.desynced;
|
||||
if (storageInitialized) chrome.storage.session.set({ hcmDesynced });
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'LEAVE_ROOM') {
|
||||
connectIntent = false;
|
||||
reconnectFailed = false;
|
||||
@@ -1606,6 +1835,14 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
resetAudioProcessingInTab(currentTabId);
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
currentRoom = null;
|
||||
controlMode = CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = null;
|
||||
controllers = [];
|
||||
serverCapabilities = [];
|
||||
hcmDesynced = false;
|
||||
// Notify content.js/popup BEFORE currentTabId is cleared so they drop any
|
||||
// stale guest-side HCM state (dialog/badge/desync) — H-2/H-3.
|
||||
broadcastControlMode();
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
roomIdleSince = null;
|
||||
@@ -1631,7 +1868,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null,
|
||||
episodeLobby: null,
|
||||
expectedAcksCount: 0
|
||||
expectedAcksCount: 0,
|
||||
hcmDesynced: false
|
||||
});
|
||||
chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {});
|
||||
addLog('Left Room', 'info');
|
||||
@@ -1704,7 +1942,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
sendResponse({ status: 'ok' });
|
||||
});
|
||||
} else if (message.type === 'REGENERATE_ID') {
|
||||
const newId = self.crypto.randomUUID().substring(0, 8);
|
||||
// Match getPeerId()'s 16-hex-char generation — see comment there.
|
||||
const newId = self.crypto.randomUUID().replace(/-/g, '').substring(0, 16);
|
||||
chrome.storage.local.set({ peerId: newId }, () => {
|
||||
peerId = newId;
|
||||
addLog(`Identity regenerated: ${newId}`, 'success');
|
||||
@@ -1726,6 +1965,25 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
});
|
||||
} else if (message.type === 'CONTENT_EVENT') {
|
||||
const processEvent = () => {
|
||||
// Host Control Mode (sender-side): a non-controller in host-only mode must
|
||||
// not drive the room. Don't broadcast; hand the action back to content.js so
|
||||
// it can snap the local player back / offer desync.
|
||||
// Defensive: require a known hostPeerId (L-6) — otherwise the actual
|
||||
// owner would gate themselves if state ever becomes inconsistent.
|
||||
if (controlMode === CONTROL_MODES.HOST_ONLY && hostPeerId && !amController() &&
|
||||
HOST_ONLY_GATED_ACTIONS.includes(message.action)) {
|
||||
addLog(`Host-only: blocked local ${message.action} (you are a guest)`, 'warn');
|
||||
if (sender.tab && sender.tab.id) {
|
||||
chrome.tabs.sendMessage(sender.tab.id, {
|
||||
type: 'HOST_BLOCKED',
|
||||
action: message.action,
|
||||
target: getHostSyncTarget()
|
||||
}).catch(() => {});
|
||||
}
|
||||
sendResponse({ status: 'blocked_host_only' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Live solo check — recomputed from the current peer list on every
|
||||
// event (the list is updated synchronously on PEER_STATUS join/leave),
|
||||
// never cached, so the instant a peer joins we resume sending.
|
||||
@@ -1864,7 +2122,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
markRoomUseful();
|
||||
getSettings().then(settings => {
|
||||
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle };
|
||||
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle, desynced: hcmDesynced };
|
||||
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
||||
if (otherCount > 0) emit(EVENTS.PEER_STATUS, statusPayload);
|
||||
|
||||
@@ -1943,6 +2201,18 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Host Control Mode: a gated guest must NOT initiate an episode lobby — the
|
||||
// server drops the guest's EPISODE_LOBBY, so the lobby would never complete
|
||||
// and the guest would self-pause (PAUSE_FOR_LOBBY) into a 60s freeze. In
|
||||
// host-only the controllers (owner + co-hosts) drive episode sync; a plain
|
||||
// guest just follows / snaps back. Use amController() for parity with the
|
||||
// CONTENT_EVENT gate and the server's controllers-based check.
|
||||
if (controlMode === CONTROL_MODES.HOST_ONLY && !amController()) {
|
||||
addLog(`Episode change ("${newTitle}") — host-only guest, not creating a lobby (controller drives).`, 'info');
|
||||
sendResponse({ status: 'host_only_guest_skip' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Variant A: alone in the room → no one to wait for. Skip the lobby
|
||||
// entirely so the next episode just plays through (no pause, no traffic).
|
||||
// Live peer check, so the moment someone joins the next transition syncs.
|
||||
|
||||
+387
-1
@@ -102,6 +102,306 @@
|
||||
}
|
||||
});
|
||||
|
||||
// --- Host Control Mode (guest-side) ---
|
||||
// When a room is in 'host-only' mode and we're a guest, a deliberate local
|
||||
// pause/seek must not drive the room (background/server already drop it). Here
|
||||
// we handle the *local* UX: snap back to the host's position, or — if the user
|
||||
// really wants to — let them go solo (desync) with a resync escape hatch.
|
||||
let hcmControlMode = 'everyone'; // mirror of room control mode
|
||||
let hcmAmController = false; // are we allowed to drive (owner or co-host)?
|
||||
let hcmHostPeerId = null; // last known host peerId (room/host identity)
|
||||
let hcmDesynced = false; // user chose to go solo
|
||||
let hcmSnapBackCooldownUntil = 0; // suppress re-trigger right after a snap-back
|
||||
let hcmDeferredSnapPending = false; // a buffer-aware snap-back is waiting for readiness
|
||||
let hcmLastUserGestureAt = 0; // for deliberate-vs-involuntary classification
|
||||
let hcmBufferingUntil = 0; // set on 'waiting' — buffering grace window
|
||||
let hcmDialogTimer = null; // 8s auto-stay timer — cleared on dialog replace (H-4)
|
||||
// Localized strings for the in-page dialog/badge (content has no i18n loader;
|
||||
// background resolves them via GET_HCM_STRINGS on init). English fallbacks here.
|
||||
const hcmStrings = {
|
||||
title: 'KoalaSync · Host controls this room',
|
||||
body: 'Only the host can control playback in this room. Keep watching together, or watch on your own?',
|
||||
stay: 'Stay in sync',
|
||||
solo: 'Watch on my own',
|
||||
badge: 'Watching on your own',
|
||||
resync: 'Resync'
|
||||
};
|
||||
const HCM_USER_GESTURE_MS = 1000;
|
||||
const HCM_BUFFERING_GRACE_MS = 1500;
|
||||
const HCM_SNAP_BACK_COOLDOWN_MS = 1000;
|
||||
const HCM_BUFFER_WAIT_MS = 8000; // cap on waiting for a buffering player before snapping anyway
|
||||
|
||||
// Track genuine user input so we can tell a deliberate pause/seek from a
|
||||
// player-/browser-initiated one. Capturing + passive so we never interfere.
|
||||
const _hcmGesture = () => { hcmLastUserGestureAt = Date.now(); };
|
||||
document.addEventListener('keydown', _hcmGesture, { capture: true, passive: true });
|
||||
document.addEventListener('pointerdown', _hcmGesture, { capture: true, passive: true });
|
||||
|
||||
function hcmIsGuestGated() {
|
||||
return hcmControlMode === 'host-only' && !hcmAmController;
|
||||
}
|
||||
|
||||
// EC-9 intent classifier: only a *clearly deliberate* guest action triggers the
|
||||
// dialog/snap-back. Anything that smells involuntary (buffering, seeking, tab
|
||||
// refocus, no recent gesture) is treated as involuntary. Bias intentional —
|
||||
// in host-only the guest never broadcasts anyway, so this only tunes UX.
|
||||
function hcmClassifyIntent() {
|
||||
const video = findVideo();
|
||||
if (!video) return 'involuntary';
|
||||
if (hcmIsLive(video)) return 'live'; // EC-15: degrade, don't gate
|
||||
if (video.readyState < 3) return 'involuntary'; // buffering / not enough data
|
||||
if (video.seeking) return 'involuntary';
|
||||
if (Date.now() < hcmBufferingUntil) return 'involuntary';
|
||||
if (Date.now() < visibilityGraceUntil) return 'involuntary';
|
||||
if (Date.now() - hcmLastUserGestureAt > HCM_USER_GESTURE_MS) return 'involuntary';
|
||||
return 'deliberate';
|
||||
}
|
||||
|
||||
// Live detection (EC-15 + DVR). Pure live reports duration Infinity/NaN. Live-DVR
|
||||
// (Twitch/YouTube-live with rewind) reports a *finite, sliding* duration — its
|
||||
// seekable window doesn't start at 0, which we use as the DVR signal.
|
||||
function hcmIsLive(video) {
|
||||
// Don't trust duration before metadata has loaded (readyState >= 1) —
|
||||
// otherwise pre-loaded videos report NaN and get misclassified as live,
|
||||
// which suppresses the desync dialog (L-2).
|
||||
if (video.readyState < 1) return false;
|
||||
if (!Number.isFinite(video.duration)) return true;
|
||||
try {
|
||||
const s = video.seekable;
|
||||
if (s && s.length > 0 && s.start(0) > 1) return true; // sliding DVR window
|
||||
} catch (_e) { /* seekable may throw if empty */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
// Snap the local player back to the host's current position/state.
|
||||
function hcmSnapBackToHost(target) {
|
||||
if (hcmDesynced) return; // user opted out — never yank them back automatically
|
||||
hcmSnapBackCooldownUntil = Date.now() + HCM_SNAP_BACK_COOLDOWN_MS;
|
||||
const video = findVideo();
|
||||
if (!video) return;
|
||||
if (target && Number.isFinite(target.targetTime)) {
|
||||
tryMediaAction(EVENTS.SEEK, { targetTime: target.targetTime });
|
||||
}
|
||||
// Adopt the host's play/pause state — but ONLY if we actually know it.
|
||||
// Defaulting to PLAY when the state is unknown would auto-resume a paused
|
||||
// video against the host's real state (H-1).
|
||||
if (target && target.playbackState === 'paused') {
|
||||
tryMediaAction(EVENTS.PAUSE);
|
||||
} else if (target && target.playbackState === 'playing') {
|
||||
tryMediaAction(EVENTS.PLAY);
|
||||
}
|
||||
reportLog('Host-only: snapped back to host position', 'info');
|
||||
}
|
||||
|
||||
// Resync to the host's current position, retrying briefly if the host's state
|
||||
// isn't known yet (e.g. they just paused and no heartbeat has propagated) —
|
||||
// otherwise the request is a silent no-op and the user thinks they're synced
|
||||
// when they aren't. Shared by the dialog's "Stay in sync" and the Resync badge.
|
||||
function hcmRequestHostSyncWithRetry() {
|
||||
let attempts = 0;
|
||||
const tryOnce = () => {
|
||||
chrome.runtime.sendMessage({ type: 'REQUEST_HOST_SYNC' }, (res) => {
|
||||
if (chrome.runtime.lastError || !res || !res.target) {
|
||||
if (++attempts < 5) setTimeout(tryOnce, 250);
|
||||
else reportLog('Host-only: resync requested but host state unavailable', 'warn');
|
||||
return;
|
||||
}
|
||||
hcmSnapBackToHost(res.target);
|
||||
});
|
||||
};
|
||||
tryOnce();
|
||||
}
|
||||
|
||||
// Buffer-aware snap-back (#3). An involuntary pause/seek often coincides with the
|
||||
// player buffering — and a player can't actually play while it's stalled. Snapping
|
||||
// immediately just fights the buffer (seek → re-buffer → another pause → …), which
|
||||
// looks like stutter. Instead: if the player isn't ready, wait until it can play
|
||||
// (readyState>=3, not seeking), then snap ONCE to the host's *current* position
|
||||
// (re-queried, since the captured target may be stale by the time buffering ends).
|
||||
// Player-agnostic on purpose — we can't enumerate every site, so this must be safe
|
||||
// regardless of whether a given player fires 'pause' or only 'waiting'.
|
||||
function hcmDeferredSnapBack() {
|
||||
if (hcmDeferredSnapPending) return; // already waiting — don't stack polls
|
||||
hcmDeferredSnapPending = true;
|
||||
const deadline = Date.now() + HCM_BUFFER_WAIT_MS;
|
||||
const poll = () => {
|
||||
// Abort if the reason to snap is gone: user went solo, we're no longer a
|
||||
// gated guest, or the video vanished.
|
||||
if (hcmDesynced || !hcmIsGuestGated()) { hcmDeferredSnapPending = false; return; }
|
||||
const video = findVideo();
|
||||
const ready = video && video.readyState >= 3 && !video.seeking;
|
||||
if (ready || Date.now() >= deadline) {
|
||||
hcmDeferredSnapPending = false;
|
||||
hcmRequestHostSyncWithRetry(); // fresh host position + snap once
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, 300);
|
||||
};
|
||||
poll();
|
||||
}
|
||||
|
||||
// Entry point: background told us our local action was blocked in host-only.
|
||||
function hcmHandleBlocked(action, target) {
|
||||
// HOST_BLOCKED is only ever sent to a gated guest (background verifies
|
||||
// host-only + !host before sending), so it's authoritative. Adopt the
|
||||
// role/mode from it in case our CONTROL_MODE broadcast hasn't landed yet
|
||||
// (join race, EC-5) — otherwise we'd miss the dialog/snap-back.
|
||||
hcmControlMode = 'host-only';
|
||||
hcmAmController = false;
|
||||
if (hcmDesynced) return; // already solo, nothing to do
|
||||
|
||||
const intent = hcmClassifyIntent();
|
||||
if (intent === 'live') return; // EC-15: leave the guest alone on live
|
||||
if (intent === 'involuntary') {
|
||||
// EC-4 loop guard: only the silent auto snap-back is suppressed by the
|
||||
// cooldown — the deliberate dialog path below must still go through,
|
||||
// otherwise a second deliberate pause inside the cooldown window leaves
|
||||
// the user stuck paused with no UI (M-3).
|
||||
if (Date.now() < hcmSnapBackCooldownUntil || hcmDeferredSnapPending) return;
|
||||
// Buffering/ads/throttle — silently re-sync, no dialog spam.
|
||||
const video = findVideo();
|
||||
if (video && video.readyState >= 3 && !video.seeking) {
|
||||
// Ready now → snap immediately. Use the captured target if it's
|
||||
// usable, otherwise re-query+retry (host state may not be known yet)
|
||||
// so we never leave the guest silently stuck (consistent with the
|
||||
// deferred and "Stay in sync" paths).
|
||||
if (target && Number.isFinite(target.targetTime)) hcmSnapBackToHost(target);
|
||||
else hcmRequestHostSyncWithRetry();
|
||||
} else {
|
||||
hcmDeferredSnapBack(); // buffering → wait for ready, then snap once (#3)
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Deliberate: offer the choice (Teleparty-style), default = snap back.
|
||||
hcmShowDesyncDialog(action, target);
|
||||
}
|
||||
|
||||
// --- In-page UI (dialog + persistent desync badge) ---
|
||||
// Built with the DOM API (CSSOM .style is CSP-safe; inline style="" in innerHTML
|
||||
// is stripped by strict style-src on Netflix/YouTube/Disney+). Hosted in a
|
||||
// Shadow DOM so the page's CSS can't restyle or hide our controls.
|
||||
let hcmDialogHost = null; // shadow host element for the dialog
|
||||
let hcmBadgeHost = null; // shadow host element for the persistent badge
|
||||
let hcmBadgePending = false; // retry flag for early-injection badge creation (L-4)
|
||||
|
||||
function hcmEl(tag, css, text) {
|
||||
const el = document.createElement(tag);
|
||||
if (css) el.style.cssText = css; // CSSOM assignment — not gated by CSP
|
||||
if (text != null) el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
function hcmRemoveDialog() {
|
||||
// Cancel any pending auto-stay timer so a replaced dialog's stale closure
|
||||
// can't later remove its successor / snap to an outdated target (H-4).
|
||||
if (hcmDialogTimer) { clearTimeout(hcmDialogTimer); hcmDialogTimer = null; }
|
||||
if (hcmDialogHost) { hcmDialogHost.remove(); hcmDialogHost = null; }
|
||||
}
|
||||
|
||||
function hcmShowDesyncDialog(action, target) {
|
||||
if (!document.body) { hcmSnapBackToHost(target); return; }
|
||||
hcmRemoveDialog();
|
||||
const host = hcmEl('div', 'all:initial');
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
|
||||
const wrap = hcmEl('div', 'position:fixed;z-index:2147483647;left:50%;bottom:32px;transform:translateX(-50%);background:#1f2937;color:#f9fafb;font:14px/1.4 system-ui,sans-serif;padding:16px 18px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.45);max-width:360px;border:1px solid #374151');
|
||||
wrap.setAttribute('role', 'dialog');
|
||||
const title = hcmEl('div', 'font-weight:600;margin-bottom:6px', hcmStrings.title);
|
||||
const body = hcmEl('div', 'margin-bottom:12px;color:#d1d5db', hcmStrings.body);
|
||||
const btnRow = hcmEl('div', 'display:flex;gap:8px;justify-content:flex-end');
|
||||
const soloBtn = hcmEl('button', 'background:#374151;color:#f9fafb;border:0;padding:8px 12px;border-radius:8px;cursor:pointer', hcmStrings.solo);
|
||||
const stayBtn = hcmEl('button', 'background:#10b981;color:#062a20;border:0;padding:8px 12px;border-radius:8px;cursor:pointer;font-weight:600', hcmStrings.stay);
|
||||
btnRow.append(soloBtn, stayBtn);
|
||||
wrap.append(title, body, btnRow);
|
||||
root.appendChild(wrap);
|
||||
document.body.appendChild(host);
|
||||
hcmDialogHost = host;
|
||||
|
||||
let settled = false;
|
||||
// Re-query the host's current position on click instead of using the
|
||||
// potentially stale target captured at HOST_BLOCKED time (M-1).
|
||||
const stay = () => {
|
||||
if (settled) return; settled = true; hcmRemoveDialog();
|
||||
hcmRequestHostSyncWithRetry();
|
||||
};
|
||||
const solo = () => { if (settled) return; settled = true; hcmRemoveDialog(); hcmEnterDesync(); };
|
||||
stayBtn.addEventListener('click', stay);
|
||||
soloBtn.addEventListener('click', solo);
|
||||
// EC-18: if the user ignores the prompt, default to staying in sync.
|
||||
hcmDialogTimer = setTimeout(() => { if (!settled) stay(); }, 8000);
|
||||
}
|
||||
|
||||
function hcmEnterDesync() {
|
||||
hcmDesynced = true;
|
||||
reportLog('Host-only: you chose to watch on your own (desynced)', 'warn');
|
||||
// Notify background so it can relay our desynced state to the host via
|
||||
// heartbeats — the host's UI then knows we're not following commands
|
||||
// instead of appearing silently un-ACK'd.
|
||||
chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: true }).catch(() => {});
|
||||
hcmShowBadge();
|
||||
}
|
||||
|
||||
function hcmExitDesync() {
|
||||
const wasDesynced = hcmDesynced;
|
||||
hcmDesynced = false;
|
||||
hcmRemoveBadge();
|
||||
if (wasDesynced) {
|
||||
chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {});
|
||||
}
|
||||
// Resync to the host's current position (retries if host state not yet known).
|
||||
hcmRequestHostSyncWithRetry();
|
||||
reportLog('Host-only: resynced with the host', 'info');
|
||||
}
|
||||
|
||||
function hcmShowBadge() {
|
||||
if (hcmBadgeHost) return;
|
||||
if (!document.body) {
|
||||
// Body not ready yet (very early injection). Defer until DOMReady,
|
||||
// otherwise the desynced user silently never sees the badge (L-4).
|
||||
if (!hcmBadgePending) {
|
||||
hcmBadgePending = true;
|
||||
const retry = () => {
|
||||
hcmBadgePending = false;
|
||||
if (hcmDesynced && !hcmBadgeHost) hcmShowBadge();
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', retry, { once: true });
|
||||
} else {
|
||||
setTimeout(retry, 50);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const host = hcmEl('div', 'all:initial');
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
const b = hcmEl('div', 'position:fixed;z-index:2147483646;right:16px;bottom:16px;background:#b45309;color:#fff;font:13px/1.3 system-ui,sans-serif;padding:8px 12px;border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.4);cursor:pointer;display:flex;align-items:center;gap:8px');
|
||||
b.append(hcmEl('span', null, '● ' + hcmStrings.badge), hcmEl('span', 'text-decoration:underline', hcmStrings.resync));
|
||||
b.addEventListener('click', hcmExitDesync);
|
||||
root.appendChild(b);
|
||||
document.body.appendChild(host);
|
||||
hcmBadgeHost = host;
|
||||
}
|
||||
|
||||
function hcmRemoveBadge() {
|
||||
if (hcmBadgeHost) { hcmBadgeHost.remove(); hcmBadgeHost = null; }
|
||||
}
|
||||
|
||||
function hcmReset() {
|
||||
const wasDesynced = hcmDesynced;
|
||||
hcmDesynced = false;
|
||||
hcmDeferredSnapPending = false;
|
||||
hcmSnapBackCooldownUntil = 0; // don't let a stale cooldown swallow the next snap-back
|
||||
hcmBufferingUntil = 0;
|
||||
hcmRemoveDialog();
|
||||
hcmRemoveBadge();
|
||||
// If we were desynced, notify background so it stops reporting us as
|
||||
// desynced in heartbeats (otherwise the host's UI keeps showing the
|
||||
// stale Solo badge until the next state change).
|
||||
if (wasDesynced) {
|
||||
chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function reportLog(message, level = 'info') {
|
||||
chrome.runtime.sendMessage({ type: 'LOG', message, level }).catch(() => {});
|
||||
}
|
||||
@@ -356,6 +656,10 @@
|
||||
|
||||
reportLog(`Episode transition detected: "${newTitle}"`, 'info');
|
||||
|
||||
// EC-12: a new episode dissolves any solo/desync state — the guest rejoins
|
||||
// the room for the fresh content rather than staying stuck on the old one.
|
||||
if (hcmDesynced) hcmReset();
|
||||
|
||||
// Do NOT pause here. We notify background.js first.
|
||||
// Background checks the setting; if enabled it creates a lobby
|
||||
// and sends back PAUSE_FOR_LOBBY so we only freeze if the feature is on.
|
||||
@@ -538,6 +842,29 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
// Host Control Mode: room mode/role changed.
|
||||
if (message.type === 'CONTROL_MODE') {
|
||||
const wasGated = hcmIsGuestGated();
|
||||
const prevHostPeerId = hcmHostPeerId;
|
||||
hcmControlMode = message.controlMode || 'everyone';
|
||||
hcmAmController = !!message.amController;
|
||||
hcmHostPeerId = message.hostPeerId || null;
|
||||
// Reset guest-side state when leaving the gated state, OR when the
|
||||
// host identity changes (room switch, host-leave fallback, missed
|
||||
// teardown broadcast) — clears stale desync so a rejoin starts clean (H-3).
|
||||
const hostChanged = prevHostPeerId !== null && hcmHostPeerId !== prevHostPeerId;
|
||||
if ((wasGated && !hcmIsGuestGated()) || hostChanged) hcmReset();
|
||||
sendResponse({ ok: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Host Control Mode: background blocked our local action — handle UX locally.
|
||||
if (message.type === 'HOST_BLOCKED') {
|
||||
hcmHandleBlocked(message.action, message.target || null);
|
||||
sendResponse({ ok: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Background asks for an immediate state push (e.g. the first peer just
|
||||
// joined while we were solo) so the newcomer syncs without waiting.
|
||||
if (message.type === 'REQUEST_HEARTBEAT') {
|
||||
@@ -550,6 +877,21 @@
|
||||
const { action, payload } = message;
|
||||
let actionCompleted = false;
|
||||
|
||||
// Host Control Mode: while watching on our own (desynced), don't apply
|
||||
// host commands. Only ACK FORCE_SYNC_PREPARE — that's the one the host's
|
||||
// force-sync flow actually waits on. Skipping CMD_ACKs for PLAY/PAUSE/SEEK
|
||||
// is intentional so the host's UI honestly reflects that we didn't apply
|
||||
// the command (M-2); sending them would make the host think we're synced.
|
||||
if (hcmDesynced) {
|
||||
const soloIgnored = [EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE];
|
||||
if (soloIgnored.includes(action)) {
|
||||
if (action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Guard: Don't execute sync commands if peers are on different episodes.
|
||||
// Only active when autoSyncNextEpisode setting is enabled (default: on).
|
||||
// Only blocks when BOTH sides have parseable S01E01-style IDs that differ.
|
||||
@@ -619,6 +961,15 @@
|
||||
|
||||
// Episode Auto-Sync: Lobby notification from background
|
||||
if (message.type === 'EPISODE_LOBBY') {
|
||||
// Host Control Mode: a desynced guest is watching on their own and must
|
||||
// not join the lobby flow. Otherwise they'd pause on title match, report
|
||||
// ready, but then ignore the host's FORCE_SYNC_* (hcmDesynced skip in
|
||||
// SERVER_COMMAND) and end up frozen in pause. They also can't be counted
|
||||
// toward lobby completion (background filters them out).
|
||||
if (hcmDesynced) {
|
||||
sendResponse({ status: 'ignored_desynced' });
|
||||
return true;
|
||||
}
|
||||
const expectedTitle = message.expectedTitle;
|
||||
if (expectedTitle) {
|
||||
reportLog(`Episode lobby received: waiting for "${expectedTitle}"`, 'info');
|
||||
@@ -924,6 +1275,9 @@
|
||||
|
||||
const handlePlay = () => reportEvent(EVENTS.PLAY);
|
||||
const handlePause = () => reportEvent(EVENTS.PAUSE);
|
||||
// Host Control Mode: a 'waiting' (buffering) event opens a grace window so the
|
||||
// pause it may trigger isn't misread as a deliberate guest action (EC-1).
|
||||
const handleWaiting = () => { hcmBufferingUntil = Date.now() + HCM_BUFFERING_GRACE_MS; };
|
||||
|
||||
// Seek filtering: ignore HLS/DASH buffering micro-seeks.
|
||||
// Only relay if delta >= MIN_SEEK_DELTA AND not already debouncing.
|
||||
@@ -994,13 +1348,15 @@
|
||||
video.removeEventListener('pause', existing.pause);
|
||||
video.removeEventListener('seeked', existing.seeked);
|
||||
video.removeEventListener('loadeddata', existing.loadeddata);
|
||||
if (existing.waiting) video.removeEventListener('waiting', existing.waiting);
|
||||
}
|
||||
video._koalaHandlers = { play: handlePlay, pause: handlePause, seeked: handleSeeked, loadeddata: handleLoadedData };
|
||||
video._koalaHandlers = { play: handlePlay, pause: handlePause, seeked: handleSeeked, loadeddata: handleLoadedData, waiting: handleWaiting };
|
||||
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener('seeked', handleSeeked);
|
||||
video.addEventListener('loadeddata', handleLoadedData);
|
||||
video.addEventListener('waiting', handleWaiting);
|
||||
video.dataset.koalaAttached = 'true';
|
||||
lastVideoSrc = video.currentSrc || video.src || null;
|
||||
|
||||
@@ -1136,4 +1492,34 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Host Control Mode: fetch current room mode/role on injection (we may have
|
||||
// been injected after ROOM_DATA already arrived, missing the broadcast).
|
||||
chrome.runtime.sendMessage({ type: 'GET_CONTROL_MODE' }, (res) => {
|
||||
if (chrome.runtime.lastError || !res) return;
|
||||
hcmControlMode = res.controlMode || 'everyone';
|
||||
hcmAmController = !!res.amController;
|
||||
hcmHostPeerId = res.hostPeerId || null;
|
||||
// Re-adopt persisted desync after a page reload so we don't start synced
|
||||
// while background still relays us as "Solo" to the host (split-brain).
|
||||
// Only when we're actually a gated guest — never adopt a stale flag as a
|
||||
// controller or in 'everyone' mode (would self-label "Solo" / ignore commands).
|
||||
if (res.desynced && res.controlMode === 'host-only' && !res.amController && !hcmDesynced) {
|
||||
hcmDesynced = true;
|
||||
hcmShowBadge();
|
||||
}
|
||||
});
|
||||
|
||||
// Pull localized strings for the in-page dialog/badge (English fallback above).
|
||||
chrome.runtime.sendMessage({ type: 'GET_HCM_STRINGS' }, (res) => {
|
||||
if (chrome.runtime.lastError || !res) return;
|
||||
Object.keys(hcmStrings).forEach(k => { if (res[k]) hcmStrings[k] = res[k]; });
|
||||
// If the badge is already showing (early desync), refresh its text in place.
|
||||
// Re-creating the host element nukes the click target mid-poll and can drop a
|
||||
// click that landed between remove() and the re-create (L-4).
|
||||
if (hcmBadgeHost) {
|
||||
const span = hcmBadgeHost.shadowRoot && hcmBadgeHost.shadowRoot.querySelector('span');
|
||||
if (span) span.textContent = '● ' + hcmStrings.badge;
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Teile diesen Link mit Freunden, damit sie beitreten können",
|
||||
"LABEL_PEERS_IN_ROOM": "Teilnehmer im Raum",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Andere Benutzer, die derzeit mit diesem Raum verbunden sind",
|
||||
"LABEL_HOST_CONTROL": "Host-Steuerung",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Festlegen, wer die Wiedergabe in diesem Raum steuern darf",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Nur ich darf die Wiedergabe steuern",
|
||||
"NOTICE_HOST_CONTROLS": "Der Host steuert die Wiedergabe für alle.",
|
||||
"NOTICE_COHOST_HINT": "Tipp: Gib einzelnen Teilnehmern unten in der Teilnehmerliste die Steuerung.",
|
||||
"BADGE_HOST": "Host",
|
||||
"BADGE_GUEST": "Gast",
|
||||
"BADGE_CONTROLLER": "Controller",
|
||||
"BTN_GIVE_CONTROL": "Steuerung geben",
|
||||
"BTN_REVOKE_CONTROL": "Entziehen",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Schaut alleine — ignoriert die Befehle des Hosts",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · Der Host steuert diesen Raum",
|
||||
"HCM_DIALOG_BODY": "Nur der Host kann die Wiedergabe in diesem Raum steuern. Weiter gemeinsam schauen oder allein weiterschauen?",
|
||||
"HCM_DIALOG_STAY": "Synchron bleiben",
|
||||
"HCM_DIALOG_SOLO": "Allein weiterschauen",
|
||||
"HCM_BADGE_SOLO": "Du schaust allein",
|
||||
"HCM_BADGE_RESYNC": "Neu sync",
|
||||
"NO_PEERS_CONNECTED": "Keine Teilnehmer verbunden",
|
||||
"BTN_LEAVE_ROOM": "Raum verlassen",
|
||||
"LABEL_SELECT_VIDEO": "Video auswählen",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Generiere deine interne ID neu und verbinde dich erneut",
|
||||
"REGEN_ID_DESC": "Verwende dies, wenn du Fehler wegen doppelter Identität siehst.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Anderes Problem? Öffne ein GitHub Issue",
|
||||
"TOAST_ID_REGENERATED": "Identität neu generiert — Verbindung wird wiederhergestellt…",
|
||||
"LABEL_CONN_STATUS": "Verbindungsstatus",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Aktueller WebSocket-Verbindungsstatus",
|
||||
"CONN_STATUS_DISCONNECTED": "Getrennt",
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Share this link with friends so they can join",
|
||||
"LABEL_PEERS_IN_ROOM": "Peers in Room",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Other users currently connected to this room",
|
||||
"LABEL_HOST_CONTROL": "Host Control",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Restrict who can control playback in this room",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Only I can control playback",
|
||||
"NOTICE_HOST_CONTROLS": "The host controls playback for everyone.",
|
||||
"NOTICE_COHOST_HINT": "Tip: grant control to individual viewers in the participant list below.",
|
||||
"BADGE_HOST": "Host",
|
||||
"BADGE_GUEST": "Guest",
|
||||
"BADGE_CONTROLLER": "Controller",
|
||||
"BTN_GIVE_CONTROL": "Give control",
|
||||
"BTN_REVOKE_CONTROL": "Revoke",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Watching on their own — host's commands are ignored",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · Host controls this room",
|
||||
"HCM_DIALOG_BODY": "Only the host can control playback in this room. Keep watching together, or watch on your own?",
|
||||
"HCM_DIALOG_STAY": "Stay in sync",
|
||||
"HCM_DIALOG_SOLO": "Watch on my own",
|
||||
"HCM_BADGE_SOLO": "Watching on your own",
|
||||
"HCM_BADGE_RESYNC": "Resync",
|
||||
"NO_PEERS_CONNECTED": "No peers connected",
|
||||
"BTN_LEAVE_ROOM": "Leave Room",
|
||||
"LABEL_SELECT_VIDEO": "Select Video",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Regenerate your internal ID and reconnect",
|
||||
"REGEN_ID_DESC": "Use this if you see \"Duplicate Identity\" errors.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Other issue? Open a GitHub Issue",
|
||||
"TOAST_ID_REGENERATED": "Identity regenerated — reconnecting…",
|
||||
"LABEL_CONN_STATUS": "Connection Status",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Current WebSocket connection state",
|
||||
"CONN_STATUS_DISCONNECTED": "Disconnected",
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Comparte este enlace con amigos para que se unan",
|
||||
"LABEL_PEERS_IN_ROOM": "Participantes en la sala",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Otros usuarios conectados actualmente a esta sala",
|
||||
"LABEL_HOST_CONTROL": "Control del anfitrión",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Restringir quién puede controlar la reproducción en esta sala",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Solo yo puedo controlar la reproducción",
|
||||
"NOTICE_HOST_CONTROLS": "El anfitrión controla la reproducción para todos.",
|
||||
"NOTICE_COHOST_HINT": "Consejo: concede el control a usuarios concretos en la lista de participantes de abajo.",
|
||||
"BADGE_HOST": "Anfitrión",
|
||||
"BADGE_GUEST": "Invitado",
|
||||
"BADGE_CONTROLLER": "Controlador",
|
||||
"BTN_GIVE_CONTROL": "Dar control",
|
||||
"BTN_REVOKE_CONTROL": "Quitar",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Viendo por su cuenta — ignora los comandos del anfitrión",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · El anfitrión controla esta sala",
|
||||
"HCM_DIALOG_BODY": "Solo el anfitrión puede controlar la reproducción en esta sala. ¿Seguir viendo juntos o ver por tu cuenta?",
|
||||
"HCM_DIALOG_STAY": "Seguir sincronizado",
|
||||
"HCM_DIALOG_SOLO": "Ver por mi cuenta",
|
||||
"HCM_BADGE_SOLO": "Viendo por tu cuenta",
|
||||
"HCM_BADGE_RESYNC": "Resincronizar",
|
||||
"NO_PEERS_CONNECTED": "Sin participantes conectados",
|
||||
"BTN_LEAVE_ROOM": "Salir de la sala",
|
||||
"LABEL_SELECT_VIDEO": "Seleccionar video",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Regenerar tu ID interno y volver a conectarte",
|
||||
"REGEN_ID_DESC": "Usa esto si ves errores de 'Identidad duplicada'.",
|
||||
"REGEN_ID_OTHER_ISSUE": "¿Tienes otro problema? Abre un reporte en GitHub",
|
||||
"TOAST_ID_REGENERATED": "Identidad regenerada — reconectando…",
|
||||
"LABEL_CONN_STATUS": "Estado de la conexión",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Estado actual de la conexión WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Desconectado",
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Partagez ce lien avec vos amis pour qu'ils vous rejoignent",
|
||||
"LABEL_PEERS_IN_ROOM": "Membres dans le salon",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Autres utilisateurs connectés à ce salon",
|
||||
"LABEL_HOST_CONTROL": "Contrôle de l'hôte",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Définir qui peut contrôler la lecture dans ce salon",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Moi seul peux contrôler la lecture",
|
||||
"NOTICE_HOST_CONTROLS": "L'hôte contrôle la lecture pour tout le monde.",
|
||||
"NOTICE_COHOST_HINT": "Astuce : donnez le contrôle à des participants précis dans la liste ci-dessous.",
|
||||
"BADGE_HOST": "Hôte",
|
||||
"BADGE_GUEST": "Invité",
|
||||
"BADGE_CONTROLLER": "Contrôleur",
|
||||
"BTN_GIVE_CONTROL": "Donner le contrôle",
|
||||
"BTN_REVOKE_CONTROL": "Retirer",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Regarde de son côté — ignore les commandes de l'hôte",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · L'hôte contrôle ce salon",
|
||||
"HCM_DIALOG_BODY": "Seul l'hôte peut contrôler la lecture dans ce salon. Continuer à regarder ensemble, ou regarder de votre côté ?",
|
||||
"HCM_DIALOG_STAY": "Rester synchronisé",
|
||||
"HCM_DIALOG_SOLO": "Regarder de mon côté",
|
||||
"HCM_BADGE_SOLO": "Vous regardez seul",
|
||||
"HCM_BADGE_RESYNC": "Resynchroniser",
|
||||
"NO_PEERS_CONNECTED": "Aucun membre connecté",
|
||||
"BTN_LEAVE_ROOM": "Quitter le salon",
|
||||
"LABEL_SELECT_VIDEO": "Choisir une vidéo",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Régénérer votre identifiant interne et vous reconnecter",
|
||||
"REGEN_ID_DESC": "Utilisez cette option si vous rencontrez des erreurs de 'Double identité'.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Autre problème? Ouvrez un Issue GitHub",
|
||||
"TOAST_ID_REGENERATED": "Identité régénérée — reconnexion…",
|
||||
"LABEL_CONN_STATUS": "Statut de la connexion",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Statut actuel de la connexion WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Déconnecté",
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Condividi questo link per invitare i tuoi amici",
|
||||
"LABEL_PEERS_IN_ROOM": "Partecipanti",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Utenti connessi a questa stanza",
|
||||
"LABEL_HOST_CONTROL": "Controllo host",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Limita chi può controllare la riproduzione in questa stanza",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Solo io posso controllare la riproduzione",
|
||||
"NOTICE_HOST_CONTROLS": "L'host controlla la riproduzione per tutti.",
|
||||
"NOTICE_COHOST_HINT": "Suggerimento: concedi il controllo ai singoli partecipanti nell'elenco qui sotto.",
|
||||
"BADGE_HOST": "Host",
|
||||
"BADGE_GUEST": "Ospite",
|
||||
"BADGE_CONTROLLER": "Controller",
|
||||
"BTN_GIVE_CONTROL": "Dai il controllo",
|
||||
"BTN_REVOKE_CONTROL": "Revoca",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Guarda per conto proprio — ignora i comandi dell'host",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · L'host controlla questa stanza",
|
||||
"HCM_DIALOG_BODY": "Solo l'host può controllare la riproduzione in questa stanza. Continuare a guardare insieme o guardare per conto tuo?",
|
||||
"HCM_DIALOG_STAY": "Resta sincronizzato",
|
||||
"HCM_DIALOG_SOLO": "Guarda per conto mio",
|
||||
"HCM_BADGE_SOLO": "Stai guardando da solo",
|
||||
"HCM_BADGE_RESYNC": "Risincronizza",
|
||||
"NO_PEERS_CONNECTED": "Nessun partecipante connesso",
|
||||
"BTN_LEAVE_ROOM": "Lascia Stanza",
|
||||
"LABEL_SELECT_VIDEO": "Seleziona Video",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Genera un nuovo ID interno e riconnettiti",
|
||||
"REGEN_ID_DESC": "Usa questa opzione se riscontri errori di 'Identità Duplicata'.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Hai altri problemi? Apri una segnalazione su GitHub",
|
||||
"TOAST_ID_REGENERATED": "Identità rigenerata — riconnessione…",
|
||||
"LABEL_CONN_STATUS": "Stato Connessione",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Stato attuale della connessione",
|
||||
"CONN_STATUS_DISCONNECTED": "Disconnesso",
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "友達とこのリンクを共有して参加してもらいましょう",
|
||||
"LABEL_PEERS_IN_ROOM": "ルーム内のメンバー",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "現在このルームに接続している他のユーザー",
|
||||
"LABEL_HOST_CONTROL": "ホスト操作",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "このルームで再生を操作できる人を制限します",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "再生を操作できるのは自分だけ",
|
||||
"NOTICE_HOST_CONTROLS": "ホストが全員の再生を操作します。",
|
||||
"NOTICE_COHOST_HINT": "ヒント: 下の参加者リストで個別の参加者に操作権限を付与できます。",
|
||||
"BADGE_HOST": "ホスト",
|
||||
"BADGE_GUEST": "ゲスト",
|
||||
"BADGE_CONTROLLER": "操作権あり",
|
||||
"BTN_GIVE_CONTROL": "操作権を付与",
|
||||
"BTN_REVOKE_CONTROL": "取り消す",
|
||||
"BADGE_DESYNCED": "ソロ",
|
||||
"TOOLTIP_PEER_DESYNCED": "単独で視聴中 — ホストのコマンドを無視しています",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · ホストがこのルームを操作中",
|
||||
"HCM_DIALOG_BODY": "このルームではホストのみが再生を操作できます。一緒に視聴を続けますか、それとも自分だけで視聴しますか?",
|
||||
"HCM_DIALOG_STAY": "同期を維持",
|
||||
"HCM_DIALOG_SOLO": "自分だけで視聴",
|
||||
"HCM_BADGE_SOLO": "自分だけで視聴中",
|
||||
"HCM_BADGE_RESYNC": "再同期",
|
||||
"NO_PEERS_CONNECTED": "接続しているメンバーはいません",
|
||||
"BTN_LEAVE_ROOM": "ルームを退室",
|
||||
"LABEL_SELECT_VIDEO": "ビデオを選択",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "内部IDを再生成して再接続します",
|
||||
"REGEN_ID_DESC": "「Duplicate Identity」(ID重複)エラーが表示される場合に使用します。",
|
||||
"REGEN_ID_OTHER_ISSUE": "他の問題?GitHub Issueを開く",
|
||||
"TOAST_ID_REGENERATED": "IDを再生成しました — 再接続中…",
|
||||
"LABEL_CONN_STATUS": "接続状態",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "現在のWebSocket接続状態",
|
||||
"CONN_STATUS_DISCONNECTED": "切断されました",
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "친구들이 참여할 수 있도록 이 링크를 공유하세요",
|
||||
"LABEL_PEERS_IN_ROOM": "방의 참여자",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "현재 이 방에 연결되어 있는 다른 사용자들",
|
||||
"LABEL_HOST_CONTROL": "호스트 제어",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "이 방에서 재생을 제어할 수 있는 사람 제한",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "나만 재생을 제어할 수 있음",
|
||||
"NOTICE_HOST_CONTROLS": "호스트가 모두의 재생을 제어합니다.",
|
||||
"NOTICE_COHOST_HINT": "팁: 아래 참가자 목록에서 개별 참가자에게 제어 권한을 부여하세요.",
|
||||
"BADGE_HOST": "호스트",
|
||||
"BADGE_GUEST": "게스트",
|
||||
"BADGE_CONTROLLER": "제어자",
|
||||
"BTN_GIVE_CONTROL": "제어 권한 부여",
|
||||
"BTN_REVOKE_CONTROL": "취소",
|
||||
"BADGE_DESYNCED": "단독",
|
||||
"TOOLTIP_PEER_DESYNCED": "혼자 시청 중 — 호스트 명령을 무시합니다",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · 호스트가 이 방을 제어합니다",
|
||||
"HCM_DIALOG_BODY": "이 방에서는 호스트만 재생을 제어할 수 있습니다. 계속 함께 보시겠어요, 아니면 혼자 보시겠어요?",
|
||||
"HCM_DIALOG_STAY": "동기화 유지",
|
||||
"HCM_DIALOG_SOLO": "혼자 보기",
|
||||
"HCM_BADGE_SOLO": "혼자 시청 중",
|
||||
"HCM_BADGE_RESYNC": "다시 동기화",
|
||||
"NO_PEERS_CONNECTED": "연결된 참여자가 없습니다",
|
||||
"BTN_LEAVE_ROOM": "방 나가기",
|
||||
"LABEL_SELECT_VIDEO": "비디오 선택",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "내부 ID를 재생성하고 다시 연결합니다",
|
||||
"REGEN_ID_DESC": "\"중복된 ID\" 오류가 발생할 경우에 사용하세요.",
|
||||
"REGEN_ID_OTHER_ISSUE": "다른 문제? GitHub Issue 열기",
|
||||
"TOAST_ID_REGENERATED": "ID가 재생성되었습니다 — 다시 연결 중…",
|
||||
"LABEL_CONN_STATUS": "연결 상태",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "현재 WebSocket 연결 상태",
|
||||
"CONN_STATUS_DISCONNECTED": "연결 끊김",
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Deel deze link met vrienden zodat ze kunnen deelnemen",
|
||||
"LABEL_PEERS_IN_ROOM": "Deelnemers in kamer",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Andere gebruikers die momenteel zijn verbonden met deze kamer",
|
||||
"LABEL_HOST_CONTROL": "Hostbeheer",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Bepaal wie de weergave in deze kamer mag bedienen",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Alleen ik mag de weergave bedienen",
|
||||
"NOTICE_HOST_CONTROLS": "De host bedient de weergave voor iedereen.",
|
||||
"NOTICE_COHOST_HINT": "Tip: geef afzonderlijke deelnemers de besturing in de lijst hieronder.",
|
||||
"BADGE_HOST": "Host",
|
||||
"BADGE_GUEST": "Gast",
|
||||
"BADGE_CONTROLLER": "Bediener",
|
||||
"BTN_GIVE_CONTROL": "Bediening geven",
|
||||
"BTN_REVOKE_CONTROL": "Intrekken",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Zelfstandig aan het kijken — negeert de commando's van de host",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · De host bedient deze kamer",
|
||||
"HCM_DIALOG_BODY": "Alleen de host kan de weergave in deze kamer bedienen. Samen blijven kijken of zelf verder kijken?",
|
||||
"HCM_DIALOG_STAY": "Gesynchroniseerd blijven",
|
||||
"HCM_DIALOG_SOLO": "Zelf verder kijken",
|
||||
"HCM_BADGE_SOLO": "Je kijkt zelfstandig",
|
||||
"HCM_BADGE_RESYNC": "Opnieuw sync",
|
||||
"NO_PEERS_CONNECTED": "Geen deelnemers verbonden",
|
||||
"BTN_LEAVE_ROOM": "Kamer verlaten",
|
||||
"LABEL_SELECT_VIDEO": "Selecteer video",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Genereer uw interne ID opnieuw en maak opnieuw verbinding",
|
||||
"REGEN_ID_DESC": "Gebruik dit als u fouten ziet over 'Duplicate Identity'.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Ander probleem? Open een GitHub Issue",
|
||||
"TOAST_ID_REGENERATED": "Identiteit opnieuw gegenereerd — opnieuw verbinden…",
|
||||
"LABEL_CONN_STATUS": "Verbindingsstatus",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Huidige WebSocket-verbindingsstatus",
|
||||
"CONN_STATUS_DISCONNECTED": "Verbinding verbroken",
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Udostępnij ten link znajomym, aby mogli dołączyć",
|
||||
"LABEL_PEERS_IN_ROOM": "Uczestnicy w pokoju",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Inni użytkownicy aktualnie połączeni z tym pokojem",
|
||||
"LABEL_HOST_CONTROL": "Kontrola hosta",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Określ, kto może sterować odtwarzaniem w tym pokoju",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Tylko ja mogę sterować odtwarzaniem",
|
||||
"NOTICE_HOST_CONTROLS": "Host steruje odtwarzaniem dla wszystkich.",
|
||||
"NOTICE_COHOST_HINT": "Wskazówka: przyznaj sterowanie wybranym uczestnikom na liście poniżej.",
|
||||
"BADGE_HOST": "Host",
|
||||
"BADGE_GUEST": "Gość",
|
||||
"BADGE_CONTROLLER": "Sterujący",
|
||||
"BTN_GIVE_CONTROL": "Daj sterowanie",
|
||||
"BTN_REVOKE_CONTROL": "Odbierz",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Ogląda samodzielnie — ignoruje polecenia hosta",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · Host steruje tym pokojem",
|
||||
"HCM_DIALOG_BODY": "Tylko host może sterować odtwarzaniem w tym pokoju. Oglądać dalej razem czy oglądać samodzielnie?",
|
||||
"HCM_DIALOG_STAY": "Pozostań zsynchronizowany",
|
||||
"HCM_DIALOG_SOLO": "Oglądaj samodzielnie",
|
||||
"HCM_BADGE_SOLO": "Oglądasz samodzielnie",
|
||||
"HCM_BADGE_RESYNC": "Zsynchronizuj",
|
||||
"NO_PEERS_CONNECTED": "Brak połączonych uczestników",
|
||||
"BTN_LEAVE_ROOM": "Opuść pokój",
|
||||
"LABEL_SELECT_VIDEO": "Wybierz wideo",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Wygeneruj ponownie swój identyfikator i połącz się ponownie",
|
||||
"REGEN_ID_DESC": "Użyj tego, jeśli widzisz błędy o zduplikowanej tożsamości.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Inny problem? Otwórz Issue na GitHub",
|
||||
"TOAST_ID_REGENERATED": "Tożsamość wygenerowana ponownie — ponowne łączenie…",
|
||||
"LABEL_CONN_STATUS": "Status połączenia",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Bieżący stan połączenia WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Rozłączono",
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Compartilhe este link com seus amigos para que eles entrem",
|
||||
"LABEL_PEERS_IN_ROOM": "Participantes",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Outros usuários conectados a esta sala",
|
||||
"LABEL_HOST_CONTROL": "Controle do anfitrião",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Restringir quem pode controlar a reprodução nesta sala",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Somente eu posso controlar a reprodução",
|
||||
"NOTICE_HOST_CONTROLS": "O anfitrião controla a reprodução para todos.",
|
||||
"NOTICE_COHOST_HINT": "Dica: conceda o controle a participantes específicos na lista abaixo.",
|
||||
"BADGE_HOST": "Anfitrião",
|
||||
"BADGE_GUEST": "Convidado",
|
||||
"BADGE_CONTROLLER": "Controlador",
|
||||
"BTN_GIVE_CONTROL": "Dar controle",
|
||||
"BTN_REVOKE_CONTROL": "Revogar",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Assistindo por conta própria — ignora os comandos do anfitrião",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · O anfitrião controla esta sala",
|
||||
"HCM_DIALOG_BODY": "Somente o anfitrião pode controlar a reprodução nesta sala. Continuar assistindo juntos ou assistir por conta própria?",
|
||||
"HCM_DIALOG_STAY": "Manter sincronizado",
|
||||
"HCM_DIALOG_SOLO": "Assistir por conta própria",
|
||||
"HCM_BADGE_SOLO": "Assistindo por conta própria",
|
||||
"HCM_BADGE_RESYNC": "Ressincronizar",
|
||||
"NO_PEERS_CONNECTED": "Sem participantes conectados",
|
||||
"BTN_LEAVE_ROOM": "Sair da sala",
|
||||
"LABEL_SELECT_VIDEO": "Selecionar vídeo",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Gerar um novo ID interno e conectar novamente",
|
||||
"REGEN_ID_DESC": "Use esta opção se aparecer o erro de 'Identidade duplicada'.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Outro problema? Abra um relatório no GitHub",
|
||||
"TOAST_ID_REGENERATED": "Identidade regenerada — reconectando…",
|
||||
"LABEL_CONN_STATUS": "Status da conexão",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Status atual da conexão WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Desconectado",
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Partilhe este link com os amigos para que possam entrar",
|
||||
"LABEL_PEERS_IN_ROOM": "Participantes na Sala",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Utilizadores atualmente ligados a esta sala",
|
||||
"LABEL_HOST_CONTROL": "Controlo do anfitrião",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Restringir quem pode controlar a reprodução nesta sala",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Só eu posso controlar a reprodução",
|
||||
"NOTICE_HOST_CONTROLS": "O anfitrião controla a reprodução para todos.",
|
||||
"NOTICE_COHOST_HINT": "Dica: conceda o controlo a participantes específicos na lista abaixo.",
|
||||
"BADGE_HOST": "Anfitrião",
|
||||
"BADGE_GUEST": "Convidado",
|
||||
"BADGE_CONTROLLER": "Controlador",
|
||||
"BTN_GIVE_CONTROL": "Dar controlo",
|
||||
"BTN_REVOKE_CONTROL": "Revogar",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "A ver por conta própria — ignora os comandos do anfitrião",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · O anfitrião controla esta sala",
|
||||
"HCM_DIALOG_BODY": "Só o anfitrião pode controlar a reprodução nesta sala. Continuar a ver em conjunto ou ver por conta própria?",
|
||||
"HCM_DIALOG_STAY": "Manter sincronizado",
|
||||
"HCM_DIALOG_SOLO": "Ver por minha conta",
|
||||
"HCM_BADGE_SOLO": "A ver por tua conta",
|
||||
"HCM_BADGE_RESYNC": "Ressincronizar",
|
||||
"NO_PEERS_CONNECTED": "Nenhum participante ligado",
|
||||
"BTN_LEAVE_ROOM": "Sair da Sala",
|
||||
"LABEL_SELECT_VIDEO": "Selecionar Vídeo",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Regerar o seu ID interno e voltar a ligar",
|
||||
"REGEN_ID_DESC": "Use isto se encontrar erros de 'Identidade Duplicada'.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Outro problema? Abra um relatório no GitHub",
|
||||
"TOAST_ID_REGENERATED": "Identidade regenerada — a voltar a ligar…",
|
||||
"LABEL_CONN_STATUS": "Estado da Ligação",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Estado atual da ligação",
|
||||
"CONN_STATUS_DISCONNECTED": "Desligado",
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Поделитесь этой ссылкой с друзьями, чтобы они вошли",
|
||||
"LABEL_PEERS_IN_ROOM": "Участники в комнате",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Другие пользователи, подключенные к этой комнате",
|
||||
"LABEL_HOST_CONTROL": "Управление ведущим",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Ограничить, кто может управлять воспроизведением в этой комнате",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Только я могу управлять воспроизведением",
|
||||
"NOTICE_HOST_CONTROLS": "Ведущий управляет воспроизведением для всех.",
|
||||
"NOTICE_COHOST_HINT": "Совет: предоставьте управление отдельным участникам в списке ниже.",
|
||||
"BADGE_HOST": "Ведущий",
|
||||
"BADGE_GUEST": "Гость",
|
||||
"BADGE_CONTROLLER": "Управляющий",
|
||||
"BTN_GIVE_CONTROL": "Дать управление",
|
||||
"BTN_REVOKE_CONTROL": "Отозвать",
|
||||
"BADGE_DESYNCED": "Соло",
|
||||
"TOOLTIP_PEER_DESYNCED": "Смотрит отдельно — игнорирует команды ведущего",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · Комнатой управляет ведущий",
|
||||
"HCM_DIALOG_BODY": "Управлять воспроизведением в этой комнате может только ведущий. Продолжить смотреть вместе или смотреть самостоятельно?",
|
||||
"HCM_DIALOG_STAY": "Оставаться синхронно",
|
||||
"HCM_DIALOG_SOLO": "Смотреть самостоятельно",
|
||||
"HCM_BADGE_SOLO": "Вы смотрите самостоятельно",
|
||||
"HCM_BADGE_RESYNC": "Синхронизировать",
|
||||
"NO_PEERS_CONNECTED": "Нет подключенных участников",
|
||||
"BTN_LEAVE_ROOM": "Выйти из комнаты",
|
||||
"LABEL_SELECT_VIDEO": "Выбрать видео",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Сбросить ваш внутренний идентификатор и переподключиться",
|
||||
"REGEN_ID_DESC": "Используйте при появлении ошибок дублирования идентификации.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Другая проблема? Откройте Issue на GitHub",
|
||||
"TOAST_ID_REGENERATED": "Идентичность перегенерирована — переподключение…",
|
||||
"LABEL_CONN_STATUS": "Статус подключения",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Текущий статус WebSocket-соединения с сервером",
|
||||
"CONN_STATUS_DISCONNECTED": "Отключено",
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Katılabilmeleri için bu bağlantıyı arkadaşlarınızla paylaşın",
|
||||
"LABEL_PEERS_IN_ROOM": "Odadaki Kişiler",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Şu anda bu odaya bağlı olan diğer kullanıcılar",
|
||||
"LABEL_HOST_CONTROL": "Sunucu Kontrolü",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Bu odada oynatmayı kimin kontrol edebileceğini belirleyin",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Oynatmayı yalnızca ben kontrol edebilirim",
|
||||
"NOTICE_HOST_CONTROLS": "Oynatmayı herkes için sunucu kontrol eder.",
|
||||
"NOTICE_COHOST_HINT": "İpucu: aşağıdaki katılımcı listesinde tek tek katılımcılara kontrol verin.",
|
||||
"BADGE_HOST": "Sunucu",
|
||||
"BADGE_GUEST": "Misafir",
|
||||
"BADGE_CONTROLLER": "Kontrolcü",
|
||||
"BTN_GIVE_CONTROL": "Kontrol ver",
|
||||
"BTN_REVOKE_CONTROL": "Geri al",
|
||||
"BADGE_DESYNCED": "Solo",
|
||||
"TOOLTIP_PEER_DESYNCED": "Tek başına izliyor — sunucu komutlarını yok sayar",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · Bu odayı sunucu kontrol ediyor",
|
||||
"HCM_DIALOG_BODY": "Bu odada oynatmayı yalnızca sunucu kontrol edebilir. Birlikte izlemeye devam mı, yoksa kendi başına mı izlemek istiyorsun?",
|
||||
"HCM_DIALOG_STAY": "Senkron kal",
|
||||
"HCM_DIALOG_SOLO": "Kendi başıma izle",
|
||||
"HCM_BADGE_SOLO": "Kendi başına izliyorsun",
|
||||
"HCM_BADGE_RESYNC": "Yeniden senkronize et",
|
||||
"NO_PEERS_CONNECTED": "Bağlı kimse yok",
|
||||
"BTN_LEAVE_ROOM": "Odadan Ayrıl",
|
||||
"LABEL_SELECT_VIDEO": "Video Seç",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Dahili kimliğinizi yeniden oluşturun ve tekrar bağlanın",
|
||||
"REGEN_ID_DESC": "\"Duplicate Identity\" (Mükerrer Kimlik) hataları görüyorsanız bunu kullanın.",
|
||||
"REGEN_ID_OTHER_ISSUE": "Başka bir sorun? GitHub Issue açın",
|
||||
"TOAST_ID_REGENERATED": "Kimlik yeniden oluşturuldu — yeniden bağlanılıyor…",
|
||||
"LABEL_CONN_STATUS": "Bağlantı Durumu",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Mevcut WebSocket bağlantı durumu",
|
||||
"CONN_STATUS_DISCONNECTED": "Bağlantı Kesildi",
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Поділіться цим посиланням з друзями, щоб вони могли приєднатися",
|
||||
"LABEL_PEERS_IN_ROOM": "Учасники в кімнаті",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Інші користувачі зараз підключені до цієї кімнати",
|
||||
"LABEL_HOST_CONTROL": "Керування ведучим",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "Обмежити, хто може керувати відтворенням у цій кімнаті",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "Лише я можу керувати відтворенням",
|
||||
"NOTICE_HOST_CONTROLS": "Ведучий керує відтворенням для всіх.",
|
||||
"NOTICE_COHOST_HINT": "Порада: надайте керування окремим учасникам у списку нижче.",
|
||||
"BADGE_HOST": "Ведучий",
|
||||
"BADGE_GUEST": "Гість",
|
||||
"BADGE_CONTROLLER": "Керуючий",
|
||||
"BTN_GIVE_CONTROL": "Дати керування",
|
||||
"BTN_REVOKE_CONTROL": "Відкликати",
|
||||
"BADGE_DESYNCED": "Соло",
|
||||
"TOOLTIP_PEER_DESYNCED": "Дивиться окремо — ігнорує команди ведучого",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · Кімнатою керує ведучий",
|
||||
"HCM_DIALOG_BODY": "Керувати відтворенням у цій кімнаті може лише ведучий. Продовжити дивитися разом чи дивитися самостійно?",
|
||||
"HCM_DIALOG_STAY": "Залишатися синхронно",
|
||||
"HCM_DIALOG_SOLO": "Дивитися самостійно",
|
||||
"HCM_BADGE_SOLO": "Ви дивитеся самостійно",
|
||||
"HCM_BADGE_RESYNC": "Синхронізувати",
|
||||
"NO_PEERS_CONNECTED": "Немає підключених однорангових пристроїв",
|
||||
"BTN_LEAVE_ROOM": "Вийти з кімнати",
|
||||
"LABEL_SELECT_VIDEO": "Виберіть Відео",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "Повторно згенеруйте свій внутрішній ідентифікатор і повторно підключіться",
|
||||
"REGEN_ID_DESC": "Використовуйте це, якщо ви бачите помилку \"Дублікати ідентифікатора\".",
|
||||
"REGEN_ID_OTHER_ISSUE": "Інша проблема? Відкрийте випуск GitHub",
|
||||
"TOAST_ID_REGENERATED": "Ідентичність перегенеровано — повторне підключення…",
|
||||
"LABEL_CONN_STATUS": "Статус підключення",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Поточний стан підключення WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Відключено",
|
||||
|
||||
@@ -44,6 +44,24 @@
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "与朋友分享此链接,以便他们可以加入",
|
||||
"LABEL_PEERS_IN_ROOM": "房间里的同伴",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "当前连接到该房间的其他用户",
|
||||
"LABEL_HOST_CONTROL": "主持人控制",
|
||||
"LABEL_HOST_CONTROL_TOOLTIP": "限制谁可以在此房间中控制播放",
|
||||
"LABEL_HOST_ONLY_TOGGLE": "仅我可以控制播放",
|
||||
"NOTICE_HOST_CONTROLS": "由主持人为所有人控制播放。",
|
||||
"NOTICE_COHOST_HINT": "提示:在下方的参与者列表中将控制权授予单个参与者。",
|
||||
"BADGE_HOST": "主持人",
|
||||
"BADGE_GUEST": "访客",
|
||||
"BADGE_CONTROLLER": "控制者",
|
||||
"BTN_GIVE_CONTROL": "授予控制权",
|
||||
"BTN_REVOKE_CONTROL": "撤销",
|
||||
"BADGE_DESYNCED": "单独",
|
||||
"TOOLTIP_PEER_DESYNCED": "单独观看中 — 忽略主持人的命令",
|
||||
"HCM_DIALOG_TITLE": "KoalaSync · 主持人控制本房间",
|
||||
"HCM_DIALOG_BODY": "本房间只有主持人可以控制播放。继续一起观看,还是自己单独观看?",
|
||||
"HCM_DIALOG_STAY": "保持同步",
|
||||
"HCM_DIALOG_SOLO": "自己单独观看",
|
||||
"HCM_BADGE_SOLO": "正在单独观看",
|
||||
"HCM_BADGE_RESYNC": "重新同步",
|
||||
"NO_PEERS_CONNECTED": "没有对等点连接",
|
||||
"BTN_LEAVE_ROOM": "离开房间",
|
||||
"LABEL_SELECT_VIDEO": "选择视频",
|
||||
@@ -92,6 +110,7 @@
|
||||
"BTN_REGEN_ID_TOOLTIP": "重新生成您的内部 ID 并重新连接",
|
||||
"REGEN_ID_DESC": "如果您看到“重复身份”错误,请使用此选项。",
|
||||
"REGEN_ID_OTHER_ISSUE": "其他问题?打开 GitHub 问题",
|
||||
"TOAST_ID_REGENERATED": "身份已重新生成 — 正在重新连接…",
|
||||
"LABEL_CONN_STATUS": "连接状态",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "当前WebSocket连接状态",
|
||||
"CONN_STATUS_DISCONNECTED": "已断开连接",
|
||||
|
||||
+26
-6
@@ -402,14 +402,18 @@
|
||||
<details style="margin-bottom: 12px; background: var(--card); border: 1px solid #334155; border-radius: 8px; padding: 8px;">
|
||||
<summary style="font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; cursor: pointer; outline: none;" data-i18n="MANUAL_CONNECT_HEADER">Manual Connect / Advanced</summary>
|
||||
<div style="margin-top: 12px;">
|
||||
<div class="form-group">
|
||||
<label title="Select which KoalaSync server to use" data-i18n="LABEL_SERVER">Server</label>
|
||||
<div style="display:flex; gap:4px; margin-bottom:8px;">
|
||||
<button id="serverOfficial" class="tab-btn active" style="flex:1; padding:6px; font-size:11px;" data-i18n="BTN_SERVER_OFFICIAL" data-i18n-title="BTN_SERVER_OFFICIAL_TOOLTIP" title="Use the official reliable server">Official</button>
|
||||
<button id="serverCustom" class="tab-btn" style="flex:1; padding:6px; font-size:11px;" data-i18n="BTN_SERVER_CUSTOM" data-i18n-title="BTN_SERVER_CUSTOM_TOOLTIP" title="Connect to your own self-hosted server">Custom</button>
|
||||
<!-- Server selection — its own concern, kept visually separate from the room fields. -->
|
||||
<div style="background: var(--bg); border: 1px solid #334155; border-radius: 8px; padding: 10px; margin-bottom: 14px;">
|
||||
<div class="form-group" style="margin-bottom:0;">
|
||||
<label title="Select which KoalaSync server to use" data-i18n="LABEL_SERVER">Server</label>
|
||||
<div style="display:flex; gap:4px; margin-bottom:0;">
|
||||
<button id="serverOfficial" class="tab-btn active" style="flex:1; padding:6px; font-size:11px;" data-i18n="BTN_SERVER_OFFICIAL" data-i18n-title="BTN_SERVER_OFFICIAL_TOOLTIP" title="Use the official reliable server">Official</button>
|
||||
<button id="serverCustom" class="tab-btn" style="flex:1; padding:6px; font-size:11px;" data-i18n="BTN_SERVER_CUSTOM" data-i18n-title="BTN_SERVER_CUSTOM_TOOLTIP" title="Connect to your own self-hosted server">Custom</button>
|
||||
</div>
|
||||
<input type="text" id="serverUrl" data-i18n-placeholder="PLACEHOLDER_SERVER_URL" placeholder="wss://your-server:3000" style="display:none; margin-top:8px;">
|
||||
</div>
|
||||
<input type="text" id="serverUrl" data-i18n-placeholder="PLACEHOLDER_SERVER_URL" placeholder="wss://your-server:3000" style="display:none;">
|
||||
</div>
|
||||
<!-- Room credentials — distinct group below the server picker. -->
|
||||
<div class="form-group">
|
||||
<label title="The unique identifier for your sync room" data-i18n="LABEL_ROOM_ID" data-i18n-title="LABEL_ROOM_ID_TOOLTIP">Room ID</label>
|
||||
<input type="text" id="roomId" data-i18n-placeholder="PLACEHOLDER_ROOM_ID" data-i18n-title="PLACEHOLDER_ROOM_ID_TOOLTIP" placeholder="Enter Room ID" title="The unique ID of the room you want to join">
|
||||
@@ -450,6 +454,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hostControlCard" class="info-card" style="margin-bottom: 20px; display:none;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label style="margin:0;" title="Restrict who can control playback in this room" data-i18n="LABEL_HOST_CONTROL" data-i18n-title="LABEL_HOST_CONTROL_TOOLTIP">Host Control</label>
|
||||
<span id="hostRoleBadge" style="font-size:10px; font-weight:700; text-transform:uppercase; padding:2px 8px; border-radius:8px; background:var(--accent); color:#fff;"></span>
|
||||
</div>
|
||||
<div id="hostControlToggleRow" style="display:none; align-items:center; justify-content:space-between; gap:12px; margin-top:12px;">
|
||||
<span style="font-size:12px; color:var(--text); line-height:1.3;" data-i18n="LABEL_HOST_ONLY_TOGGLE">Only I can control playback</span>
|
||||
<label class="toggle-switch" style="flex:none;">
|
||||
<input type="checkbox" id="hostControlToggle">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="hostControlCohostHint" style="display:none; margin-top:10px; padding-top:10px; border-top:1px solid #334155; font-size:11px; color:var(--text-muted); line-height:1.4;" data-i18n="NOTICE_COHOST_HINT">Tip: grant control to individual viewers in the participant list below.</div>
|
||||
<div id="hostControlGuestNote" style="display:none; margin-top:10px; font-size:12px; color:var(--text-muted);" data-i18n="NOTICE_HOST_CONTROLS">The host controls playback for everyone.</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label title="Other users currently connected to this room" data-i18n="LABEL_PEERS_IN_ROOM" data-i18n-title="LABEL_PEERS_IN_ROOM_TOOLTIP">Peers in Room</label>
|
||||
<div id="peerList" class="info-card">
|
||||
|
||||
+186
-6
@@ -41,6 +41,12 @@ const elements = {
|
||||
retryBtn: document.getElementById('retryBtn'),
|
||||
sectionJoin: document.getElementById('section-join'),
|
||||
sectionActive: document.getElementById('section-active'),
|
||||
hostControlCard: document.getElementById('hostControlCard'),
|
||||
hostRoleBadge: document.getElementById('hostRoleBadge'),
|
||||
hostControlToggleRow: document.getElementById('hostControlToggleRow'),
|
||||
hostControlToggle: document.getElementById('hostControlToggle'),
|
||||
hostControlGuestNote: document.getElementById('hostControlGuestNote'),
|
||||
hostControlCohostHint: document.getElementById('hostControlCohostHint'),
|
||||
activeRoomId: document.getElementById('activeRoomId'),
|
||||
activeServer: document.getElementById('activeServer'),
|
||||
peerListSync: document.getElementById('peerListSync'),
|
||||
@@ -244,6 +250,7 @@ async function init() {
|
||||
updatePingDisplay(res.ping);
|
||||
updatePeerList(res.peers);
|
||||
lastKnownPeers = res.peers || [];
|
||||
updateHostControlUI({ controlMode: res.controlMode, amHost: res.amHost, amController: res.amController, controllers: res.controllers, hostPeerId: res.hostPeerId, hostControlSupported: res.hostControlSupported, coHostSupported: res.coHostSupported, inRoom: res.status === 'connected' });
|
||||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||
|
||||
// If user has a room configured but background is not connected (disconnected or idle),
|
||||
@@ -303,6 +310,98 @@ function toggleUIState(inRoom) {
|
||||
if (syncInactive) syncInactive.style.display = inRoom ? 'none' : 'block';
|
||||
}
|
||||
|
||||
// --- Host Control Mode UI ---
|
||||
// True when we're a guest in a host-only room → remote-control buttons are locked.
|
||||
let hcmGuestLocked = false;
|
||||
|
||||
// Co-Host state mirrored for the peer-list renderer (promote/demote + role badges).
|
||||
let hcmAmOwner = false;
|
||||
let hcmControllers = [];
|
||||
let hcmCoHostSupported = false;
|
||||
let hcmOwnerPeerId = null;
|
||||
let hcmRoomIsHostOnly = false;
|
||||
|
||||
function updateHostControlUI(state) {
|
||||
const s = state || {};
|
||||
const controlMode = s.controlMode;
|
||||
const amHost = !!s.amHost;
|
||||
const amController = !!s.amController;
|
||||
const hostOnly = controlMode === 'host-only';
|
||||
|
||||
// Stash for the peer-list renderer, then force it to re-render with new roles.
|
||||
hcmAmOwner = amHost;
|
||||
hcmControllers = Array.isArray(s.controllers) ? s.controllers : [];
|
||||
hcmCoHostSupported = !!s.coHostSupported;
|
||||
hcmOwnerPeerId = s.hostPeerId || null;
|
||||
hcmRoomIsHostOnly = hostOnly;
|
||||
lastPeersJson = '';
|
||||
if (activePeers) updatePeerList(activePeers);
|
||||
|
||||
const card = elements.hostControlCard;
|
||||
if (!card) return;
|
||||
// Explicit capability advertised by the relay. Against an older relay it's
|
||||
// false/absent → feature unavailable, so hide the card instead of misleading UI.
|
||||
const serverSupportsHostControl = !!s.hostControlSupported;
|
||||
// Show the card only when meaningful: owner always (to manage); a non-controller
|
||||
// guest only while host-only is active. A guest in a normal room sees nothing.
|
||||
const show = s.inRoom && serverSupportsHostControl && (amHost || hostOnly);
|
||||
if (!show) {
|
||||
card.style.display = 'none';
|
||||
hcmGuestLocked = false;
|
||||
setRemoteControlsLocked(false);
|
||||
return;
|
||||
}
|
||||
card.style.display = 'block';
|
||||
if (elements.hostRoleBadge) {
|
||||
const role = amHost ? (getMessage('BADGE_HOST') || 'Host')
|
||||
: amController ? (getMessage('BADGE_CONTROLLER') || 'Controller')
|
||||
: (getMessage('BADGE_GUEST') || 'Guest');
|
||||
elements.hostRoleBadge.textContent = role;
|
||||
elements.hostRoleBadge.style.background = (amHost || amController) ? 'var(--accent)' : 'var(--text-muted)';
|
||||
}
|
||||
if (elements.hostControlToggleRow) elements.hostControlToggleRow.style.display = amHost ? 'flex' : 'none';
|
||||
if (elements.hostControlToggle) elements.hostControlToggle.checked = hostOnly;
|
||||
if (elements.hostControlGuestNote) elements.hostControlGuestNote.style.display = (!amController && hostOnly) ? 'block' : 'none';
|
||||
// Owner-only discoverability hint: co-hosting is granted down in the peer list,
|
||||
// which isn't obvious from this card. Only show it when it's actionable — owner,
|
||||
// relay supports co-host, and the room is locked to host-only (the buttons only
|
||||
// render in that mode).
|
||||
if (elements.hostControlCohostHint) elements.hostControlCohostHint.style.display = (amHost && !!s.coHostSupported && hostOnly) ? 'block' : 'none';
|
||||
|
||||
// Only a non-controller is locked out of the remote controls.
|
||||
hcmGuestLocked = (!amController && hostOnly);
|
||||
setRemoteControlsLocked(hcmGuestLocked);
|
||||
}
|
||||
|
||||
function setRemoteControlsLocked(locked) {
|
||||
[elements.playBtn, elements.pauseBtn, elements.forceSyncBtn].forEach(btn => {
|
||||
if (!btn) return;
|
||||
btn.disabled = locked;
|
||||
btn.style.opacity = locked ? '0.5' : '';
|
||||
btn.style.cursor = locked ? 'not-allowed' : '';
|
||||
btn.title = locked ? (getMessage('NOTICE_HOST_CONTROLS') || 'The host controls playback for everyone.') : '';
|
||||
});
|
||||
// Always restore the default labels. The action handlers leave the text in a
|
||||
// transitional state ("Playing..." / "Pausing...") and the 2.5s safety reset
|
||||
// skips the refresh while guest-locked — so reset on BOTH transitions: on lock
|
||||
// (host enabled host-only just as the guest clicked → don't freeze "Playing...")
|
||||
// and on unlock (L-1).
|
||||
if (elements.playBtn) elements.playBtn.textContent = getMessage('BTN_PLAY') || 'Play';
|
||||
if (elements.pauseBtn) elements.pauseBtn.textContent = getMessage('BTN_PAUSE') || 'Pause';
|
||||
}
|
||||
|
||||
if (elements.hostControlToggle) {
|
||||
elements.hostControlToggle.addEventListener('change', () => {
|
||||
const mode = elements.hostControlToggle.checked ? 'host-only' : 'everyone';
|
||||
chrome.runtime.sendMessage({ type: 'SET_CONTROL_MODE', controlMode: mode }, (res) => {
|
||||
// Server broadcasts CONTROL_MODE back to refresh the UI; revert on failure.
|
||||
if (chrome.runtime.lastError || !res || res.status !== 'ok') {
|
||||
elements.hostControlToggle.checked = (mode === 'everyone');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateUI(roomId, password, useCustomServer = false, serverUrl = '') {
|
||||
const inRoom = !!roomId;
|
||||
toggleUIState(inRoom);
|
||||
@@ -577,6 +676,11 @@ function updatePeerList(peers) {
|
||||
|
||||
header.appendChild(nameSpan);
|
||||
|
||||
// Right-side badges + actions, kept in one group so they sit together
|
||||
// on the right instead of being scattered by the header's space-between.
|
||||
const rightGroup = document.createElement('div');
|
||||
rightGroup.style.cssText = 'display:flex; align-items:center; gap:6px; flex-shrink:0;';
|
||||
|
||||
// Volume Icon (Top Right)
|
||||
if (p.volume !== undefined && p.volume !== null) {
|
||||
const volIcon = document.createElement('div');
|
||||
@@ -590,9 +694,57 @@ function updatePeerList(peers) {
|
||||
const you = document.createElement('span');
|
||||
you.style.cssText = 'font-size:10px; color:var(--accent); font-weight:bold;';
|
||||
you.textContent = getMessage('LABEL_YOU') || 'YOU';
|
||||
header.appendChild(you);
|
||||
rightGroup.appendChild(you);
|
||||
}
|
||||
|
||||
// Host Control Mode: show "Solo" badge for peers watching on their own.
|
||||
if (typeof p === 'object' && p.desynced) {
|
||||
const solo = document.createElement('span');
|
||||
solo.style.cssText = 'font-size:10px; color:#fff; background:#b45309; padding:2px 6px; border-radius:6px; font-weight:600;';
|
||||
const soloText = getMessage('BADGE_DESYNCED') || 'Solo';
|
||||
solo.textContent = soloText;
|
||||
solo.title = getMessage('TOOLTIP_PEER_DESYNCED') || soloText;
|
||||
rightGroup.appendChild(solo);
|
||||
}
|
||||
|
||||
// Co-Host: role badges + the owner's promote/demote control. Only meaningful
|
||||
// once the room is locked to host-only — in 'everyone' mode anyone can already
|
||||
// control, so roles are moot and we show nothing (no badge/button noise).
|
||||
// The promote/demote button additionally requires owner privileges (below).
|
||||
const showRoles = !!hcmOwnerPeerId && hcmRoomIsHostOnly;
|
||||
if (showRoles) {
|
||||
const isOwner = pId === hcmOwnerPeerId;
|
||||
const isController = !isOwner && hcmControllers.includes(pId);
|
||||
if (isOwner || isController) {
|
||||
const roleBadge = document.createElement('span');
|
||||
roleBadge.style.cssText = 'font-size:10px; color:#fff; background:var(--accent); padding:2px 6px; border-radius:6px; font-weight:600;';
|
||||
roleBadge.textContent = isOwner ? (getMessage('BADGE_HOST') || 'Host') : (getMessage('BADGE_CONTROLLER') || 'Controller');
|
||||
rightGroup.appendChild(roleBadge);
|
||||
}
|
||||
// Owner can promote/demote any non-owner peer.
|
||||
if (hcmAmOwner && hcmCoHostSupported && !isOwner) {
|
||||
const btn = document.createElement('button');
|
||||
const revoke = isController;
|
||||
btn.style.cssText = `font-size:10px; padding:3px 9px; border-radius:6px; cursor:pointer; white-space:nowrap; font-weight:600; transition:background .15s,color .15s; border:1px solid ${revoke ? 'var(--text-muted)' : 'var(--accent)'}; background:transparent; color:${revoke ? 'var(--text-muted)' : 'var(--accent)'};`;
|
||||
btn.textContent = revoke ? (getMessage('BTN_REVOKE_CONTROL') || 'Revoke') : (getMessage('BTN_GIVE_CONTROL') || 'Give control');
|
||||
btn.title = revoke ? (getMessage('BTN_REVOKE_CONTROL') || 'Revoke') : (getMessage('BTN_GIVE_CONTROL') || 'Give control');
|
||||
btn.addEventListener('mouseenter', () => {
|
||||
btn.style.background = revoke ? 'var(--text-muted)' : 'var(--accent)';
|
||||
btn.style.color = '#fff';
|
||||
});
|
||||
btn.addEventListener('mouseleave', () => {
|
||||
btn.style.background = 'transparent';
|
||||
btn.style.color = revoke ? 'var(--text-muted)' : 'var(--accent)';
|
||||
});
|
||||
btn.addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ type: 'SET_PEER_ROLE', peerId: pId, controller: !isController }, () => {});
|
||||
});
|
||||
rightGroup.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
if (rightGroup.childNodes.length) header.appendChild(rightGroup);
|
||||
|
||||
peerItem.appendChild(header);
|
||||
|
||||
// Media Info
|
||||
@@ -1145,7 +1297,11 @@ if (elements.langSelector) {
|
||||
elements.serverUrl.addEventListener('change', () => {
|
||||
let url = elements.serverUrl.value.trim();
|
||||
if (url && !url.includes('://')) {
|
||||
url = 'ws://' + url;
|
||||
// Default to secure wss:// — plain ws:// is only valid for a local relay
|
||||
// (and the background rejects/upgrades ws:// for any remote host anyway).
|
||||
const host = url.split('/')[0].split(':')[0].toLowerCase();
|
||||
const isLocal = host === 'localhost' || host === '127.0.0.1';
|
||||
url = (isLocal ? 'ws://' : 'wss://') + url;
|
||||
elements.serverUrl.value = url;
|
||||
chrome.storage.local.set({ serverUrl: url });
|
||||
}
|
||||
@@ -1262,7 +1418,7 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
}
|
||||
if (useCustom && serverUrl) {
|
||||
try {
|
||||
const urlToCheck = serverUrl.includes('://') ? serverUrl : 'ws://' + serverUrl;
|
||||
const urlToCheck = serverUrl.includes('://') ? serverUrl : 'wss://' + serverUrl;
|
||||
new URL(urlToCheck);
|
||||
} catch (_e) {
|
||||
showError(getMessage('ERR_INVALID_SERVER_URL'));
|
||||
@@ -1366,6 +1522,7 @@ elements.targetTab.addEventListener('change', () => {
|
||||
});
|
||||
|
||||
elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
if (hcmGuestLocked) return; // guest in host-only room — backstop (M-2/L-3)
|
||||
if (elements.forceSyncBtn.disabled) return;
|
||||
|
||||
const originalText = elements.forceSyncBtn.textContent;
|
||||
@@ -1407,7 +1564,10 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
const peerCount = (status.peers || []).filter(p => (typeof p === 'object' ? p.peerId : p) !== localPeerId).length;
|
||||
const syncTimeoutMs = peerCount === 0 ? 3000 : 12000;
|
||||
const forceSyncReset = () => {
|
||||
if (!forceSyncDone) {
|
||||
// Don't unlock a button that's locked because we became a guest mid-flight —
|
||||
// hcmGuestLocked is the source of truth for the lock state, and the next
|
||||
// CONTROL_MODE update restores the correct label.
|
||||
if (!forceSyncDone && !hcmGuestLocked) {
|
||||
elements.forceSyncBtn.disabled = false;
|
||||
elements.forceSyncBtn.textContent = originalText;
|
||||
}
|
||||
@@ -1454,6 +1614,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
});
|
||||
|
||||
elements.playBtn.addEventListener('click', () => {
|
||||
if (hcmGuestLocked) return; // guest in host-only room — backstop
|
||||
if (!elements.targetTab.value) {
|
||||
showToast(getMessage('ERR_SELECT_VIDEO'), 'warning');
|
||||
return;
|
||||
@@ -1472,7 +1633,7 @@ elements.playBtn.addEventListener('click', () => {
|
||||
});
|
||||
// Safety reset: restore button after 2.5s in case no peers respond
|
||||
setTimeout(() => {
|
||||
if (elements.playBtn.disabled) {
|
||||
if (elements.playBtn.disabled && !hcmGuestLocked) {
|
||||
elements.playBtn.textContent = getMessage('BTN_PLAY');
|
||||
elements.playBtn.disabled = false;
|
||||
}
|
||||
@@ -1480,6 +1641,7 @@ elements.playBtn.addEventListener('click', () => {
|
||||
});
|
||||
|
||||
elements.pauseBtn.addEventListener('click', () => {
|
||||
if (hcmGuestLocked) return; // guest in host-only room — backstop
|
||||
if (!elements.targetTab.value) {
|
||||
showToast(getMessage('ERR_SELECT_VIDEO'), 'warning');
|
||||
return;
|
||||
@@ -1498,7 +1660,7 @@ elements.pauseBtn.addEventListener('click', () => {
|
||||
});
|
||||
// Safety reset: restore button after 2.5s in case no peers respond
|
||||
setTimeout(() => {
|
||||
if (elements.pauseBtn.disabled) {
|
||||
if (elements.pauseBtn.disabled && !hcmGuestLocked) {
|
||||
elements.pauseBtn.textContent = getMessage('BTN_PAUSE');
|
||||
elements.pauseBtn.disabled = false;
|
||||
}
|
||||
@@ -1511,6 +1673,20 @@ elements.clearLogs.addEventListener('click', () => {
|
||||
});
|
||||
});
|
||||
|
||||
if (elements.regenId) {
|
||||
elements.regenId.addEventListener('click', () => {
|
||||
elements.regenId.disabled = true;
|
||||
chrome.runtime.sendMessage({ type: 'REGENERATE_ID' }, (res) => {
|
||||
elements.regenId.disabled = false;
|
||||
if (chrome.runtime.lastError || !res || !res.peerId) {
|
||||
showToast(getMessage('TOAST_ID_REGENERATED') || 'Failed to regenerate identity', 'error');
|
||||
return;
|
||||
}
|
||||
showToast(getMessage('TOAST_ID_REGENERATED') || 'Identity regenerated — reconnecting…', 'success', 3000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
elements.copyInvite.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(elements.inviteLink.value).then(() => {
|
||||
const original = elements.copyInvite.textContent;
|
||||
@@ -1656,6 +1832,9 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
} else if (msg.type === 'PEER_UPDATE') {
|
||||
updatePeerList(msg.peers);
|
||||
if (msg.peers) detectPeerChanges(msg.peers);
|
||||
} else if (msg.type === 'CONTROL_MODE') {
|
||||
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
|
||||
updateHostControlUI({ controlMode: msg.controlMode, amHost: msg.amHost, amController: msg.amController, controllers: msg.controllers, hostPeerId: msg.hostPeerId, hostControlSupported: msg.hostControlSupported, coHostSupported: msg.coHostSupported, inRoom });
|
||||
} else if (msg.type === 'CONNECTION_STATUS') {
|
||||
if (msg.status === 'connected' || msg.status === 'disconnected') {
|
||||
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
|
||||
@@ -1673,6 +1852,7 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (res.peers) updatePeerList(res.peers);
|
||||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||
updatePingDisplay(res.ping);
|
||||
updateHostControlUI({ controlMode: res.controlMode, amHost: res.amHost, amController: res.amController, controllers: res.controllers, hostPeerId: res.hostPeerId, hostControlSupported: res.hostControlSupported, coHostSupported: res.coHostSupported, inRoom: true });
|
||||
});
|
||||
}
|
||||
if (msg.status === 'disconnected') {
|
||||
|
||||
+316
-1
@@ -3,6 +3,7 @@ import http from 'node:http';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { connectionCounts, clearRateLimitMaps } from '../server/rate-limiter.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(path.join(__dirname, '..', 'server', 'package.json'));
|
||||
@@ -23,6 +24,9 @@ function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith(
|
||||
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 j(ws, rid, pid, pw=null) { s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0'}); assert.equal((await a(ws))[0],'room_data'); }
|
||||
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).
|
||||
function resetConnectionRate() { connectionCounts.clear(); clearRateLimitMaps(); }
|
||||
|
||||
try {
|
||||
process.env.ADMIN_METRICS_TOKEN = 'ws-integration-test-32chars-minimum!';
|
||||
@@ -57,6 +61,317 @@ try {
|
||||
s(p1,'leave_room',{}); const [ev,d]=await a(p2); assert.equal(ev,'peer_status');assert.equal(d.status,'left');
|
||||
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Capabilities: ROOM_DATA advertises server features for client detection ---
|
||||
const capClient = await c();
|
||||
s(capClient, 'join_room', { roomId: 'cap-'+Date.now(), peerId: 'capp', protocolVersion: '1.0.0' });
|
||||
const [capEv, capData] = await a(capClient);
|
||||
assert.equal(capEv, 'room_data');
|
||||
assert.ok(Array.isArray(capData.capabilities) && capData.capabilities.includes('host-control'),
|
||||
'ROOM_DATA advertises the host-control capability');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Default 'everyone' mode does NOT gate anyone (host-control OFF = unchanged) ---
|
||||
// Confirms that with host-only off, a non-host guest can still drive every
|
||||
// room-moving event exactly like before the feature existed.
|
||||
const erid = 'every-'+Date.now();
|
||||
const e1 = await c(), e2 = await c(); // e1 = creator/host, e2 = guest
|
||||
await j(e1, erid, 'ehost'); await j(e2, erid, 'eguest'); e1._m.length = e2._m.length = 0;
|
||||
s(e2,'play',{currentTime:1}); await w(e1,'play');
|
||||
s(e2,'pause',{currentTime:2}); await w(e1,'pause');
|
||||
s(e2,'seek',{currentTime:3}); await w(e1,'seek');
|
||||
s(e2,'force_sync_prepare',{targetTime:0}); await w(e1,'force_sync_prepare');
|
||||
s(e2,'episode_lobby',{expectedTitle:'S1E1'}); await w(e1,'episode_lobby');
|
||||
// (reaching here without a wait timeout == nothing was gated)
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Host Control Mode ---
|
||||
const hrid = 'host-'+Date.now();
|
||||
const h1 = await c(), h2 = await c(); // h1 = host (first joiner), h2 = guest
|
||||
await j(h1, hrid, 'host1'); await j(h2, hrid, 'guest1'); h1._m.length = h2._m.length = 0;
|
||||
|
||||
// Host enables host-only -> both peers get the control_mode broadcast
|
||||
s(h1,'set_control_mode',{controlMode:'host-only'});
|
||||
await w(h1,'control_mode'); await w(h2,'control_mode');
|
||||
h1._m.length = h2._m.length = 0;
|
||||
|
||||
// Guest's room-moving event (pause) is dropped -> host must NOT receive it
|
||||
s(h2,'pause',{currentTime:5});
|
||||
let guestPauseDropped = false; try { await w(h1,'pause',600); } catch { guestPauseDropped = true; }
|
||||
assert.ok(guestPauseDropped, 'guest pause dropped in host-only');
|
||||
|
||||
// Host's own pause still relays to the guest
|
||||
s(h1,'pause',{currentTime:7}); await w(h2,'pause');
|
||||
h1._m.length = h2._m.length = 0;
|
||||
|
||||
// desynced flag is relayed through PEER_STATUS heartbeats so the host's UI
|
||||
// can show "Solo" for guests watching on their own.
|
||||
s(h2,'peer_status',{status:'heartbeat',desynced:true,currentTime:42,playbackState:'playing'});
|
||||
let hbData = null; const hbStart = Date.now();
|
||||
while (Date.now()-hbStart < 600 && !hbData) {
|
||||
for (let i=0;i<h1._m.length;i++){ const r=h1._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='peer_status'){ h1._m.splice(i,1); hbData=dd; break; } } }
|
||||
await new Promise(r=>setTimeout(r,30));
|
||||
}
|
||||
assert.ok(hbData && hbData.desynced === true, 'desynced=true relayed in heartbeat');
|
||||
h1._m.length = h2._m.length = 0;
|
||||
|
||||
// Guest cannot change the control mode -> host must NOT receive a broadcast.
|
||||
// The rejected sender gets a unicast of the *actual* state so any optimistic
|
||||
// UI reverts (H-5); assert both halves.
|
||||
s(h2,'set_control_mode',{controlMode:'everyone'});
|
||||
let guestSetBlocked = false; try { await w(h1,'control_mode',600); } catch { guestSetBlocked = true; }
|
||||
assert.ok(guestSetBlocked, 'non-host cannot set control mode');
|
||||
let rejectSync = null; const rsStart = Date.now();
|
||||
while (Date.now()-rsStart < 600 && !rejectSync) {
|
||||
for (let i=0;i<h2._m.length;i++){ const r=h2._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='control_mode'){ h2._m.splice(i,1); rejectSync=dd; break; } } }
|
||||
await new Promise(r=>setTimeout(r,30));
|
||||
}
|
||||
assert.ok(rejectSync && rejectSync.controlMode==='host-only' && rejectSync.hostPeerId==='host1',
|
||||
'rejected sender is re-synced to actual state');
|
||||
h1._m.length = h2._m.length = 0;
|
||||
|
||||
// Host leaves -> room falls back to 'everyone' and reassigns host to the guest
|
||||
s(h1,'leave_room',{});
|
||||
let fb = null; const fbStart = Date.now();
|
||||
while (Date.now()-fbStart < 2000 && !fb) {
|
||||
for (let i=0;i<h2._m.length;i++){ const r=h2._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='control_mode'){ h2._m.splice(i,1); fb=dd; break; } } }
|
||||
await new Promise(r=>setTimeout(r,50));
|
||||
}
|
||||
assert.ok(fb && fb.controlMode==='everyone' && fb.hostPeerId==='guest1', 'host leave -> fallback everyone + new host');
|
||||
close();
|
||||
|
||||
// --- M-4: rapid control-mode toggles are debounced per-room ---
|
||||
const drid = 'debounce-'+Date.now();
|
||||
const db1 = await c(), db2 = await c();
|
||||
await j(db1, drid, 'dhost'); await j(db2, drid, 'dguest'); db1._m.length = db2._m.length = 0;
|
||||
|
||||
// First toggle (everyone → host-only) goes through.
|
||||
s(db1,'set_control_mode',{controlMode:'host-only'});
|
||||
await w(db1,'control_mode'); await w(db2,'control_mode');
|
||||
db1._m.length = db2._m.length = 0;
|
||||
|
||||
// Immediate second toggle (host-only → everyone) should be debounced:
|
||||
// broadcast goes to neither peer, but sender gets a re-sync unicast.
|
||||
s(db1,'set_control_mode',{controlMode:'everyone'});
|
||||
let dGuestGotIt = false; try { await w(db2,'control_mode',600); } catch { dGuestGotIt = true; }
|
||||
assert.ok(dGuestGotIt, 'rapid control-mode toggle is debounced (no broadcast)');
|
||||
let dSenderResync = null; const dsStart = Date.now();
|
||||
while (Date.now()-dsStart < 600 && !dSenderResync) {
|
||||
for (let i=0;i<db1._m.length;i++){ const r=db1._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='control_mode'){ db1._m.splice(i,1); dSenderResync=dd; break; } } }
|
||||
await new Promise(r=>setTimeout(r,30));
|
||||
}
|
||||
assert.ok(dSenderResync && dSenderResync.controlMode==='host-only',
|
||||
'debounced toggle re-syncs sender to actual state');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Host role survives peerId dedup (reconnect / second tab) ---
|
||||
const hdrid = 'dedup-host-'+Date.now();
|
||||
const hd1 = await c(), hd2 = await c();
|
||||
await j(hd1, hdrid, 'dhost'); await j(hd2, hdrid, 'dguest'); hd1._m.length = hd2._m.length = 0;
|
||||
s(hd1,'set_control_mode',{controlMode:'host-only'});
|
||||
await w(hd1,'control_mode'); await w(hd2,'control_mode');
|
||||
hd1._m.length = hd2._m.length = 0;
|
||||
// The host's peerId re-joins on a fresh socket → server dedupes the old socket.
|
||||
// This must NOT demote the host or reset the mode (a network blip / second tab).
|
||||
const hd3 = await c();
|
||||
s(hd3,'join_room',{roomId:hdrid,peerId:'dhost',protocolVersion:'1.0.0'});
|
||||
const hdrd = await a(hd3);
|
||||
assert.equal(hdrd[0],'room_data');
|
||||
assert.ok(hdrd[1].controlMode === 'host-only' && hdrd[1].hostPeerId === 'dhost',
|
||||
'host role + host-only mode survive peerId dedup (reconnect/second tab)');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Co-Host: owner promotes a guest to controller (can drive); demote re-gates ---
|
||||
const crid = 'cohost-'+Date.now();
|
||||
const co1 = await c(), co2 = await c(), co3 = await c(); // owner / to-promote / stays guest
|
||||
await j(co1, crid, 'owner'); await j(co2, crid, 'cohost'); await j(co3, crid, 'guestx');
|
||||
co1._m.length = co2._m.length = co3._m.length = 0;
|
||||
s(co1,'set_control_mode',{controlMode:'host-only'});
|
||||
await w(co1,'control_mode'); await w(co2,'control_mode'); await w(co3,'control_mode');
|
||||
co1._m.length = co2._m.length = co3._m.length = 0;
|
||||
// before promotion the co-host is gated
|
||||
s(co2,'pause',{currentTime:1});
|
||||
let coGatedBefore=false; try { await w(co1,'pause',500); } catch { coGatedBefore=true; }
|
||||
assert.ok(coGatedBefore, 'co-host gated before promotion');
|
||||
// owner promotes co-host → controllers broadcast includes owner + cohost
|
||||
s(co1,'set_peer_role',{peerId:'cohost',controller:true});
|
||||
let promo=null; const pps=Date.now();
|
||||
while(Date.now()-pps<800 && !promo){ for(let i=0;i<co2._m.length;i++){const r=co2._m[i];if(r.startsWith('42')){const[e,dd]=JSON.parse(r.substring(2));if(e==='control_mode'){co2._m.splice(i,1);promo=dd;break;}}} await new Promise(r=>setTimeout(r,30)); }
|
||||
assert.ok(promo && Array.isArray(promo.controllers) && promo.controllers.includes('cohost') && promo.controllers.includes('owner'),
|
||||
'promotion broadcasts controllers (owner + cohost)');
|
||||
co1._m.length = co2._m.length = co3._m.length = 0;
|
||||
// promoted co-host can now drive; a plain guest still cannot
|
||||
s(co2,'pause',{currentTime:2}); await w(co1,'pause');
|
||||
s(co3,'play',{currentTime:3});
|
||||
let plainGuestGated=false; try { await w(co1,'play',500); } catch { plainGuestGated=true; }
|
||||
assert.ok(plainGuestGated, 'plain guest still gated after a co-host is promoted');
|
||||
co1._m.length = co2._m.length = co3._m.length = 0;
|
||||
// a non-owner (the co-host) cannot promote anyone
|
||||
s(co2,'set_peer_role',{peerId:'guestx',controller:true});
|
||||
let nonOwnerBlocked=false; try { await w(co3,'control_mode',500); } catch { nonOwnerBlocked=true; }
|
||||
assert.ok(nonOwnerBlocked, 'non-owner cannot promote (no room broadcast)');
|
||||
co1._m.length = co2._m.length = co3._m.length = 0;
|
||||
// owner demotes the co-host → gated again
|
||||
s(co1,'set_peer_role',{peerId:'cohost',controller:false});
|
||||
await w(co2,'control_mode');
|
||||
co1._m.length = co2._m.length = co3._m.length = 0;
|
||||
s(co2,'seek',{currentTime:4});
|
||||
let coGatedAfter=false; try { await w(co1,'seek',500); } catch { coGatedAfter=true; }
|
||||
assert.ok(coGatedAfter, 'demoted co-host is gated again');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- H-1: a demoted co-host's FORCE_SYNC_EXECUTE still relays when they
|
||||
// initiated the in-flight PREPARE. Without the initiator exemption, the
|
||||
// EXECUTE would be dropped by the host-only gate and every peer would be
|
||||
// left stuck paused. ---
|
||||
const h1rid = 'h1-'+Date.now();
|
||||
const ho = await c(), hc = await c(), hg = await c(); // owner / co-host / guest
|
||||
await j(ho, h1rid, 'owner'); await j(hc, h1rid, 'cohost'); await j(hg, h1rid, 'guest');
|
||||
ho._m.length = hc._m.length = hg._m.length = 0;
|
||||
s(ho,'set_control_mode',{controlMode:'host-only'});
|
||||
await w(ho,'control_mode'); await w(hc,'control_mode'); await w(hg,'control_mode');
|
||||
ho._m.length = hc._m.length = hg._m.length = 0;
|
||||
// owner promotes co-host; co-host initiates force sync
|
||||
s(ho,'set_peer_role',{peerId:'cohost',controller:true});
|
||||
await w(hc,'control_mode');
|
||||
ho._m.length = hc._m.length = hg._m.length = 0;
|
||||
s(hc,'force_sync_prepare',{targetTime:0});
|
||||
await w(ho,'force_sync_prepare'); await w(hg,'force_sync_prepare');
|
||||
ho._m.length = hc._m.length = hg._m.length = 0;
|
||||
// owner demotes the co-host mid-flight — the EXECUTE must still go through.
|
||||
// Wait out the per-room role-change debounce (M-4) so this demote broadcasts:
|
||||
// in real usage the host can't promote, run a force-sync, and demote inside 500ms.
|
||||
await new Promise(r => setTimeout(r, 550));
|
||||
s(ho,'set_peer_role',{peerId:'cohost',controller:false});
|
||||
await w(hc,'control_mode');
|
||||
ho._m.length = hc._m.length = hg._m.length = 0;
|
||||
s(hc,'force_sync_execute',{});
|
||||
await w(ho,'force_sync_execute'); await w(hg,'force_sync_execute');
|
||||
// After the EXECUTE, the initiator slot is cleared: a fresh EXECUTE from the
|
||||
// (now plain guest) co-host is gated again, confirming the exemption is scoped.
|
||||
ho._m.length = hc._m.length = hg._m.length = 0;
|
||||
s(hc,'force_sync_execute',{});
|
||||
let reGated=false; try { await w(ho,'force_sync_execute',500); } catch { reGated=true; }
|
||||
assert.ok(reGated, 'initiator exemption is cleared after the EXECUTE relayes');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- A guest's stray EXECUTE (no matching PREPARE they initiated) is still gated ---
|
||||
const grid = 'h1b-'+Date.now();
|
||||
const go = await c(), gg = await c();
|
||||
await j(go, grid, 'own'); await j(gg, grid, 'gst');
|
||||
go._m.length = gg._m.length = 0;
|
||||
s(go,'set_control_mode',{controlMode:'host-only'});
|
||||
await w(go,'control_mode'); await w(gg,'control_mode');
|
||||
go._m.length = gg._m.length = 0;
|
||||
s(gg,'force_sync_execute',{});
|
||||
let uninitGated=false; try { await w(go,'force_sync_execute',500); } catch { uninitGated=true; }
|
||||
assert.ok(uninitGated, 'guest FORCE_SYNC_EXECUTE without a matching PREPARE is gated');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// =====================================================================
|
||||
// BACKWARD COMPATIBILITY — old clients (pre-HCM build) against new server.
|
||||
// These tests simulate an old client by deliberately omitting fields the
|
||||
// new feature added (desynced in heartbeats, capabilities expectation)
|
||||
// and by ignoring CONTROL_MODE broadcasts. The wire format for existing
|
||||
// events must stay byte-compatible: the relay must accept old payloads
|
||||
// and must not inject new fields old clients would misread.
|
||||
// =====================================================================
|
||||
|
||||
// --- BC-1: Old-client heartbeat (no `desynced` field) is accepted and
|
||||
// relayed without injecting `desynced`. Old clients never sent the
|
||||
// field; the relay must strip it from the wire so we don't surprise
|
||||
// them with unexpected keys. ---
|
||||
const bcrid = 'bc-'+Date.now();
|
||||
const bco = await c(), bcn = await c(); // bco = "old", bcn = "new"
|
||||
await j(bco, bcrid, 'oldp'); await j(bcn, bcrid, 'newp');
|
||||
bco._m.length = bcn._m.length = 0;
|
||||
// Old-client heartbeat: every field an old build would send, NO `desynced`.
|
||||
s(bco,'peer_status',{
|
||||
status:'heartbeat', username:'old', tabTitle:'t', mediaTitle:'m',
|
||||
playbackState:'playing', currentTime:42, volume:0.5, muted:false
|
||||
});
|
||||
let bcRelay = null; const bcStart = Date.now();
|
||||
while (Date.now()-bcStart < 800 && !bcRelay) {
|
||||
for (let i=0;i<bcn._m.length;i++){ const r=bcn._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='peer_status'){ bcn._m.splice(i,1); bcRelay=dd; break; } } }
|
||||
await new Promise(r=>setTimeout(r,30));
|
||||
}
|
||||
assert.ok(bcRelay, 'old-style heartbeat relayed');
|
||||
assert.ok(bcRelay.desynced === undefined, 'old-client heartbeat has no desynced on the wire (stripped)');
|
||||
assert.equal(bcRelay.currentTime, 42, 'old-client currentTime preserved');
|
||||
assert.equal(bcRelay.senderId, 'oldp', 'old-client senderId preserved');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- BC-2: Old client in a host-only room — server still gates its events
|
||||
// even though the client has no awareness of the mode. This is the
|
||||
// key guarantee for mixed rooms during rollout: an old client can't
|
||||
// drive a host-only room just because it ignores CONTROL_MODE. ---
|
||||
const hmrid = 'hcmix-'+Date.now();
|
||||
const hmo = await c(), hmg = await c(); // hmo = host (new), hmg = "old" guest
|
||||
await j(hmo, hmrid, 'hmixhost'); await j(hmg, hmrid, 'hmixold');
|
||||
hmo._m.length = hmg._m.length = 0;
|
||||
s(hmo,'set_control_mode',{controlMode:'host-only'});
|
||||
await w(hmo,'control_mode'); await w(hmg,'control_mode'); // old client's socket still receives it; old client would ignore
|
||||
hmo._m.length = hmg._m.length = 0;
|
||||
// "Old" guest tries to drive — server must drop. Host must NOT receive it.
|
||||
s(hmg,'pause',{currentTime:5});
|
||||
let oldGated=false; try { await w(hmo,'pause',600); } catch { oldGated=true; }
|
||||
assert.ok(oldGated, 'old-client pause dropped in host-only (server enforces regardless of client awareness)');
|
||||
// Host's own command still relays to the old client's socket — old client
|
||||
// applies it via its existing PLAY/PAUSE handler.
|
||||
s(hmo,'pause',{currentTime:7}); await w(hmg,'pause');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- BC-3: Mixed room with old + new client in 'everyone' mode — every
|
||||
// event flows identically to pre-HCM. Confirms no regression in the
|
||||
// default-mode relay path that could fragment a rolling-update room. ---
|
||||
const mxrid = 'mix-'+Date.now();
|
||||
const mxo = await c(), mxn = await c(); // mxo = old, mxn = new
|
||||
await j(mxo, mxrid, 'oldmx'); await j(mxn, mxrid, 'newmx');
|
||||
mxo._m.length = mxn._m.length = 0;
|
||||
// Old → new
|
||||
s(mxo,'play',{currentTime:1}); await w(mxn,'play');
|
||||
s(mxo,'seek',{currentTime:99}); await w(mxn,'seek');
|
||||
s(mxo,'force_sync_prepare',{targetTime:5}); await w(mxn,'force_sync_prepare');
|
||||
s(mxo,'episode_lobby',{expectedTitle:'S1E1'}); await w(mxn,'episode_lobby');
|
||||
// New → old
|
||||
mxo._m.length = mxn._m.length = 0;
|
||||
s(mxn,'pause',{currentTime:2}); await w(mxo,'pause');
|
||||
s(mxn,'seek',{currentTime:50}); await w(mxo,'seek');
|
||||
s(mxn,'force_sync_execute',{}); await w(mxo,'force_sync_execute');
|
||||
s(mxn,'episode_lobby_cancel',{}); await w(mxo,'episode_lobby_cancel');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- BC-4: New-client heartbeat WITH `desynced` does not break an old
|
||||
// client's receive path. The field is appended but old clients ignore
|
||||
// unknown keys — verify the relay preserves every pre-HCM field and
|
||||
// only adds `desynced`. ---
|
||||
const b4rid = 'bc4-'+Date.now();
|
||||
const b4old = await c(), b4new = await c();
|
||||
await j(b4old, b4rid, 'b4o'); await j(b4new, b4rid, 'b4n');
|
||||
b4old._m.length = b4new._m.length = 0;
|
||||
s(b4new,'peer_status',{status:'heartbeat', desynced:true, currentTime:7, playbackState:'paused', username:'newp'});
|
||||
let b4Relay = null; const b4Start = Date.now();
|
||||
while (Date.now()-b4Start < 800 && !b4Relay) {
|
||||
for (let i=0;i<b4old._m.length;i++){ const r=b4old._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='peer_status'){ b4old._m.splice(i,1); b4Relay=dd; break; } } }
|
||||
await new Promise(r=>setTimeout(r,30));
|
||||
}
|
||||
assert.ok(b4Relay, 'new-client heartbeat relayed to old client');
|
||||
assert.equal(b4Relay.desynced, true, 'desynced preserved for new recipients');
|
||||
assert.equal(b4Relay.currentTime, 7, 'old fields preserved on the same relay');
|
||||
assert.equal(b4Relay.senderId, 'b4n', 'senderId preserved');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Password room ---
|
||||
const prid = 'pw-'+Date.now();
|
||||
@@ -93,7 +408,7 @@ try {
|
||||
let d=''; res.on('data',c=>d+=c); res.on('end',()=>r([res.statusCode,JSON.parse(d)])); }));
|
||||
assert.equal(st,200); assert.equal(body.status,'online');
|
||||
|
||||
console.log('All 13 WebSocket integration tests passed');
|
||||
console.log('All WebSocket integration tests passed (incl. host control mode)');
|
||||
} catch(e) {
|
||||
console.error('FAILED:', e.message);
|
||||
process.exitCode=1;
|
||||
|
||||
+222
-12
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
|
||||
import { Server } from 'socket.io';
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../shared/constants.js';
|
||||
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
|
||||
import {
|
||||
buildHealthPayload,
|
||||
checkCooldown,
|
||||
@@ -64,6 +64,10 @@ if (!isAdminMetricsTokenStrong(ADMIN_METRICS_TOKEN)) {
|
||||
console.warn('[SECURITY] ADMIN_METRICS_TOKEN is set but shorter than 32 characters. Use a long random token.');
|
||||
}
|
||||
|
||||
if (!process.env.SERVER_SALT) {
|
||||
console.warn('[SECURITY] SERVER_SALT is not set — using the built-in default salt (public in the repo). Set a unique SERVER_SALT so room-password hashes are not computed with a known salt.');
|
||||
}
|
||||
|
||||
export const app = express();
|
||||
app.set('trust proxy', 1); // For real client IP through reverse proxy
|
||||
|
||||
@@ -148,6 +152,39 @@ const peerToSocket = new Map(); // peerId -> socketId (Global lookup)
|
||||
const roomCreationLocks = new Map(); // roomId -> Promise (prevents race on room creation)
|
||||
const peerJoinLocks = new Map(); // peerId -> Promise (prevents race on same peerId joins)
|
||||
|
||||
// Host Control Mode: events a non-host guest may NOT initiate while a room is in
|
||||
// 'host-only' mode (they would move/disrupt everyone). Reactions like FORCE_SYNC_ACK,
|
||||
// EPISODE_READY, PEER_STATUS heartbeats remain allowed for all peers.
|
||||
const HOST_ONLY_GATED_EVENTS = new Set([
|
||||
EVENTS.PLAY,
|
||||
EVENTS.PAUSE,
|
||||
EVENTS.SEEK,
|
||||
EVENTS.FORCE_SYNC_PREPARE,
|
||||
EVENTS.FORCE_SYNC_EXECUTE,
|
||||
EVENTS.EPISODE_LOBBY,
|
||||
EVENTS.EPISODE_LOBBY_CANCEL
|
||||
]);
|
||||
|
||||
// Features this relay supports, advertised to clients in ROOM_DATA so they can
|
||||
// enable matching UI/behavior only when the server actually backs it. Append a
|
||||
// flag here when a new server-gated feature ships (e.g. co-host promotion).
|
||||
const SERVER_CAPABILITIES = [CAPABILITIES.HOST_CONTROL, CAPABILITIES.CO_HOST];
|
||||
|
||||
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
|
||||
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
|
||||
// from generating one broadcast per toggle across all peers.
|
||||
const CONTROL_MODE_MIN_INTERVAL_MS = 500;
|
||||
|
||||
// Canonical control-mode/role snapshot sent to clients. controllers always includes
|
||||
// the owner (hostPeerId). Built in one place so every emit stays consistent.
|
||||
function controlModePayload(room) {
|
||||
return {
|
||||
controlMode: room.controlMode,
|
||||
hostPeerId: room.hostPeerId,
|
||||
controllers: room.controllers ? Array.from(room.controllers) : (room.hostPeerId ? [room.hostPeerId] : [])
|
||||
};
|
||||
}
|
||||
|
||||
function log(type, message, details = '') {
|
||||
const debugLogging = process.env.DEBUG_LOGGING === '1';
|
||||
const isVerbose = type === 'CONN' || type === 'ROOM' || type === 'DEDUPE' || type === 'CORS' || type === 'ACKDROP';
|
||||
@@ -201,6 +238,42 @@ function removePeerFromRoom(socketId, roomId, reason) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3.6. Host Control Mode: if the host left (and isn't still connected via another
|
||||
// socket), fall back to 'everyone' so the room never gets stuck locked, and
|
||||
// reassign host to the earliest remaining peer so the feature stays usable.
|
||||
// (v1: immediate fallback, no grace period — see host-control-mode docs.)
|
||||
// Skip while a join for this peerId is in flight (peerJoinLocks holds it):
|
||||
// that's the reconnect / second-tab case where the same peerId is being
|
||||
// re-added right after the dedupe kicked the old socket. Demoting there
|
||||
// would silently unlock the room on every second-tab open.
|
||||
// NOTE: this does *not* cover a true network blip where the old socket's
|
||||
// 'disconnect' fires before the new socket acquires its join lock — in that
|
||||
// window peerJoinLocks is empty, so the fallback fires. Documented v1
|
||||
// limitation (no host grace period); see KNOWN_LIMITATIONS.md.
|
||||
const peerRejoining = peerJoinLocks.has(peerId);
|
||||
const peerGone = !isPeerStillConnected && !peerRejoining;
|
||||
if (peerGone && room.controllers && room.peers.size > 0) {
|
||||
const wasController = room.controllers.has(peerId);
|
||||
room.controllers.delete(peerId);
|
||||
// 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.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.
|
||||
const nextPeerData = room.peerData.values().next().value;
|
||||
room.hostPeerId = nextPeerData ? nextPeerData.peerId : null;
|
||||
room.controlMode = CONTROL_MODES.EVERYONE;
|
||||
room.controllers = new Set(room.hostPeerId ? [room.hostPeerId] : []);
|
||||
io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
log('ROOM', `Owner left room ${roomId.substring(0, 3)}*** — fell back to 'everyone', new owner: ${room.hostPeerId}`);
|
||||
} else if (wasController) {
|
||||
// A co-host left → keep the mode, just broadcast the updated controller list.
|
||||
io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
log('ROOM', `Controller ${peerId} left room ${roomId.substring(0, 3)}***`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Delete empty room
|
||||
if (room.peers.size === 0) {
|
||||
rooms.delete(roomId);
|
||||
@@ -333,7 +406,19 @@ io.on('connection', (socket) => {
|
||||
peers: new Set(),
|
||||
peerIds: new Map(),
|
||||
peerData: new Map(),
|
||||
lastActivity: Date.now()
|
||||
lastActivity: Date.now(),
|
||||
// Host Control Mode: creator (first joiner) is the host/owner.
|
||||
hostPeerId: peerId,
|
||||
controlMode: CONTROL_MODES.EVERYONE,
|
||||
lastControlModeChangeAt: 0, // M-4: per-room debounce for control-mode toggles
|
||||
lastRoleChangeAt: 0, // M-4: per-room debounce for role promote/demote
|
||||
// Co-Host: peers allowed to drive in 'host-only'. Always includes the owner.
|
||||
controllers: new Set([peerId]),
|
||||
// H-1: peerId of the in-flight force-sync initiator. Lets a demoted
|
||||
// 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
|
||||
};
|
||||
rooms.set(roomId, room);
|
||||
createdByMe = true;
|
||||
@@ -415,10 +500,14 @@ 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' });
|
||||
socket.emit(EVENTS.ROOM_DATA, {
|
||||
roomId,
|
||||
socket.emit(EVENTS.ROOM_DATA, {
|
||||
roomId,
|
||||
peers: Array.from(room.peers).map(sid => room.peerData.get(sid)),
|
||||
activeLobby: room.activeLobby || null
|
||||
activeLobby: room.activeLobby || null,
|
||||
hostPeerId: room.hostPeerId || null,
|
||||
controlMode: room.controlMode || CONTROL_MODES.EVERYONE,
|
||||
controllers: room.controllers ? Array.from(room.controllers) : [],
|
||||
capabilities: SERVER_CAPABILITIES
|
||||
});
|
||||
log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`);
|
||||
} finally {
|
||||
@@ -458,7 +547,35 @@ io.on('connection', (socket) => {
|
||||
const room = rooms.get(mapping.roomId);
|
||||
if (room) {
|
||||
room.lastActivity = Date.now();
|
||||
|
||||
|
||||
// --- Host Control Mode gate ---
|
||||
// In 'host-only' mode, drop room-moving events from anyone who is not
|
||||
// a controller (the owner + any promoted co-hosts). Robust chokepoint:
|
||||
// independent of client behavior, kills spam. Heartbeats/ACKs pass.
|
||||
//
|
||||
// H-1 exception: a demoted co-host's FORCE_SYNC_EXECUTE still has to
|
||||
// land — otherwise their already-relayed PREPARE would leave the whole
|
||||
// room stuck paused. Track the in-flight initiator on PREPARE and let
|
||||
// their matching EXECUTE through regardless of current controllers set.
|
||||
if (eventName === EVENTS.FORCE_SYNC_PREPARE &&
|
||||
room.controlMode === CONTROL_MODES.HOST_ONLY &&
|
||||
room.controllers && room.controllers.has(mapping.peerId)) {
|
||||
room.forceSyncInitiator = mapping.peerId;
|
||||
}
|
||||
const isOwnForceSyncExecute = eventName === EVENTS.FORCE_SYNC_EXECUTE &&
|
||||
room.forceSyncInitiator && mapping.peerId === room.forceSyncInitiator;
|
||||
if (!isOwnForceSyncExecute &&
|
||||
room.controlMode === CONTROL_MODES.HOST_ONLY &&
|
||||
!(room.controllers && room.controllers.has(mapping.peerId)) &&
|
||||
HOST_ONLY_GATED_EVENTS.has(eventName)) {
|
||||
log('ROOM', `Dropped ${eventName} from guest ${mapping.peerId} in host-only room ${mapping.roomId.substring(0, 3)}***`);
|
||||
return;
|
||||
}
|
||||
// Clear initiator tracking once the EXECUTE has been relayed.
|
||||
if (eventName === EVENTS.FORCE_SYNC_EXECUTE && room.forceSyncInitiator) {
|
||||
room.forceSyncInitiator = null;
|
||||
}
|
||||
|
||||
// --- S-2 & S-3: Sanitize ALL relay fields (strings, numbers, booleans) ---
|
||||
const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : undefined;
|
||||
const clampNum = (val, min, max) => typeof val === 'number' && Number.isFinite(val) ? Math.max(min, Math.min(max, val)) : undefined;
|
||||
@@ -469,12 +586,13 @@ io.on('connection', (socket) => {
|
||||
room.peerData.set(socket.id, {
|
||||
...existing,
|
||||
username: data.username !== undefined ? (clamp(data.username, 30) ?? existing.username) : existing.username,
|
||||
tabTitle: data.tabTitle !== undefined ? (clamp(data.tabTitle, 100) ?? existing.tabTitle) : existing.tabTitle,
|
||||
mediaTitle: data.mediaTitle !== undefined ? (clamp(data.mediaTitle, 100) ?? existing.mediaTitle) : existing.mediaTitle,
|
||||
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 !== undefined ? (clampNum(data.currentTime, 0, 86400) ?? existing.currentTime) : existing.currentTime,
|
||||
currentTime: data.currentTime === null ? null : (data.currentTime !== undefined ? (clampNum(data.currentTime, 0, 86400) ?? 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),
|
||||
lastSeen: Date.now()
|
||||
});
|
||||
|
||||
@@ -482,14 +600,15 @@ io.on('connection', (socket) => {
|
||||
const relayPayload = {
|
||||
senderId: mapping.peerId,
|
||||
seq: clampNum(data.seq, 0, Number.MAX_SAFE_INTEGER),
|
||||
currentTime: clampNum(data.currentTime, 0, 86400),
|
||||
currentTime: data.currentTime === null ? null : clampNum(data.currentTime, 0, 86400),
|
||||
targetTime: clampNum(data.targetTime, 0, 86400),
|
||||
playbackState: validState(data.playbackState),
|
||||
username: clamp(data.username, 30),
|
||||
tabTitle: clamp(data.tabTitle, 100),
|
||||
mediaTitle: clamp(data.mediaTitle, 100),
|
||||
tabTitle: data.tabTitle === null ? null : clamp(data.tabTitle, 100),
|
||||
mediaTitle: data.mediaTitle === null ? null : clamp(data.mediaTitle, 100),
|
||||
volume: clampNum(data.volume, 0, 1),
|
||||
muted: validBool(data.muted),
|
||||
desynced: validBool(data.desynced),
|
||||
peerId: mapping.peerId,
|
||||
status: typeof data.status === 'string' ? data.status.substring(0, 16) : undefined,
|
||||
expectedTitle: clamp(data.expectedTitle, 100),
|
||||
@@ -553,6 +672,97 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(EVENTS.SET_CONTROL_MODE, (data) => {
|
||||
if (!checkEventRate(socket.id)) {
|
||||
log('SECURITY', `Event rate limit exceeded for socket (SET_CONTROL_MODE): ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
if (!data || typeof data !== 'object') return;
|
||||
const mode = data.controlMode;
|
||||
if (mode !== CONTROL_MODES.EVERYONE && mode !== CONTROL_MODES.HOST_ONLY) return;
|
||||
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (!mapping) return;
|
||||
const room = rooms.get(mapping.roomId);
|
||||
if (!room) return;
|
||||
|
||||
// Only the owner may change the control mode.
|
||||
if (mapping.peerId !== room.hostPeerId) {
|
||||
log('AUTH', `Non-host ${mapping.peerId} tried to set control mode in ${mapping.roomId.substring(0, 3)}***`);
|
||||
// Re-sync the sender to the actual room state so any optimistic UI
|
||||
// (e.g. popup toggle) reverts — covers stale-local-amHost races (H-5).
|
||||
socket.emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
return;
|
||||
}
|
||||
if (room.controlMode === mode) return; // no-op, ignore (UI debounce backstop)
|
||||
|
||||
// M-4: per-room debounce — reject toggles faster than CONTROL_MODE_MIN_INTERVAL_MS.
|
||||
// The host still gets the final state via the next legit change; this just
|
||||
// kills the spam vector.
|
||||
const now = Date.now();
|
||||
if (now - room.lastControlModeChangeAt < CONTROL_MODE_MIN_INTERVAL_MS) {
|
||||
log('ROOM', `Control mode toggle debounced in ${mapping.roomId.substring(0, 3)}***`);
|
||||
// Re-sync the sender so any optimistic UI matches the actual (unchanged) state.
|
||||
socket.emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
return;
|
||||
}
|
||||
|
||||
room.controlMode = mode;
|
||||
room.lastControlModeChangeAt = now;
|
||||
room.lastActivity = now;
|
||||
io.to(mapping.roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
log('ROOM', `Control mode set to '${mode}' by host in room ${mapping.roomId.substring(0, 3)}***`);
|
||||
});
|
||||
|
||||
socket.on(EVENTS.SET_PEER_ROLE, (data) => {
|
||||
if (!checkEventRate(socket.id)) {
|
||||
log('SECURITY', `Event rate limit exceeded for socket (SET_PEER_ROLE): ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
if (!data || typeof data !== 'object') return;
|
||||
const targetPeerId = typeof data.peerId === 'string' ? data.peerId.substring(0, 16) : null;
|
||||
const makeController = data.controller === true;
|
||||
if (!targetPeerId) return;
|
||||
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (!mapping) return;
|
||||
const room = rooms.get(mapping.roomId);
|
||||
if (!room || !room.controllers) return;
|
||||
|
||||
// Only the owner may promote/demote controllers.
|
||||
if (mapping.peerId !== room.hostPeerId) {
|
||||
log('AUTH', `Non-owner ${mapping.peerId} tried to set peer role in ${mapping.roomId.substring(0, 3)}***`);
|
||||
socket.emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
return;
|
||||
}
|
||||
// The owner is always a controller and cannot be demoted.
|
||||
if (targetPeerId === room.hostPeerId) return;
|
||||
// Target must be a peer currently in the room.
|
||||
const targetPresent = Array.from(room.peerData.values()).some(d => d.peerId === targetPeerId);
|
||||
if (!targetPresent) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - (room.lastRoleChangeAt || 0) < CONTROL_MODE_MIN_INTERVAL_MS) {
|
||||
log('ROOM', `Role change debounced in ${mapping.roomId.substring(0, 3)}***`);
|
||||
socket.emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
return;
|
||||
}
|
||||
|
||||
if (makeController) {
|
||||
if (room.controllers.has(targetPeerId)) return; // no-op
|
||||
room.controllers.add(targetPeerId);
|
||||
} else {
|
||||
if (!room.controllers.has(targetPeerId)) return; // no-op
|
||||
room.controllers.delete(targetPeerId);
|
||||
}
|
||||
room.lastRoleChangeAt = now;
|
||||
room.lastActivity = now;
|
||||
io.to(mapping.roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
log('ROOM', `Peer ${targetPeerId} ${makeController ? 'promoted to' : 'demoted from'} controller in ${mapping.roomId.substring(0, 3)}***`);
|
||||
});
|
||||
|
||||
socket.on(EVENTS.EVENT_ACK, (data) => {
|
||||
if (!checkEventRate(socket.id)) {
|
||||
log('SECURITY', `Event rate limit exceeded for socket (ACK): ${socket.id}`);
|
||||
|
||||
@@ -33,6 +33,11 @@ export const EVENTS = {
|
||||
ROOM_DATA: "room_data", // Server -> Client: current room state
|
||||
ERROR: "error",
|
||||
|
||||
// Host Control Mode
|
||||
SET_CONTROL_MODE: "set_control_mode", // Client -> Server: host sets room control mode ('everyone' | 'host-only')
|
||||
CONTROL_MODE: "control_mode", // Server -> Client: control mode/role changed { controlMode, hostPeerId, controllers }
|
||||
SET_PEER_ROLE: "set_peer_role", // Client -> Server: owner promotes/demotes a peer { peerId, controller }
|
||||
|
||||
// Media Control
|
||||
PLAY: "play",
|
||||
PAUSE: "pause",
|
||||
@@ -57,6 +62,24 @@ export const EVENTS = {
|
||||
PONG: "pong" // server responds with same { t } for client RTT calculation
|
||||
};
|
||||
|
||||
// Room control modes (Host Control Mode feature).
|
||||
// NOTE: content.js does not import this module — it uses the string literals
|
||||
// 'everyone' / 'host-only' directly. Keep these values in sync there.
|
||||
export const CONTROL_MODES = {
|
||||
EVERYONE: 'everyone', // default: anyone can play/pause/seek for the room
|
||||
HOST_ONLY: 'host-only' // only the host drives the room
|
||||
};
|
||||
|
||||
// Server feature capabilities, advertised to clients in ROOM_DATA. Lets a client
|
||||
// detect what the relay actually supports instead of inferring it from the
|
||||
// presence of a data field — so new server features degrade cleanly on older
|
||||
// relays (unknown/absent list → feature treated as unavailable) and old clients
|
||||
// simply ignore the field. Add a flag here as each server-gated feature lands.
|
||||
export const CAPABILITIES = {
|
||||
HOST_CONTROL: 'host-control',
|
||||
CO_HOST: 'co-host' // owner promotes guests to additional controllers
|
||||
};
|
||||
|
||||
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
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "Alles, was du über synchrones Videoschauen mit Freunden wissen musst.",
|
||||
"FAQ_Q1": "Kann ich Netflix, Emby oder Jellyfin synchron mit Freunden schauen?",
|
||||
"FAQ_A1": "Ja! KoalaSync funktioniert mit Netflix, YouTube, Twitch und jeder Website mit einem HTML5-Videoplayer — einschließlich Emby, Jellyfin und eigenen Medienservern. Installiere die Erweiterung, erstelle einen Raum, teile den Einladungslink und alle schauen perfekt synchron.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "Streamt KoalaSync das Video an meine Freunde?",
|
||||
"FAQ_A_VIDEO_ACCESS": "Nein. KoalaSync streamt, hostet oder teilt keine Videoinhalte und umgeht keinen Zugang. Jede Person schaut das Video lokal im eigenen Browser und braucht selbst Zugriff auf die jeweilige Website oder den Streamingdienst, zum Beispiel ein eigenes Netflix-Konto. KoalaSync synchronisiert nur Aktionen wie Play, Pause und Spulen.",
|
||||
"FAQ_Q2": "Ist KoalaSync eine kostenlose Alternative zu Teleparty?",
|
||||
"FAQ_A2": "100 % kostenlos und Open Source unter der MIT-Lizenz. Keine Premium-Stufen, keine gesperrten Funktionen, keine Werbung. Anders als Teleparty läuft KoalaSyncs Relay-Server ausschließlich im RAM — ohne Datensammlung und ohne Benutzerkonten.",
|
||||
"FAQ_Q3": "Funktioniert KoalaSync mit Emby, Jellyfin und eigenen Medienservern?",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "Everything you need to know about watching videos together in perfect sync.",
|
||||
"FAQ_Q1": "Can I watch Netflix, Emby or Jellyfin together in sync with friends?",
|
||||
"FAQ_A1": "Yes! KoalaSync works on Netflix, YouTube, Twitch and any website with an HTML5 video player — including Emby, Jellyfin and self-hosted media servers. Install the extension, create a room, share the invite link, and everyone watches in perfect sync.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "Does KoalaSync stream the video to my friends?",
|
||||
"FAQ_A_VIDEO_ACCESS": "No. KoalaSync does not stream, host, share, or bypass access to video content. Everyone watches the video locally in their own browser and needs their own access to the website or streaming service, such as their own Netflix account. KoalaSync only synchronizes playback actions like play, pause, and seeking.",
|
||||
"FAQ_Q2": "Is KoalaSync a free alternative to Teleparty?",
|
||||
"FAQ_A2": "100% free and open source under the MIT License. No premium tiers, no locked features, no ads — everything is included. Unlike Teleparty, KoalaSync's relay server is RAM-only with zero data collection or user accounts required.",
|
||||
"FAQ_Q3": "Does KoalaSync work with Emby, Jellyfin and self-hosted media servers?",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "Todo lo que necesitas saber para ver vídeos en perfecta sincronía.",
|
||||
"FAQ_Q1": "¿Puedo ver Netflix, Emby o Jellyfin sincronizado con amigos?",
|
||||
"FAQ_A1": "¡Sí! KoalaSync funciona en Netflix, YouTube, Twitch y cualquier web con reproductor HTML5, incluyendo Emby, Jellyfin y servidores multimedia propios. Instala la extensión, crea una sala, comparte el enlace y todos veréis lo mismo al mismo tiempo.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "¿KoalaSync transmite el video a mis amigos?",
|
||||
"FAQ_A_VIDEO_ACCESS": "No. KoalaSync no transmite, aloja, comparte ni evita el acceso al contenido de video. Cada persona ve el video localmente en su propio navegador y necesita su propio acceso al sitio web o servicio de streaming, por ejemplo su propia cuenta de Netflix. KoalaSync solo sincroniza acciones de reproducción como reproducir, pausar y avanzar o retroceder.",
|
||||
"FAQ_Q2": "¿Es KoalaSync una alternativa gratuita a Teleparty?",
|
||||
"FAQ_A2": "Es 100% gratuito y de código abierto (Licencia MIT). Sin suscripciones, sin funciones bloqueadas y sin anuncios. A diferencia de Teleparty, el servidor de KoalaSync solo usa RAM: no hay bases de datos ni cuentas de usuario.",
|
||||
"FAQ_Q3": "¿Funciona KoalaSync con Emby, Jellyfin y servidores propios?",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "Tout ce que vous devez savoir pour regarder des vidéos en parfaite synchronisation.",
|
||||
"FAQ_Q1": "Puis-je regarder Netflix, Emby ou Jellyfin en synchronisation avec des amis ?",
|
||||
"FAQ_A1": "Oui ! KoalaSync fonctionne avec Netflix, YouTube, Twitch et tout site avec un lecteur vidéo HTML5 — y compris Emby, Jellyfin et les serveurs multimédia auto-hébergés. Installez l'extension, créez un salon, partagez le lien et regardez en parfaite synchronisation.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "KoalaSync diffuse-t-il la vidéo à mes amis ?",
|
||||
"FAQ_A_VIDEO_ACCESS": "Non. KoalaSync ne diffuse, n’héberge, ne partage et ne contourne aucun accès aux contenus vidéo. Chaque personne regarde la vidéo localement dans son propre navigateur et doit disposer de son propre accès au site ou au service de streaming, par exemple son propre compte Netflix. KoalaSync synchronise uniquement les actions de lecture comme lecture, pause et recherche dans la vidéo.",
|
||||
"FAQ_Q2": "KoalaSync est-il une alternative gratuite à Teleparty ?",
|
||||
"FAQ_A2": "100 % gratuit et open source sous licence MIT. Aucun niveau premium, aucune fonctionnalité verrouillée, aucune publicité. Contrairement à Teleparty, le serveur relay de KoalaSync fonctionne uniquement en RAM — sans collecte de données ni comptes utilisateur.",
|
||||
"FAQ_Q3": "KoalaSync fonctionne-t-il avec Emby, Jellyfin et les serveurs multimédia auto-hébergés ?",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "Tutto quello che c'è da sapere per guardare video in perfetta sincronia.",
|
||||
"FAQ_Q1": "Posso guardare Netflix o YouTube in sincronia?",
|
||||
"FAQ_A1": "Certamente! KoalaSync funziona su Netflix, YouTube, Twitch e qualsiasi sito con video HTML5. Basta installare l'estensione, creare una stanza e condividere il link.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "KoalaSync trasmette il video ai miei amici?",
|
||||
"FAQ_A_VIDEO_ACCESS": "No. KoalaSync non trasmette, ospita, condivide o aggira l’accesso ai contenuti video. Ogni persona guarda il video localmente nel proprio browser e deve avere il proprio accesso al sito o al servizio di streaming, ad esempio un proprio account Netflix. KoalaSync sincronizza solo le azioni di riproduzione come play, pausa e avanzamento.",
|
||||
"FAQ_Q2": "KoalaSync è gratuito?",
|
||||
"FAQ_A2": "Sì, al 100%. È open source con licenza MIT. Non ci sono funzioni a pagamento né pubblicità. A differenza di altre app, non raccogliamo dati utente.",
|
||||
"FAQ_Q3": "Funziona con Emby o Jellyfin?",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "完璧に同期して動画を一緒に楽しむために必要なすべての情報。",
|
||||
"FAQ_Q1": "友達と一緒にNetflix、Emby、Jellyfinを同期視聴できますか?",
|
||||
"FAQ_A1": "はい!KoalaSyncはNetflix, YouTube, TwitchおよびHTML5ビデオプレーヤーを備えたあらゆるウェブサイト(Emby, Jellyfin, 独自のメディアサーバーを含む)で動作します。拡張機能をインストールし、ルームを作成して招待リンクを共有するだけで、全員で完全に同期して視聴できます。",
|
||||
"FAQ_Q_VIDEO_ACCESS": "KoalaSync は友達に動画を配信しますか?",
|
||||
"FAQ_A_VIDEO_ACCESS": "いいえ。KoalaSync は動画コンテンツを配信、ホスト、共有したり、アクセス制限を回避したりしません。各参加者は自分のブラウザで動画をローカルに視聴し、Netflix アカウントなど、そのウェブサイトやストリーミングサービスへの自分自身のアクセス権が必要です。KoalaSync は再生、一時停止、シークなどの再生操作だけを同期します。",
|
||||
"FAQ_Q2": "KoalaSyncはTelepartyの無料の代替ツールですか?",
|
||||
"FAQ_A2": "MITライセンスのもとで100%無料かつオープンソース。有料プラン、制限された機能、広告などは一切ありません。Telepartyとは異 nel、KoalaSyncのリレーサーバーはメモリ上のみで動作し、データ収集やユーザーアカウント作成は不要です。",
|
||||
"FAQ_Q3": "KoalaSyncはEmby、Jellyfin、およびセルフホストのメディアサーバーで動作しますか?",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "완벽한 동기화로 비디오를 함께 시청하는 방법에 대해 알아야 할 모든 것.",
|
||||
"FAQ_Q1": "친구들과 Netflix, Emby 또는 Jellyfin을 함께 동기화하여 시청할 수 있나요?",
|
||||
"FAQ_A1": "네! KoalaSync는 Netflix, YouTube, Twitch 및 HTML5 비디오 플레이어가 있는 모든 웹사이트(Emby, Jellyfin 및 자체 호스팅 미디어 서버 포함)에서 작동합니다. 확장 프로그램을 설치하고 방을 만든 후 초대 링크를 공유하면 모두가 완벽하게 동기화되어 시청할 수 있습니다.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "KoalaSync가 친구들에게 동영상을 스트리밍하나요?",
|
||||
"FAQ_A_VIDEO_ACCESS": "아니요. KoalaSync는 동영상 콘텐츠를 스트리밍, 호스팅, 공유하거나 접근 권한을 우회하지 않습니다. 모든 참가자는 각자의 브라우저에서 동영상을 로컬로 시청하며, Netflix 계정처럼 해당 웹사이트나 스트리밍 서비스에 대한 본인만의 접근 권한이 필요합니다. KoalaSync는 재생, 일시정지, 탐색 같은 재생 동작만 동기화합니다.",
|
||||
"FAQ_Q2": "KoalaSync는 Teleparty의 무료 대체 서비스인가요?",
|
||||
"FAQ_A2": "MIT 라이선스에 따라 100% 무료이며 오픈 소스입니다. 프리미ウム 등급도, 제한된 기능도, 광고도 없습니다. 모든 것이 포함되어 있습니다. Teleparty와 달리 KoalaSync의 릴레이 서버는 가입이나 개인정보 수집 없이 RAM에서만 실행됩니다.",
|
||||
"FAQ_Q3": "KoalaSync는 Emby, Jellyfin 및 자체 호스팅 미디어 서버에서 작동하나요?",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "Alles wat u moet weten over het samen bekijken van video's in perfecte synchronisatie.",
|
||||
"FAQ_Q1": "Kan ik Netflix, Emby of Jellyfin samen in sync kijken met vrienden?",
|
||||
"FAQ_A1": "Ja! KoalaSync werkt op Netflix, YouTube, Twitch en elke website met een HTML5-videospeler — inclusief Emby, Jellyfin en zelfgehoste mediaservers. Installeer de extensie, maak een kamer, deel de uitnodigingslink en iedereen kijkt in perfecte sync.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "Streamt KoalaSync de video naar mijn vrienden?",
|
||||
"FAQ_A_VIDEO_ACCESS": "Nee. KoalaSync streamt, host of deelt geen videocontent en omzeilt geen toegang tot video’s. Iedereen kijkt de video lokaal in de eigen browser en heeft zelf toegang nodig tot de website of streamingdienst, bijvoorbeeld een eigen Netflix-account. KoalaSync synchroniseert alleen afspeelacties zoals afspelen, pauzeren en spoelen.",
|
||||
"FAQ_Q2": "Is KoalaSync een gratis alternatief voor Teleparty?",
|
||||
"FAQ_A2": "100% gratis en open-source onder de MIT-licentie. Geen premium-niveaus, geen vergrendelde functies, geen advertenties — alles is inbegrepen. In tegenstelling tot Teleparty is de relayserver van KoalaSync alleen in het RAM-geheugen met nul gegevensverzameling of gebruikersaccounts vereist.",
|
||||
"FAQ_Q3": "Werkt KoalaSync met Emby, Jellyfin en zelfgehoste mediaservers?",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "Wszystko, co musisz wiedzieć o wspólnym oglądaniu filmów w idealnej synchronizacji.",
|
||||
"FAQ_Q1": "Czy mogę oglądać Netflix, Emby lub Jellyfin w synchronizacji ze znajomymi?",
|
||||
"FAQ_A1": "Tak! KoalaSync działa na Netflix, YouTube, Twitch i każdej stronie z odtwarzaczem wideo HTML5 — w tym Emby, Jellyfin i własnych serwerach mediów. Zainstaluj rozszerzenie, utwórz pokój, udostępnij link i wszyscy oglądają w idealnej synchronizacji.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "Czy KoalaSync przesyła wideo do moich znajomych?",
|
||||
"FAQ_A_VIDEO_ACCESS": "Nie. KoalaSync nie przesyła, nie hostuje, nie udostępnia ani nie omija dostępu do treści wideo. Każda osoba ogląda wideo lokalnie we własnej przeglądarce i potrzebuje własnego dostępu do danej strony lub usługi streamingowej, na przykład własnego konta Netflix. KoalaSync synchronizuje tylko akcje odtwarzania, takie jak odtwarzanie, pauza i przewijanie.",
|
||||
"FAQ_Q2": "Czy KoalaSync to darmowa alternatywa dla Teleparty?",
|
||||
"FAQ_A2": "W 100% darmowy i o otwartym kodzie na licencji MIT. Brak planów premium, brak zablokowanych funkcji, brak reklam — wszystko jest w cenie. W przeciwieństwie do Teleparty, serwer KoalaSync działa tylko w pamięci RAM, bez zbierania danych i bez kont użytkowników.",
|
||||
"FAQ_Q3": "Czy KoalaSync działa z Emby, Jellyfin i własnymi serwerami mediów?",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "Tudo o que você precisa saber.",
|
||||
"FAQ_Q1": "Posso assistir Netflix ou YouTube em sincronia com meus amigos?",
|
||||
"FAQ_A1": "Sim! O KoalaSync funciona no Netflix, YouTube, Twitch e qualquer site com vídeo HTML5. Instale a extensão, compartilhe o link e pronto.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "O KoalaSync transmite o vídeo para meus amigos?",
|
||||
"FAQ_A_VIDEO_ACCESS": "Não. O KoalaSync não transmite, hospeda, compartilha nem contorna o acesso a conteúdos de vídeo. Cada pessoa assiste ao vídeo localmente no próprio navegador e precisa ter seu próprio acesso ao site ou serviço de streaming, como sua própria conta da Netflix. O KoalaSync sincroniza apenas ações de reprodução, como reproduzir, pausar e avançar ou voltar.",
|
||||
"FAQ_Q2": "É gratuito?",
|
||||
"FAQ_A2": "Sim, é 100% gratuito e open source. Sem recursos pagos e sem anúncios.",
|
||||
"FAQ_Q3": "Funciona com Emby ou Jellyfin?",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "Tudo o que precisa de saber.",
|
||||
"FAQ_Q1": "Posso ver a Netflix ou YouTube com os meus amigos?",
|
||||
"FAQ_A1": "Sim! O KoalaSync funciona no Netflix, YouTube, Twitch e qualquer site com vídeo HTML5. Instale a extensão, partilhe o link e está feito.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "O KoalaSync transmite o vídeo para os meus amigos?",
|
||||
"FAQ_A_VIDEO_ACCESS": "Não. O KoalaSync não transmite, aloja, partilha nem contorna o acesso a conteúdos de vídeo. Cada pessoa vê o vídeo localmente no seu próprio navegador e precisa do seu próprio acesso ao site ou serviço de streaming, por exemplo a sua própria conta Netflix. O KoalaSync apenas sincroniza ações de reprodução, como reproduzir, pausar e avançar ou recuar.",
|
||||
"FAQ_Q2": "É gratuito?",
|
||||
"FAQ_A2": "Sim, é 100% gratuito e de código aberto. Sem funções premium bloqueadas e sem publicidade.",
|
||||
"FAQ_Q3": "Funciona com Emby ou Jellyfin?",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "Всё, что нужно знать о синхронном просмотре видео с друзьями.",
|
||||
"FAQ_Q1": "Могу ли я смотреть Netflix, Emby или Jellyfin синхронно с друзьями?",
|
||||
"FAQ_A1": "Да! KoalaSync работает с Netflix, YouTube, Twitch и любым сайтом с HTML5-видеоплеером — включая Emby, Jellyfin и собственные медиасерверы. Установите расширение, создайте комнату, поделитесь ссылкой и смотрите в идеальной синхронизации.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "KoalaSync транслирует видео моим друзьям?",
|
||||
"FAQ_A_VIDEO_ACCESS": "Нет. KoalaSync не транслирует, не размещает, не передает видеоконтент и не обходит доступ к нему. Каждый участник смотрит видео локально в своем браузере и должен иметь собственный доступ к сайту или стриминговому сервису, например собственный аккаунт Netflix. KoalaSync синхронизирует только действия воспроизведения: запуск, паузу и перемотку.",
|
||||
"FAQ_Q2": "KoalaSync — бесплатная альтернатива Teleparty?",
|
||||
"FAQ_A2": "100% бесплатно и с открытым исходным кодом под лицензией MIT. Без премиум-уровней, без заблокированных функций, без рекламы. В отличие от Teleparty, сервер KoalaSync работает только в ОЗУ — без сбора данных и учётных записей.",
|
||||
"FAQ_Q3": "Работает ли KoalaSync с Emby, Jellyfin и собственными медиасерверами?",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "Videoları mükemmel bir uyum içinde birlikte izleme hakkında bilmeniz gereken her şey.",
|
||||
"FAQ_Q1": "Arkadaşlarımla Netflix, Emby veya Jellyfin'i eş zamanlı olarak izleyebilir miyim?",
|
||||
"FAQ_A1": "Evet! KoalaSync; Netflix, YouTube, Twitch ve HTML5 video oynatıcısı olan tüm web sitelerinde (Emby, Jellyfin ve kendi barındırdığınız medya sunucuları dahil) çalışır. Eklentiyi kurun, bir oda oluşturun, davet bağlantısını paylaşın; herkes mükemmel bir uyum içinde izlesin.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "KoalaSync videoyu arkadaşlarıma aktarır mı?",
|
||||
"FAQ_A_VIDEO_ACCESS": "Hayır. KoalaSync video içeriğini yayınlamaz, barındırmaz, paylaşmaz veya erişim kısıtlamalarını aşmaz. Herkes videoyu kendi tarayıcısında yerel olarak izler ve ilgili web sitesine ya da yayın hizmetine kendi erişimine, örneğin kendi Netflix hesabına, ihtiyaç duyar. KoalaSync yalnızca oynat, duraklat ve ileri geri sarma gibi oynatma eylemlerini senkronize eder.",
|
||||
"FAQ_Q2": "KoalaSync, Teleparty'ye ücretsiz bir alternatif mi?",
|
||||
"FAQ_A2": "MIT Lisansı kapsamında %100 ücretsiz ve açık kaynaklıdır. Premium katmanlar yok, kilitli özellikler yok, reklam yok — her şey dahil. Teleparty'nin aksine, KoalaSync'in aktarım sunucusu yalnızca RAM üzerindedir; sıfır veri toplama ve kullanıcı hesabı gereksinimi vardır.",
|
||||
"FAQ_Q3": "KoalaSync; Emby, Jellyfin ve kendi barındırdığınız medya sunucularıyla çalışır mı?",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "Все, що вам потрібно знати про спільний перегляд відео в ідеальній синхронізації.",
|
||||
"FAQ_Q1": "Чи можу я дивитися Netflix, Emby або Jellyfin разом із друзями?",
|
||||
"FAQ_A1": "так! KoalaSync працює на Netflix, YouTube, Twitch і будь-якому веб-сайті з відеопрогравачем HTML5, включаючи Emby, Jellyfin і медіасервери, розміщені на власному хості. Установіть розширення, створіть кімнату, поділіться посиланням для запрошення, і всі дивитимуться в повній синхронізації.",
|
||||
"FAQ_Q_VIDEO_ACCESS": "Чи транслює KoalaSync відео моїм друзям?",
|
||||
"FAQ_A_VIDEO_ACCESS": "Ні. KoalaSync не транслює, не розміщує, не поширює відеоконтент і не обходить доступ до нього. Кожен учасник дивиться відео локально у власному браузері та повинен мати власний доступ до сайту або стримінгового сервісу, наприклад власний акаунт Netflix. KoalaSync синхронізує лише дії відтворення, як-от запуск, паузу та перемотування.",
|
||||
"FAQ_Q2": "Чи є KoalaSync безкоштовною альтернативою Teleparty?",
|
||||
"FAQ_A2": "100% безкоштовне та з відкритим кодом за ліцензією MIT. Без преміум-рівнів, без заблокованих функцій, без реклами — все включено. На відміну від Teleparty, ретрансляційний сервер KoalaSync призначений лише для RAM і не вимагає збору даних або облікових записів користувачів.",
|
||||
"FAQ_Q3": "Чи працює KoalaSync із Emby, Jellyfin і власними медіа-серверами?",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"FAQ_SUBTITLE": "关于完美同步地一起观看视频所需了解的一切。",
|
||||
"FAQ_Q1": "我可以与朋友同步观看 Netflix、Emby 或 Jellyfin 吗?",
|
||||
"FAQ_A1": "是的! KoalaSync 适用于 Netflix、YouTube、Twitch 以及任何具有 HTML5 视频播放器的网站 - 包括 Emby、Jellyfin 和自托管媒体服务器。安装扩展程序、创建房间、共享邀请链接,每个人都可以完美同步观看。",
|
||||
"FAQ_Q_VIDEO_ACCESS": "KoalaSync 会把视频串流给我的朋友吗?",
|
||||
"FAQ_A_VIDEO_ACCESS": "不会。KoalaSync 不会串流、托管、共享视频内容,也不会绕过任何访问权限。每个人都在自己的浏览器中本地观看视频,并且需要自己拥有该网站或流媒体服务的访问权限,例如自己的 Netflix 账号。KoalaSync 只同步播放、暂停和拖动进度等播放操作。",
|
||||
"FAQ_Q2": "KoalaSync 是 Teleparty 的免费替代品吗?",
|
||||
"FAQ_A2": "在 MIT 许可证下 100% 免费和开源。没有高级套餐、没有锁定功能、没有广告——一切都包含在内。与 Teleparty 不同,KoalaSync 的中继服务器仅是 RAM,需要零数据收集或用户帐户。",
|
||||
"FAQ_Q3": "KoalaSync 是否可以与 Emby、Jellyfin 和自托管媒体服务器配合使用?",
|
||||
|
||||
@@ -154,6 +154,11 @@
|
||||
"name": "{{FAQ_Q1}}",
|
||||
"acceptedAnswer": { "@type": "Answer", "text": "{{FAQ_A1}}" }
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "{{FAQ_Q_VIDEO_ACCESS}}",
|
||||
"acceptedAnswer": { "@type": "Answer", "text": "{{FAQ_A_VIDEO_ACCESS}}" }
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "{{FAQ_Q2}}",
|
||||
@@ -1197,6 +1202,10 @@
|
||||
<summary><span>{{FAQ_Q1}}</span></summary>
|
||||
<p><span>{{FAQ_A1}}</span></p>
|
||||
</details>
|
||||
<details class="faq-item" data-reveal>
|
||||
<summary><span>{{FAQ_Q_VIDEO_ACCESS}}</span></summary>
|
||||
<p><span>{{FAQ_A_VIDEO_ACCESS}}</span></p>
|
||||
</details>
|
||||
<details class="faq-item" data-reveal>
|
||||
<summary><span>{{FAQ_Q2}}</span></summary>
|
||||
<p><span>{{FAQ_A2}}</span></p>
|
||||
|
||||
Reference in New Issue
Block a user