Docs rewrite: current-state docs, HANDOFF retired

README for the repo page; HOW-IT-WORKS overview; ARCHITECTURE,
PROTOCOL, DEVELOPMENT checked against the code; ROADMAP is backlog
only (isolate/dedicated server, sync fixes, rendering+shaders,
Windows build, Jellyfin epic). HANDOFF.md and mpv-http-contract.md
folded in and deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 00:42:28 +03:00
1 parent aeff087c7d
commit d8a0729029
9 files changed
+680 -687

No files matched your search

+33 -26
View File
@@ -1,35 +1,42 @@
# AGENTS.md — sync_relay_project
# AGENTS.md
Project conventions for agents. Global interaction rules live in the
user-level AGENTS.md; this file holds repo-specific facts only.
Source of truth order: codebase > `HANDOFF.md` + `docs/ROADMAP.md` >
chat history.
Repo-specific rules. Global interaction rules live in the user-level
AGENTS.md. Source of truth: the code, then the docs below.
## Docs
- [README.md](README.md): users. [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md):
plain tour. [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): packages, sync,
media, updater, decisions. [docs/PROTOCOL.md](docs/PROTOCOL.md): wire format.
[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md): build, test, release, gotchas.
[docs/ROADMAP.md](docs/ROADMAP.md): backlog and epics.
- Docs describe current truth only: no dates, no "fixed" notes, no phase
logs (git has history, plans go in ROADMAP).
- Each fact lives in exactly one file; others link to it.
- Code and its docs change in the same commit. A wire change also bumps
`RelayConstants.protoVersion` and edits PROTOCOL.md.
- User-facing wording says "server" (there is only one), never "room".
Code identifiers and wire names keep their real names until the rename
in ROADMAP item 1.
## Wishlist drafts
The user drops raw idea/bug lists as drafts (gitignored, never
committed: `docs/Wishlist.md`, `docs/drafts/`). Agent duty on sight:
translate each entry into its proper place — `docs/ROADMAP.md`
backlog (ordered, one sitting-sized item each) or Epics, real bugs
with repro detail preserved — then confirm placement with the user
and delete the draft. Drafts must never be committed, even partially.
The user drops raw idea/bug lists as drafts (gitignored, never committed:
`docs/Wishlist.md`, `docs/drafts/`). On sight, translate each entry into its
place in `docs/ROADMAP.md` (ordered backlog item or epic; keep repro detail
for real bugs), confirm placement with the user, then delete the draft.
Drafts must never be committed, even partially.
## Commits
Few commits, one per topic/session — never per fix. Rewriting
unpushed history (amend, squash) is expected, not exceptional.
Commit code + its docs together. No `flutter analyze` unless asked
or diagnosing; scoped `dart analyze` on touched files is the
pre-commit check. Tests run only when the code under test changed.
Few commits, one per topic/session, never per fix. Rewriting unpushed
history (amend, squash) is normal. Commit code + its docs together. No
`flutter analyze` unless asked or diagnosing; scoped `dart analyze` on touched
files is the pre-commit check. Run tests only when the code under test changed.
## Releases (user pushes tags, CI does the rest)
## Releases
Release flow: `tool/release.sh [patch|minor|major]` derives the
version (no typing), bumps all pubspecs, commits, tags → review →
`git push --follow-tags` → `.gitea/workflows/release.yml`
builds and publishes the Forgejo release (manifest already written
by release.sh — predictable asset URL, no CI roundtrip). `tool/release_publish.sh` is the same
flow by hand. Workflows live in `.gitea/workflows` (this forge reads
`.gitea` first, `.github` is ignored when it exists).
Never commit secrets: tokens arrive via env (`DISCORD_WEBHOOK_URL`,
`RELEASE_TOKEN`) or Actions-provided (`GITHUB_TOKEN/GITEA_TOKEN`).
`tool/release.sh [patch|minor|major]` then `git push --follow-tags`; CI
publishes. Details: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). Never commit
secrets: tokens come from env (`DISCORD_WEBHOOK_URL`, `RELEASE_TOKEN`) or
Actions (`GITHUB_TOKEN`/`GITEA_TOKEN`).
-323
View File
@@ -1,323 +0,0 @@
# HANDOFF — sync_relay_project (watch-together app)
Date: 2026-09-12 (updated late session). Phases 0–4 + presence hardening
+ UI redesigns done, all green (core 42, server 41, desktop 24).
Phases retired as a tracker 09-12 — `docs/ROADMAP.md` is now a flat
backlog (Built / Backlog / Epics / Deferred); phase text lives in
git history.
Black screen (§8) DIAGNOSED + FIXED (avatar-texture teardown race;
Visibility keep-mounted) + VERIFIED, incl. over internet. Bisect
screens (legacy/minimal/nosheet) REMOVED 09-12 — app.dart routes
session straight to SessionScreen. Internet test PASSED (WAN port
6969, 09-12); user reports 2-client LAN soak + 1.4 GB real-file
test also done 09-12 (outcomes not yet recorded below). Tonight's
fixes: server logging wired into app (`initLogging` + hello logs),
Cancel aborts pending connect, first-join failure no longer
retry-loops. Git repo INITIALIZED (commit 427d851, root
.gitignore). Prior chat may be unreliable; this file plus `docs/`
is the full state (`docs/ROADMAP.md` is now the phase tracker).
## 1. What this is
Private watch-together desktop app (Windows + Linux). Host serves a file
or shared link from their own machine; 4–8 friends watch in sync.
One binary, three modes: Client (Join), Server+Client (Host, opens
ports, self-plays via loopback), `--headless` (server only, no window).
North star: <1.5 s drift, seeks settle ~2 s, cache rides short drops,
kick kills sync+video, no relay memory growth.
## 2. Locked architecture (do not reopen without the user)
- `packages/core`: protocol, sync engine, media abstractions. ZERO Flutter
imports (enforced by test). `packages/server`: Relic-based room + media
server, also zero Flutter imports. `apps/desktop`: the ONE Flutter
binary embedding both. Direct-serve model: host opens ports, clients
open nothing. NO host-upload tunnel (the brief's WSS chunk protocol is
obsolete unless a VPS tier ever needs remote-host upload).
- Server HTTP+WS: **Relic ^1.2.0**. Client WS: **web_socket_channel**.
Server lib also uses **web_socket** for event types.
- PACKAGES ARE NOT LOCKED (user decision 09-11, overrides the old brief
list): pick on merit, no churn where the stack works. Current desktop
additions: media_kit_video + media_kit_libs_video (required),
file_picker (avatar/media picking), hive_ce + hive_ce_flutter
(connect-form persistence), image (avatar shrink). signals + media_kit
+ Relic stay (load-bearing).
- Sync: heavily Syncplay-inspired + original readiness design, NOT 1:1.
Reference clone (scratch, may be gone — re-clone shallow):
`git clone --depth 1 https://github.com/Syncplay/syncplay.git /tmp/opencode/syncplay-ref`
Audit already done; conscious deviations: clock never moves from
reports (theirs follows slowest watcher), no pause-on-leave,
force-for-all instead of operator-only setOthersReadiness, and
QUORUM gate (09-12, user found solo-play meaningless): HELD→PLAYING
needs in-set size ≥ 2 unless ≤1 member is fresh — lone watcher plays
alone, second watcher present ⇒ one Space waits. Late joiners outside
the set never block.
- Single room password (one secret, persisted in Hive on the user's own
box — accepted). No op roles (deferred: op flag + second hello
secret). Kick = transient nudge: revoke + close + `kicked` reason,
instant rejoin with fresh token.
- Deferred, do not build: TLS/Caddy, Jellyfin native, updater
expansion (v1 `--upgrade` SHIPPED 09-12 — see §9.12; in-app
prompt + Windows helper still deferred), playlist (extension shape reserved in PROTOCOL.md), op roles, sounds
(researched, wired, no assets — see §7).
- Standing rules: health reporting (every failure displays, nothing
stalls silently); proto bump + PROTOCOL.md same-commit on ANY
wire change (still v4 — quorum/timeouts are server policy, no bump).
## 3. Repo map (root: Flutter/sync_relay_project/)
- `packages/core/lib/src/`: constants.dart (tuning: seek 300 ms,
drift 5 s, slow 1.5 s @0.95, min_buffer 4 s, heartbeat 1 s, ping 5 s,
avatar 256 KiB (base64 chars; wide headroom over any 128x128 PNG
— raised 09-12 from 64 KiB, which rejected normal PNGs), name 32, chat 500; dropAfter lives as RoomEngine ctor
default, now 30 s), errors.dart, logging.dart, config.dart,
protocol.dart (v4 schemas), media_source.dart, sync_engine.dart
(RoomEngine + quorum in evaluate's HELD branch; PLAYING branch
unchanged).
- `packages/server/lib/src/`: session.dart, media_endpoint.dart,
room_hub.dart (forgetSocket, sweepClosedSockets via `isClosed` poll,
closeAllSockets, onError→forgetSocket), room_server.dart (1 Hz
heartbeat: sweepStale + sweepClosedSockets + dropSilentSockets +
broadcast; stop() closes client sockets first). `bin/server.dart`.
- `apps/desktop/lib/src/`: sync/ (sync_client.dart: hello/intents/
fan-out/match-by-`t`; room_diff.dart: diff + debounce + play gate),
playback/ (media_kit Player, 64 MB cache, pin props, 0.5 s seek
deadband, stop() on leave, SYNC_RELAY_VO/HWDEC env overrides),
session/ (session_coordinator.dart: THE brain — room→player,
reports, scrub/nudge→seek intents incl. HELD, seek-land retry,
idle 8/10 min, reconnect loop, 5 s silence timeout; health.dart),
sfx/ (SfxKind/Cue/Service + NoopSfxService — sounds deferred),
state/ (session_state.dart: route/mode/lastForm/prefs/host/join/
leave; connect_prefs.dart), screens/ (welcome/connect/session),
widgets/ (user_section.dart: ConnectProfileCard; member_avatar.dart:
THE avatar — memoized, no bg on images, dark ring+fallback;
dialogs.dart: kick confirm; media_input.dart: parseMediaInput).
- `apps/desktop/test/`: room_diff_test (9), media_input_test (6),
updater_test (7: version/manifest/layout + stub-server flows),
sync_playback_e2e_test (quorum: solo Space holds, both ready plays;
needs mpv + ~/Videos/chiyo.mp4), coord_presence_test (dead server
clears + reconnects; needs mpv).
- `tool/`: range_logger, shaper (chaos: KB/s cap, latency,
--kill-after), fake_viewer, e2e_check (sim timeline, still ALL PASS
post-quorum), package_linux.sh (release tarball + Discord card,
stamps APP_VERSION).
- `docs/`: PROTOCOL.md (v4 + quorum + 15/30 s timeouts + isClosed note,
normative), ARCHITECTURE.md (+health rule), mpv-http-contract.md.
- `deploy/`: systemd unit. Git repo initialized 09-12 (`git init`,
root `.gitignore`, initial commit 427d851 covering phases 0–4 +
tonight's connect/logging fixes). Bisection is possible again.
## 4. Protocol v4 + policy (see PROTOCOL.md for normative detail)
hello→welcome(token, memberId, mediaUrl?, room-state); intents:
ready{ready,source}/force{target:all|memberId,ready}/seek/seek-swap/
report{position,buffered,eventId}/chat/kick{memberId}. Broadcasts:
room @1 Hz + on change (diff by eventId), feed{actor,action,…}
(toasts/log/sounds), kicked{reason}, err. Media:
GET /m/<sid>/<token>/<name> (200/206×3 shapes/416/401/403/410/503).
Transport PLAYING/HELD; gate = non-empty set AND all in-set ready +
buffered≥4 s + fresh AND quorum (in-set≥2 unless ≤1 fresh).
Stale >15 s ⇒ out of gate; silent >30 s ⇒ closed + removed.
Closed sockets swept every second; socket errors remove immediately.
Client: 5 s room-silence ⇒ dead (clear room, warn, reconnect loop
1.5 s polite infinite + manual cancel/retry, prior-flag restore).
## 5. Phase log (all exits met, all green as stated above)
- P0 scaffold + CI + Flutter-free tests; binary binds/logs/exits.
- P1 Range table byte-exact, real mpv traces, shaper built. 503 tested.
- P2 sessions: secret→128-bit token, 401/403/410 gates, kick kills WS+
media, byte counters, fake_viewer ALL PASS live, proto→3.
- P3 engine + readiness: engine tests (fake clock), e2e 15/15 sim,
Syncplay audit. Sanity: NaN guards, caps, stale/leave feeds,
1 Hz flush, token redaction.
- P4 desktop shell→app: SyncClient framing, RoomDiff (eventId diff,
slowdown latch 0.95×/0.1 reset, seek-land check), media_kit wiring
(pinned buffer props, buffered from demuxer), Space=ready, avatars,
toasts+sheet, health display, reconnect loop. Sounds WIRED ONLY
(NoopSfxService) — see §7.
- P5 presence+sync hardening (user-reported, all reproduced live
first): stop() closes sockets; isClosed sweep (~1 s graceful);
onError forget (~2–4 s kill -9, was 56 s ghost); drop 60→30 s;
client silence-timeout + stuck-reconnect-flag fix + ready-future
leak fix; seek race fixed (2 s same-event debounce both
transports, edge-aware detector 1 s guard, 0.5 s apply deadband);
HELD seeks send intents (paused frame moves for all); arrows
nudge ∓10 s; local play gate (in-set+ready); autoplay off;
leave() stops player (was leaking audio); kick confirm; Cancel
connect; connect-form memory→Hive persistence; file/media
Browse buttons.
- P6 UI redesigns (two subagents, disjoint files — repeat this
pattern): full-bleed video + overlays, controls never autohide
(24 h hover) + Ready/Mark-everyone-ready IN the controls bar
(intents only, zero local transport buttons), center avatars-only
64 px, slide-in toasts, animated right sheet (users/log/chat/swap/
Leave w/ confirm), health pill only-when-unwell, ESC closes/
asks-leave, global focus-proof keys (Space/Tab/arrows/ESC/F,
350 ms throttle, ignored while typing; Tab traversal disabled on
the session screen 09-12 — game-style, Tab toggles the sheet), single media field
(parseMediaInput + tests), avatar pipeline (128 px PNG, keeps
transparency — JPEG did not; self-check measures encoded base64
against RelayConstants, single source of truth), tap to
pick, no path fields), Discord-like corner profile (connect only;
welcome is title+Host/Join; edits apply next join — no rename
intent on the wire), dialogs for terminal states.
- UX decisions locked along the way: Space flips self-ready only;
free seeks any transport (no flag churn); join catch-up never
halts; idle 8-min warn + 10-min auto-unready; reconnect = re-hello
with flag restore; kick → dialog + connect screen; fixed 1.5 s
reconnect + cancel/retry; GMod toasts (social only) + side sheet;
avatar fallback = initial circle; "waiting for X (n/m ready)".
- Open user questions for later: warning-tone timing; bar width at
minimum window; second-ESC on leave dialog (re-asks vs cancel);
cancel-stops-host-server (kept: idle means no bound port).
## 6. Gotchas (learned the hard way)
1. Relic gives Range as TYPED RangeHeader — convert via unit+first.
2. Relic server-side onDone DOES NOT fire for dead peers (proven
live both directions: clean close AND kill -9 both ghosted to
timeout). Presence = isClosed heartbeat sweep + socket-onError
forget + lastSeen timeouts. NEVER depend on onDone. (Docs pattern
confirms CloseReceived is a data event.)
3. Welcome races room broadcasts — match WS messages by `t`, never
by index.
4. web_socket_channel streams are single-subscription — one listener
per socket (test double-listens throw "already been listened to").
5. `sub.asFuture()` can resolve before `onDone` — poll with
deadlines in tests.
6. Comma-form mpv reconnect flags work here; colon-form rejected
(re-verify via set_property if touched).
7. Numeric literals corrupted once by tooling — verify with rg after
writes touching ports/consts.
8. Tool `bash` `&`-backgrounding is unreliable; use nohup + log file
in its OWN call, poll in later calls, kill via `fuser -k
PORT/tcp` (never `pkill -f`). Chain small calls, never long
`&`-chains (they hang the runner past timeout with zero output).
9. Tests must not assume threat model beyond trust-basis.
10. web_socket_channel 3.x: `channel.ready` errors unhandled on
refused connections — eat it (`unawaited(ch.ready.then(...,
onError: ...))`); stream onError is the reporting path.
11. MediaKit has NO public Player.setProperty — runtime props go via
guarded dynamic call on player.platform; cache size via public
PlayerConfiguration(bufferSize:).
12. NEVER `flutter test`-pump the session screen: media_kit Video
hangs the runner (a hung flutter_tester must be kill -9'd by PID;
pgrep carefully). Sync-path coverage lives in room_diff (pure),
lifecycle (server, raw WS), coord_presence + e2e (real mpv,
no widgets). Deleted session_overlays_test.dart for this reason.
13. No `flutter analyze` unless asked (IDE covers it) — except
deliberate per-change checks; kept clean throughout.
14. New `MediaDesktopVideoControlsThemeData` per build is fine
(stock guards subscriptions) but wasteful; leave it.
15. Env overrides for mpv experiments (no rebuild to switch):
SYNC_RELAY_VO (e.g. xv, gpu, gpu-next), SYNC_RELAY_HWDEC (e.g. no).
16. Hidden-but-mounted overlays keep focus: closing the sheet left
focus on an offstage button, so Space fell through (350 ms
throttle hole) into the hidden control. Fix pattern: ExcludeFocus
on the closed subtree + unfocus-on-close effect. Same reason new
overlay buttons reuse the shared no-focus node.
## 7. Sounds (deferred, wiring ready)
Base: Kenney Interface Sounds (CC0, confirmation_* ladder for
ascending-ready). Alt ladder: ObsydianX Pack 1 (CC0, avoid paid
packs). Soft layer: akx/Notifications (CC0 branch). Playback pick:
flutter_soloud (preload WAV, per-play pitch) — NOT added. Swap-in =
replace NoopSfxService; cues already fire with readyIndex. URLs +
mapping in 09-11 chat (unreliable — re-derive from sources above).
## 8. Black window while playing (09-12, FIXED)
Symptom was: room PLAYING ⇒ entire window black (audio + keys
alive, unready restored, zero errors). Cause: the waiting-avatars
center overlay unmounted its GPU textures (avatar Images) exactly
at the HELD→PLAYING flip; teardown racing mpv's frame uploads on
the shared GL context fails silently ⇒ black. Legacy chips never
unmounted and minimal never mounted, which is why they survived.
Fix: center overlay is Visibility(visible + maintainState +
maintainAnimation + maintainSize) — avatars stay mounted, paint
skipped when hidden. Verified by user with full tree, and holds
over internet (WAN 2-client test, port 6969, late 09-12) — CLOSED.
RULE: never unmount GPU-textured widgets on a transport-gated
branch; keep mounted + skip paint. Bisect screens
(legacy/minimal/nosheet + SYNC_RELAY_UI switch in app.dart)
DELETED 09-12 (commit tracks this); no commented bisect lines
remain in session_screen.dart.
## 9. Next (ordered, updated 09-12 late session)
1. §8 black screen — CLOSED (fix verified incl. over internet).
2. Remove bisect flags — DONE 09-12 (files deleted, app.dart
routes session directly; tree analyzes clean).
3. 2-client LAN soak — user reports DONE 09-12 (details pending).
4. 1.4 GB real-file media test — user reports DONE 09-12
(details pending; api_key must never land in files).
5.–8. MIGRATED 09-12 to `docs/ROADMAP.md` backlog (cache-exhaustion,
sounds, file-join wart, /health) — the roadmap is now the single
backlog; this section keeps session history + user-owned pendings
only.
9. FIXED 09-12: auto-unready fired while passively watching —
the 10-min check keyed off `_lastInput`, refreshed only by
explicit actions. Steady playback now counts as activity:
warn + auto-unready are skipped while room transport is
PLAYING (`session_coordinator.dart` idle block). HELD-and-idle
still unreadies after 10 min.
10. FIXED 09-12 (second attempt — first theory was wrong): one chat
send rendered as TWO log rows. Root cause is server dual
delivery, not double-send: `broadcastChat`
(`room_hub.dart:69`) emits both a `ChatMessage` (with text,
converted to a feed entry client-side) and a textless `feed`
entry, and the client logged both. The blank-guard in
`sendChat` was defense against a phantom. Real fix: client
drops textless `chat` feed entries (`sync_client.dart` feed
case) — one send, one row, one toast, one sfx. No wire
change, still v4. Chat field also keeps focus after send
(dedicated FocusNode, re-requested on submit/button).
11. KNOWN (non-breaking, fix later — roadmap backlog #2): seeks feel
jumpy (forward-back-forward) — suspect the seek-land retry
(`_checkSeekLanded`, `session_coordinator.dart:429`)
re-applying SeekAction against the 0.5 s deadband / 2 s
debounce and the 1 Hz room echo. Needs a live repro with a
timeline log before touching.
12. Updater v1 SHIPPED 09-12: `watch-together --upgrade`
(`apps/desktop/lib/src/update/updater.dart`, zero Flutter
imports, 7 tests) — manifest check, download, sha256, apply
over live install (extract-temp + delete-first move-over;
ETXTBSY forbids writing the running exe, delete+rename is
fine). Version stamped via `--dart-define=APP_VERSION`
(wired in `tool/package_linux.sh`; dev runs report
`0.0.0-dev`). Publishing is CI-driven: tag `vX.Y.Z` →
`.gitea/workflows/release.yml` builds, creates the Forgejo
release, uploads the tarball. Manifest is written locally by
`release.sh` (predictable asset URL, no CI roundtrip);
sha256 is optional — skipped with a note when absent
(HTTPS + gzip CRC cover the realistic cases).
Manual trigger only: bump desktop pubspec in a release commit
first (script enforces tag == pubspec). No silent
proto jumps: updater warns when manifest protocol != local.
Environment: dart 3.14 beta, flutter 3.48 beta, mpv+ffmpeg present,
~/Videos/chiyo.mp4 sample. Never kill the user's Syncplay mpv
instance. Git repo at project root (init 09-12); commit code + docs
together; prefer FEW commits (one per topic/session, not per fix)
over bisect-granularity — user decision 09-12.
## 10. Window chrome — frameless, mpv-style (09-12, VERIFIED by user)
Native title bar gone everywhere (`window_manager ^0.5.2`,
`TitleBarStyle.hidden` in main.dart GUI path only, headless
untouched). New screens MUST bring their own drag + min/max/close
or they ship immovable — reuse `widgets/window_buttons.dart` +
`widgets/window_chrome_bar.dart` (welcome/connect pattern).
Session: drag is `widgets/window_drag_layer.dart`, topmost raw-
pointer observer inside the controls stack (a GestureDetector
below media_kit's overlay LOSES the arena — do not revert to
that); dead zones: bottom 120 px (seek stays exact), open sheet,
leave dialog. `modifyVolumeOnScroll: false` is load-bearing for
drag — side effect: volume is speaker-button-only, no scroll-
volume. Maximize button = media_kit fullscreen toggle in
session (no OS maximize there); NO aspect-ratio lock by user
decision (custom UI lives outside the video frame). Close =
graceful leave + stopServer, then window close. Min size
160x90, no max. Verified on KDE Wayland (Arch): bar-drag,
video-drag, fullscreen, free resize all good.
+91 -21
View File
@@ -1,28 +1,98 @@
# sync_relay_project
# watch-together
Watch-together desktop app (working title). One binary, three modes:
Watch a video with friends, in sync, with the quality of a real mpv player.
Paste a Jellyfin link, any direct video link, or pick a file on your own disk;
everyone's play, pause and seek stay together.
- **Client** — GUI Join, sync WS + mpv. No server code runs.
- **Server+Client (Host)** — GUI Host starts `packages/server` in-process,
listens on a port, self-connects via loopback. Host must open ports.
Clients never open ports.
- **Headless** — `watch.exe --headless`, same server package, no window.
Why not Jellyfin SyncPlay: it runs in the browser and its sync is poor.
This is a desktop app that drives mpv.
v1 order: links support (shared http(s) media URL, Syncplay-style sync)
first, file relay (server-proxied bytes, whitelist case) after.
Jellyfin native + auto-update are deferred as their own undertakings.
Name: `watch-together` is a placeholder and the final name is undecided
(`sync-relay` is the working name). It appears in file names and the
update manifest until that is settled.
Layout:
- Desktop only (Linux now, Windows planned), always mpv. No web clients.
- No transcoding. Everyone plays the original file.
- Friends trust model: no accounts, no roles. A blank password means an open server.
```
/packages/core protocol, sync engine, messages (pure Dart, no Flutter)
/packages/server room + media HTTP endpoint (pure Dart, no Flutter)
/apps/desktop Flutter app embedding packages/server (the ONE binary)
/tool spikes + harnesses (range_logger, shaper, fake_viewer, e2e_check)
/deploy systemd unit, notes
/docs PROTOCOL.md, ARCHITECTURE.md, mpv-http-contract.md, ROADMAP.md
```
How it fits together: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md).
Phases + status: `docs/ROADMAP.md`. Session handoff: `HANDOFF.md` (root).
## Install and update
Enforcement: `core` and `server` must have zero Flutter imports (tested).
Download the latest `watch-together-<version>-linux-x64.tar.gz` from the
Releases page of this repo, extract it, run `watch_together`.
Update from the app: when a newer version exists, an update indicator
appears on Home and opens the update card. "Update and restart" downloads,
verifies, applies in place and restarts; the install folder must be writable
by you. Settings has Check now and an auto-check toggle. From a terminal:
`watch_together --upgrade` or `update.sh` (shipped in the tarball). A
release with a newer protocol is a required update: everyone on a server
must run the same protocol version, and a mismatch is refused with a message
telling you to update.
## Host from the app
Home, Host card. Pick a port, an optional password (blank = anyone can
join), and optionally a video: a link or a file path. Your name and avatar
come from your profile. The app starts a
server inside itself and joins it. Friends need to reach your machine: open
the TCP port on your router (or use a LAN or VPN address). Friends open nothing.
If you pick a local file, the server streams it to everyone from your disk.
Nothing is uploaded or stored anywhere.
## Dedicated server
Being reworked: today the server is started by the app (host) and the
standalone server is a dev tool. The planned dedicated server is a separate
binary built from `packages/server`. See [docs/ROADMAP.md](docs/ROADMAP.md).
## Join
Home, Join card. Enter the host's address, port, and the password if there
is one. Your name and avatar come from your profile (Home chip or Settings).
Servers you got into appear as up to 6 Recent chips on Home: one click rejoins.
## Settings
Profile, hardware decoding (auto, off or custom; the env var
`SYNC_RELAY_HWDEC` overrides it; applies on the next join or Reload video),
updates, and the keyboard shortcuts (read-only). Integrations (Jellyfin) and
shaders show "coming soon". About shows the version.
Keys in a session: Space = ready / unready, Tab = side panel (people, log,
chat, change video), Left/Right = seek 10 s, F = fullscreen, Esc = close
panel or ask to leave, Ctrl+R = reload video.
## Ports
| What | Default | Notes |
|---|---|---|
| Sync WebSocket and `/health` | TCP 8080 | plain `ws://`, no TLS |
| Local file streaming | same port | path `/m/...`, needs a session token |
Only the host needs the port reachable.
## Jellyfin
Pasting a Jellyfin direct-play link works today like any link. Proper
Jellyfin support (per-person login, watched status, library browsing) is
planned: see [docs/ROADMAP.md](docs/ROADMAP.md).
## Video quality and shaders
Planned: Anime4K and other mpv shaders as a per-viewer setting. See
[docs/ROADMAP.md](docs/ROADMAP.md).
## Troubleshooting
- "host offline - check address and port": wrong address, or the port is not open.
- "wrong server password": password mismatch.
- "host runs a different version": update the app on both sides.
- Picture frozen but sound fine: Ctrl+R reloads the video; the app also does it automatically.
- Everyone waits at "waiting for X": X has not buffered 4 seconds yet or has not pressed Space.
- No Windows build yet.
For contributors: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md),
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), [docs/PROTOCOL.md](docs/PROTOCOL.md).
+216 -68
View File
@@ -1,84 +1,232 @@
# ARCHITECTURE
# Architecture
One binary, three modes. `packages/server` (Relic) is embedded, not a separate product.
Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
[PROTOCOL.md](PROTOCOL.md). Plans: [ROADMAP.md](ROADMAP.md).
- **Client**: GUI Join, sync WS client (`web_socket_channel` in
`apps/desktop`, NOT `packages/core` — core stays Flutter-free)
+ mpv. No server code runs.
- **Server+Client (Host)**: GUI Host starts `packages/server` in-process, binds port, self-connects via loopback. Host opens ports, runs traffic. Clients open nothing outbound-only.
- **Headless**: `--headless`, same server package, no window/player init.
## Packages
Server side: **Relic** (HTTP + built-in WS). Client side WS: **`web_socket_channel`**
(Relic has no client; server WS handling stays in Relic).
| Path | What | Rule |
|---|---|---|
| `packages/core` | protocol messages, sync engine (`RoomEngine`), constants, config, Range parsing, logging | pure Dart, no Flutter |
| `packages/server` | Relic HTTP + WebSocket server (`RoomServer`, `RoomHub`, `Session`, media endpoint), `bin/server.dart` | pure Dart, no Flutter |
| `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), updater | the only shipped binary |
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
| `deploy` | systemd unit for the standalone server (stale until the dedicated server exists) | |
Media order (locked): links first (shared http(s) URL, Syncplay-style sync),
file relay after (server-proxied bytes for the whitelist case).
Guard: `packages/core/test/flutter_free_test.dart` fails if anything under
`core/lib` imports Flutter, `dart:ui` or `signals_flutter`; CI runs
analyze + tests for core and server on every push. Server has no such test but
the same rule; it is checked by CI building without Flutter. The client
WebSocket (`web_socket_channel`) lives in `apps/desktop`; Relic has no client.
## Media paths (links vs file relay)
## Server lifecycle
Two ways to watch, same sync engine, same Range endpoint:
Today (in-process): `SessionState.host()` creates a `RoomServer`, starts it
on the chosen port (bound to all IPv4 interfaces), then the app joins it over
`127.0.0.1` like any other client. Leaving or closing the window stops it.
`RoomServer` runs a 1 s heartbeat: stale sweep, closed-socket sweep, silent
socket drop, room + feed broadcast. There is one session, id `default`.
1. **Links (primary, shipped):** host shares an http(s) media URL
(e.g. a Jellyfin Download URL). Viewers stream it directly; the
server is sync-authority only. The URL travels as `media.url`
in room broadcasts and as welcome `mediaUrl` (set to the shared
URL when the server holds no file). This
covers most sessions — proxying is usually unnecessary.
2. **File relay (secondary, local-file half shipped):** host serves
bytes from their own machine through the server's token-gated
Range endpoint `GET /m/<sid>/<token>/<name>`. Viewers (and the
host via loopback) play the server URL. This is the fallback for
the whitelist case: viewers cannot reach the source, but the
server/host can (e.g. Jellyfin allowlists the host, not the
viewers). No server storage — bytes stream file→socket, config +
logs only, never to disk.
`--headless` does not work: the Flutter binary needs a display and exits with
"cannot open display" on a headless box. `packages/server/bin/server.dart` is
the only working standalone server (flags `--port`, `--secret`, `--name`,
`--media`, `--advertise-host`, `--config`; default port 8080).
Backends behind `MediaSource` (`packages/core/lib/src/media_source.dart`):
`FileMediaSource` (local file — SHIPPED as an abstraction, but the
live route serves `SessionMedia.file` directly via `file.openRead`;
nothing in `packages/` references `FileMediaSource` today);
`HttpSource` (server fetches a remote URL and re-serves it —
DEFERRED, Phase 8). `resolveMediaRequest` stays
framework-agnostic so both ride the same tested Range table.
Planned: hosting from the app runs the server on a Dart isolate (ports and
sockets stay in the same process, UI stays responsive); the dedicated server
is a separate `dart compile exe` of `packages/server`. See [ROADMAP.md](ROADMAP.md).
## Deferred: remote-host upload tunnel (NOT active protocol)
Passwords: the default secret is empty, which means an open server (a
warning is logged). Otherwise one shared secret, compared at hello.
The host is the member that connected from a loopback address; it cannot be
kicked. Kick = revoke token, send `kicked`, close; the
person may rejoin at once with a new token.
If a VPS tier ever needs a *remote* host to push bytes to a server
it cannot serve from disk, the reserved design is the brief's WSS
tunnel: host dials out, JSON control frames
(`hello`/`welcome`+mediaUrl, `open`{id,start,end}, `ack`, `cancel`,
`eof`/`err`) plus binary `[u32 BE reqId][payload]` chunks —
chunk 256 KiB, credit window 8 (≈2 MiB in flight), max 16 concurrent
`open`, relay acks only after socket flush (real backpressure).
`RelayConstants.chunkBytes/creditWindowChunks/maxConcurrentOpen`
are reserved for this. No code exists; direct-serve covers v1
(host IS the server, opens ports itself).
## Sync model
On file relay, host playback goes through loopback like everyone
else's (`http://127.0.0.1:<port>/…`) — same code path as remote
viewers, so server bugs surface on the host screen first. (On
links the host plays the direct URL.) Deployment ladder,
cheapest first: host's own PC (LAN, or internet with port-forward) →
same binary `--headless` on a friend's always-on box → cheap VPS
(CGNAT escape; TLS/Caddy deferred with this tier).
Loosely modeled on Syncplay, with a different readiness design.
Sync: heavily Syncplay-inspired + original readiness design, NOT 1:1
(audit done; deviations: clock never moves from reports, no
pause-on-leave, force-for-all, quorum gate — see HANDOFF §2).
Additive namespaced extensions only, `proto` bump + `PROTOCOL.md` same-commit.
- **Transport** is `playing` or `held`. The server owns the clock:
`positionAt(now) = position + (playing ? now - serverTime : 0)`.
Reports from clients never move it. Only seek, swap and gate transitions
write `position`/`serverTime` and bump `eventId`.
- **Reports**: clients send `position`, `buffered`, `eventId` every 1 s. The
server stores `buffered` and liveness; a report with `eventId` older than
current has its position ignored.
- **Ready gate** (HELD to PLAYING): the in-set is non-empty, every in-set
member is ready, fresh and has `buffered >= 4 s` (`minBufferSecs`), and
quorum holds: in-set size >= 2, unless at most one member is fresh. So a
lone viewer plays alone; with a second viewer present one Space waits for the
other. Joining members start with `inSet=false, ready=false` and never block.
Disconnect removes from the set; swap clears everyone.
- **Pause**: while PLAYING, any in-set member who becomes not ready, under
4 s buffered or stale flips the server to HELD (feed action `pause` by `server`).
Recovery resumes by itself when the gate passes.
- **Seek**: allowed from any member in any transport; the server writes the
position and bumps `eventId`; flags are untouched. Clients diff each `room`
broadcast by `eventId` (`RoomDiff`): new event means seek to the computed
room position, transport change means play/pause. Between events: drift
over 5 s for more than 2 s seeks, ahead by more than 1.5 s sets rate 0.95
until level, otherwise nothing. Local seeks are detected by the coordinator
polling mpv position once per second (`_detectUserSeek`, jump over 2 s and
more than 2 s from the room) and sent as a `seek` intent; a retry
(`_checkSeekLanded`) re-applies a room seek that has not landed after 1.5 s.
Known roughness: see ROADMAP.
- **Liveness**: WS ping every 5 s; silent over 15 s means stale (out of the
gate, `stale` feed); over 30 s the socket is closed and the member removed.
Closed sockets are polled with `isClosed` every second: Relic's server-side
`onDone` does not fire for dead peers, so presence never relies on it.
- **Client policies**: no room broadcast for 5 s means connection lost, clear
the server state and reconnect every 1.5 s until cancelled (an epoch counter makes
a cancelled attempt stale) or kicked. After a reconnect the ready flag is
restored from the server's state. Cancel during setup aborts the join.
Closing the window leaves gracefully (2 s timeout).
- **Idle** (`IdleTracker`): measured from the later of the last input and the
moment playback stopped. While HELD and self ready: warning at 8 min,
auto-unready at 10 min, and never less than 2 min after the warning.
- **Deviations from Syncplay**: clock never follows the slowest watcher; no
pause-on-leave; anyone can force ready/unready (all or one); quorum gate;
the clock is server time, and remote clock offset is not corrected (clients
assume their clock matches the server's).
No server storage: config + logs only. Media never touches disk.
Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit.
## Health reporting (standing rule)
## Media
Every failure must display. Nothing stalls silently.
Two sources, same sync.
- Server `/health` returns machine-readable status: ok, room, viewer
count, uptime, media, bytes served, version. The app reads it.
- App surfaces all failure states in UI with cause + what-to-do:
connecting/reconnecting, wrong secret (401), kicked (403), session
gone (410), host offline / buffering (503), starvation
("waiting for X — N.N s buffered"). Backed by a structured status
signal (severity + message), not bare strings.
- Errors log via `debugPrint` + stack trace at the boundary, then
convert to a displayable state. No swallowed exceptions.
- **Link**: the media field holds an http(s) URL. It travels as `media.url`
in `swap` and `room`, and as `mediaUrl` in `welcome`. Each app plays it
directly; the server is only the clock.
- **Host local file**: `RoomServer.setLocalFile` / `mediaPath` makes the file
the session media. `GET /m/<sid>/<token>/<name>` serves it with a Range
table, gated by the viewer's token. The server builds the URL per viewer
(`http://<host>:<port>/m/default/<token>/<name>`), so tokens never reach
other viewers. `<host>` is the authority the client dialled; `advertiseHost`
(default empty) only overrides it when set explicitly. When a `room`
broadcast has no `media.url`, the client builds the URL itself from the host
it connected to. Bytes stream file to socket and are counted as streamed
(`bytesServed`); nothing is stored. The host plays through loopback like
everyone else.
Range table (`resolveMediaRequest`, tested byte-exact):
| Request | Response |
|---|---|
| no Range | 200, `Content-Length` = size |
| `bytes=a-b` | 206, `Content-Range: bytes a-b/size` |
| `bytes=a-` | 206 to EOF |
| `bytes=-n` | 206 last n bytes |
| unsatisfiable | 416, `Content-Range: bytes */size` |
| host has no file | 503 + `Retry-After: 2` |
| bad / unknown token, kicked, unknown session | 401, 403, 410 |
Always `Accept-Ranges: bytes`; `Content-Type` from the extension
(`contentTypeForFile`). Relic hands the Range over as a typed `RangeHeader`;
the endpoint takes the first range and keeps the unit, so non-`bytes` units
still get 416.
What mpv does against it (recorded with `tool/range_logger.dart`): mp4 opens
once with no Range then streams `bytes=0-` to the end; mkv opens `bytes=0-`
twice (probe, playback), reads a small tail at `size-n` (cues, expressed
open-ended), then every seek is a fresh `bytes=N-` connection. A server
outage of a few seconds is ridden out from mpv's cache with no error. Longer
outages exhaust the cache and rely on reconnect (not chaos-tested yet).
mpv settings (`PlaybackController`): `Player` with `bufferSize` 64 MiB,
`libass` on; pinned via runtime property: `demuxer-readahead-secs=10`,
`cache-secs=10`, `stream-lavf-o=reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4`
(comma form; this mpv build rejects colon-joined). media_kit has no public
`setProperty`; the code calls it through a guarded dynamic call on
`player.platform`. Hardware decoding comes from Settings (auto, off, custom)
and is passed to the `VideoController`; env `SYNC_RELAY_HWDEC` (e.g. `no`)
overrides it. It is read per player, so a change applies on the next join or
Reload video.
Player lifecycle: the `Player` and its `VideoController` live for one
session: created on join, disposed on leave, replaced by `recreate()` for
"Reload video" (button or Ctrl+R; volume kept). `openUrl` opens with `play: false`; play/pause/seek/rate come only from
`RoomDiff` actions or the play gate (`localPlayAllowed`: self in-set and
ready). A room seek within 0.5 s of the current position is skipped. `leave()`
stops the player (otherwise audio keeps playing). On media change
(`mediaId` bump) the coordinator opens the new URL and `RoomDiff` seeks to 0,
holds and resets rate.
Frozen picture: `FrameWatchdog` samples about once a second. It hashes a
small capture of the compositor's centre (`ShownFrameProbe`) and suspects a
freeze when position advances while that hash stays constant for 3 s. Two
differing mpv `screenshot-raw` frames show the decoder is still producing
pictures, which confirms the freeze (a still scene is dismissed). A confirmed
freeze reloads the video with a toast. It fires once until the picture moves
again for 10 s, so it cannot loop. Seeks, pause, buffering and a hidden window
disarm it.
Never unmount GPU-textured widgets (avatar images) on a transport-gated
branch: teardown races mpv's frame upload and the window goes black. Keep them
mounted and skip painting (`Visibility` with `maintainState`).
## Desktop UI
One `HomeScreen` (`screens/home/`) with a nav rail: Home (profile chip, Join
card, Host card, up to 6 Recent server chips for one-click rejoin), Settings
(profile, hardware decoding, updates, shortcuts, shaders "coming soon"),
Integrations (Jellyfin "coming soon") and About. The update indicator sits on
Home. Settings persist in two Hive boxes: `connect_form` (last form and
recents; saved even when a join fails) and `settings`. Link media names are
derived from the URL path and never contain a query string.
Window chrome is frameless (`window_manager`): every screen brings its own
drag area and min/max/close buttons (`widgets/window_chrome_bar.dart`,
`window_buttons.dart`); the session drag layer is `window_drag_layer.dart`.
## Health reporting
Every failure must show in the UI with a cause and a next step; nothing
stalls silently. The app has a `HealthStatus` signal (severity + message)
for connecting, reconnecting, wrong password, version mismatch, kicked,
host offline, and starvation ("waiting for X - N.N s buffered").
Errors are logged with a stack trace at the boundary, then turned into a
displayable state. The server's `GET /health` returns JSON (`ok`, `room`,
`proto`, `viewers`, `bytesServed`, `uptimeSecs`); it is unauthenticated and
the app does not read it.
## Updater
`apps/desktop/lib/src/update/updater.dart` (no Flutter imports, unit tested).
Reads a manifest (`version`, `protocol`, `url`, optional `sha256`, `notes`)
from `updateManifestUrl`, compares versions, downloads the tarball, checks
sha256 when present, then extracts into a staging dir inside the install
folder (same filesystem, so moves are renames; the folder must be
user-writable). Each top-level entry is moved aside and the new one moved in,
with rollback on failure (writing the running executable fails with ETXTBSY;
rename works). A packaged install is detected by `data/` and `lib/` next to
the binary; anything else exits with "update manually". A manifest whose
protocol is newer than the local one is a required update. Windows apply
(`*.old` rename, swept on next start) exists but is untested. The version is
stamped with `--dart-define=APP_VERSION` (dev runs report `0.0.0-dev` and skip
checks). `watch_together --upgrade` (or the shipped `update.sh`) runs it from
the CLI; `UpdateController` runs the same code for Home's update indicator,
which opens the update card, and restarts the app ("Update and restart").
Settings has Check now and an auto-check toggle. The manifest is `updates/manifest.json` in
this repo, written by `tool/release.sh`.
## Decisions
- Desktop + mpv only, no web clients: exists because Jellyfin SyncPlay is bad.
- No transcoding: bandwidth is plentiful and the Jellyfin box should not be loaded.
- No roles, friends trust model: simplest thing that fits the use.
- Blank password = open server: the owner chooses between open and private.
- The host cannot be kicked: the server dies with the host anyway.
- Server ships inside the app (and, planned, as a separate compiled server):
a Flutter binary cannot run headless, so a dedicated server needs its own
build of `packages/server`.
- Server owns the clock; reports never move it: avoids ping-pong between clients.
- Quorum gate: a lone viewer must be able to watch; two or more must start together.
- Relic for the server, `web_socket_channel` for the client; packages are
chosen on merit, not locked.
- Plain `ws://`, no TLS: friends use LAN, VPN or a forwarded port; TLS
(e.g. a reverse proxy) is a documentation item.
- One session per server, id `default`: the map is keyed for later but unused.
- No server storage: config and logs only; media never touches the server disk.
- Avatars ride in the 1 Hz `room` broadcast (cap 256 KiB base64 per avatar):
simple, costs host uplink; hash-based serving is on the roadmap.
+102
View File
@@ -0,0 +1,102 @@
# Development
Layout and rules: [ARCHITECTURE.md](ARCHITECTURE.md).
## Build
Needs the Flutter SDK (beta channel, Dart 3.13+) and, for the Linux desktop
build, the usual GTK toolchain (clang, cmake, ninja, pkg-config, libgtk-3-dev).
mpv libraries come from `media_kit_libs_video`.
```
cd apps/desktop && flutter pub get && flutter run -d linux
cd apps/desktop && flutter build linux --release --dart-define=APP_VERSION=<x.y.z>
tool/package_linux.sh [--skip-build] [--noupload] # dist/*.tar.gz
```
`APP_VERSION` defaults to `0.0.0-dev`; `package_linux.sh` stamps it from the
desktop pubspec. Setting `DISCORD_WEBHOOK_URL` makes the packager post the
tarball to Discord (never commit it).
## Dev loop
Run a server without the app, then point apps at it:
```
dart run packages/server/bin/server.dart --port 8080 --secret x [--media FILE]
```
Prints `READY port=... room=...` and stops on SIGINT. Then Join from one or
more apps at `127.0.0.1:8080`. Or just Host from the app. mpv env override:
`SYNC_RELAY_HWDEC` (wins over the Settings choice). `SYNC_RELAY_VO` has no
effect with embedded video: media_kit forces `vo=libmpv`.
Harnesses in `tool/` (own `pubspec.yaml`, `dart run tool/<name>.dart`):
`range_logger` (serve a file and log every Range request), `shaper`
(bandwidth cap, latency, `--kill-after` for outage tests), `fake_viewer`
and `e2e_check` (simulated viewers against a live server).
## Tests
- `cd packages/core && dart test`, `cd packages/server && dart test`.
- `cd apps/desktop && flutter test test/<file>`.
- `sync_playback_e2e_test.dart` and `coord_presence_test.dart` use real mpv;
the e2e one also expects `~/Videos/chiyo.mp4`. Run only tests related to
what you changed.
- CI (`.gitea/workflows/ci.yml`): analyze + test for core and server only.
Desktop tests are not in CI.
Traps:
- Never pump the session screen or media_kit `Video` in `flutter test`: the
runner hangs (kill the `flutter_tester` by PID). Test pure logic
(`RoomDiff`, coordinator without widgets) instead.
- Never depend on a server-side WebSocket `onDone`; assert presence with
deadline polling.
- `sub.asFuture()` can resolve before `onDone`; poll with a deadline.
- Tests must not assume a threat model beyond the friends trust basis.
## Releasing
User pushes tags; CI does the rest.
1. `tool/release.sh [patch|minor|major] [--dry-run]`: derives the next
version from the latest tag, bumps the three pubspecs in lockstep, writes
`updates/manifest.json` (asset URL is predictable), commits `Release vX.Y.Z`,
makes an annotated tag.
2. Review, then `git push --follow-tags`.
3. `.gitea/workflows/release.yml` builds the Linux tarball and runs
`tool/release_publish.sh vX.Y.Z`, which creates the release and uploads it.
By hand: `tool/package_linux.sh` then `tool/release_publish.sh vX.Y.Z`.
Tokens come from the environment (`RELEASE_TOKEN`, or Actions-provided
`GITHUB_TOKEN`/`GITEA_TOKEN`). The manifest has no `sha256` by default; the
updater skips the check when absent. Workflows live in `.gitea/workflows`
(the forge reads `.gitea` before `.github`).
## Gotchas
- Relic gives Range as a typed `RangeHeader`; convert from unit + first range.
- Relic server-side `onDone` does not fire for dead peers. Presence is the
`isClosed` sweep + `onError` forget + `lastSeen` timeouts.
- `web_socket_channel` 3.x: `channel.ready` errors are unhandled on refused
connections; swallow them and report through the stream's `onError`.
Streams are single-subscription: one listener per socket.
- media_kit has no public `Player.setProperty`; the code reaches mpv through a
dynamic call on `player.platform`. Cache size goes through
`PlayerConfiguration(bufferSize:)`.
- mpv reconnect flags: comma form works, colon form is rejected. Re-verify
through `setProperty` if touched.
- Overlays that are hidden but mounted keep focus (Space falls into a hidden
button). Use `ExcludeFocus` on closed subtrees and unfocus on close; reuse
the shared no-focus node for overlay buttons.
- `modifyVolumeOnScroll: false` on the session controls is load-bearing for
window drag (volume is the speaker button only). A `GestureDetector` below
media_kit's overlay loses the gesture arena; the drag layer is a raw pointer
observer on top.
- New screens must bring their own drag area and window buttons (frameless
window).
- Chat arrives twice from the server (see PROTOCOL.md); the client drops the
textless feed copy.
- Tab traversal is disabled on the session screen on purpose; Tab toggles the
side panel.
- After editing constants (ports, thresholds), grep to confirm the value landed.
+76
View File
@@ -0,0 +1,76 @@
# How it works
A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
[PROTOCOL.md](PROTOCOL.md).
## The pieces
```
your PC friend's PC
+-------------------+ +-------------------+
| app (mpv inside) | | app (mpv inside) |
+---------+---------+ +---------+---------+
| sync (WebSocket) | sync (WebSocket)
v v
+----------------------------------------+
| server (who is ready, where are we) |
+----------------------------------------+
video bytes come from somewhere else:
- a link (Jellyfin, any http/https URL): each app fetches it directly
- a file on the host's disk: the server streams it (/m/... endpoint)
```
- **App**: the desktop window. It draws the video with mpv, shows who is
here, and sends what you do (ready, seek, chat).
- **Server**: keeps one shared clock and the list of people. It never sees
video frames, only "position 123 s, playing". There is exactly one server
and one watch session on it.
- **Jellyfin** (or any web host): where a link's video lives. The server does
not touch it today; each app downloads it itself.
## Ways to run
- **Host from the app**: the app you click Host in also runs the server.
Simplest. The server lives as long as that app.
- **Dedicated server**: a separate server program on an always-on box.
Planned, see [ROADMAP.md](ROADMAP.md).
Friends join either way with the Join card on Home.
## One session, start to finish
1. **Connect.** The app opens a WebSocket to the server and says hello
(version, password, name, avatar). Wrong password or version: refused with
a reason. Otherwise the server answers with a welcome and the current state.
2. **Catch up.** The app opens the current video, jumps to where the server
says we are, and buffers. A new arrival is not ready yet and never holds
anyone back.
3. **Ready gate.** Press Space to say you are ready. The server starts
playback only when everyone who is ready has at least 4 seconds buffered.
If two or more people are ready, one person alone cannot start it for the
others. A lone viewer can play alone.
4. **Play.** The server owns the clock. Each app compares its mpv position
to the clock: a small lead slows mpv a little (0.95x), a big gap (over 5 s)
jumps. If someone's buffer runs dry the server pauses everyone and resumes
when they have caught up.
5. **Seek.** Anyone can seek, anytime. The server moves the clock and
everybody follows. Seeking does not change anyone's ready flag.
6. **Swap.** Anyone can change the video by pasting another link. Everybody
stops at 0, ready flags clear, and the gate runs again.
7. **Leave.** Closing or leaving drops you from the server; the others keep
going. If you vanish without saying goodbye, you are marked stale after 15 s
(stops blocking) and removed after 30 s. The app reconnects by itself and
restores your ready flag.
There are no roles: anyone can seek, swap, force-ready others and kick
anyone except the host.
## Where video comes from
- **Link**: you paste an http(s) URL. Every app plays it directly with mpv.
Works for a Jellyfin direct-download link or any file on the web.
- **Local file (host only)**: you pick a file on your disk. The server
streams it to everyone, host included, over HTTP with range requests, each
with a per-person token. Nothing is stored or converted.
- No transcoding, ever. Quality is whatever the file is.
+75 -102
View File
@@ -1,120 +1,93 @@
# PROTOCOL — v4 (Phase 3: sync engine + readiness)
# Protocol (proto = 4)
`proto = 4`. Bump history: v1 hello/state/event shapes; v2 `token` in
welcome; v3 `kicked`/`kick`; v4 readiness intents, room fan-out, feed,
public member ids (tokens never leave their owner — media URLs carry
only the requester's own token).
Wire format only. Behavior (gate, clock, timeouts) is in
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
`packages/core/lib/src/protocol.dart` and `packages/server/lib/src/room_hub.dart`.
## Transport vs readiness (the core split)
Any wire change bumps `RelayConstants.protoVersion` and edits this file in
the same commit. Changes are additive.
Room transport is PLAYING or HELD. Seeks move transport position only:
while PLAYING anyone seeks freely, everyone follows, catch-up buffering
is tolerated with no flag changes. Readiness gates only the
HELD→PLAYING transition. Starvation pauses transport without touching
flags; recovery auto-resumes on buffers, no Space needed.
## Transport
## Message catalog
- WebSocket at `/sync`, JSON text frames, each an object with a `t` field.
- The first client frame must be `hello` within 10 s. Server pings every 5 s.
- Match incoming frames by `t`, never by order: `welcome` can race `room`.
- Failures: server sends `{"t":"err","message":...}` and closes.
Messages: `bad hello: ...`, `proto mismatch: got X, want Y`, `wrong secret`,
`rejected: <reason>` (e.g. avatar too large), `bad message: ...` (a frame
with a non-finite number, e.g. NaN position), `bad swap media`,
`swap needs a url or the hosted file` (empty swap).
- `GET /health` returns JSON (`ok`, `room`, `proto`, `viewers`, `bytesServed`,
`uptimeSecs`; unauthenticated). `GET /m/<sid>/<token>/<name>`
serves the host's file (Range table in ARCHITECTURE.md).
Client→server intents (all post-hello text frames):
## Client to server
```
{"t":"ready","ready":true,"source":"manual|auto"} Space flip / idle timer
{"t":"force","target":"all|<memberId>","ready":false}
{"t":"seek","position":123.4} doSeek event, room jumps
{"t":"swap","media":{"name":..,"size":..,"duration":..,"url":..}}
{"t":"hello","proto":4,"secret":"","user":{"name":"","avatar":""},
"media":{"name":"","size":0,"duration":0,"url":null}}
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
{"t":"force","target":"all"|"<memberId>","ready":true}
{"t":"seek","position":123.4}
{"t":"swap","media":{"name":"","size":0,"duration":0,"url":"..."}}
{"t":"report","position":123.1,"buffered":6.2,"eventId":41}
{"t":"chat","text":"…"}
{"t":"kick","target":"<memberId>","reason":"…"} v3, now by member id
{"t":"chat","text":"..."}
{"t":"kick","target":"<memberId>","reason":""}
```
Server→client:
Field notes:
- `hello.secret`: the server password; a server with an empty secret is open.
The member connecting from a loopback address is the host.
`hello.media` is carried but not used by the server today.
- `user.avatar`: base64 image, at most 256 KiB of characters. `name` is
trimmed, empty becomes `?`, cut to 32 characters.
- `chat.text` is cut to 500 characters.
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
- Frames before `welcome` (other than `hello`) are ignored; unparsable or
unknown frames are ignored silently.
## Server to client
```
{"t":"room",{eventId,transport:"playing"|"held",position,serverTime,
mediaId,media:{…}|null,
members:[{id,name,avatar,ready,inSet,buffered,stale}]}}
{"t":"feed",{actor,action,target?,reason?,at}} toasts + sheet log
{"t":"welcome",{token,memberId,mediaUrl?,state}} v2+4, state now a room
{"t":"kicked",{reason}} {"t":"err",{message}}
{"t":"welcome","token":"...","memberId":"m1","mediaUrl":"...","state":{<room>}}
{"t":"room","eventId":7,"transport":"playing"|"held","position":12.3,
"serverTime":"<ISO-8601 UTC>","mediaId":1,
"media":{name,size,duration,url?}|absent,
"members":[{"id","name","avatar","ready","inSet","buffered","stale"}]}
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
{"t":"chat","text":"...","from":"<name>"}
{"t":"kicked","reason":""}
{"t":"err","message":""}
```
`room` broadcasts at 1 Hz and on every change. Clients diff by
`eventId`: position jump ⇒ seek, transport change ⇒ play/pause.
`feed` actions: ready, unready, force-ready, force-unready, force-play,
force-hold, kick, join, leave, stale, swap, seek, pause, resume, chat —
every one carries actor + server timestamp.
Force-play-all is `force` with `target:"all",ready:true`; anyone not
ready Spaces back. Sounds key off feed actions.
Chat dual delivery: the server emits both `{"t":"chat","text","from"}`
(render this one — it carries the text) and a textless `feed` chat
entry (housekeeping echo). Clients must render exactly one row per
send; the reference client drops the textless feed entry.
- `welcome.token` is private to its owner and appears only in that member's
media URL. `mediaUrl` is omitted when there is no media; it is a built
`/m/...` URL for a host file (host part = what the client dialled), otherwise
the shared link.
`welcome.state` has the same shape as `room`.
- `room` is sent every second and on every change. `position` is the value at
`serverTime`; clients extrapolate while `transport` is `playing`.
`eventId` increases on seek, swap and transport changes.
`mediaId` increases on swap.
- `feed.action` values: `ready`, `unready`, `force-ready`, `force-unready`,
`force-play`, `force-hold`, `kick`, `join`, `leave`, `stale`, `swap`,
`seek`, `pause`, `resume`, `chat`. `target` and `reason` are optional;
`reason` carries `auto`/`manual` for ready and `silent too long` or
`no signal` for leave/stale.
- Chat is delivered twice: a `chat` frame with the text and a textless
`feed` with `action:"chat"`. Render the `chat` frame only; the reference
client drops the textless feed entry.
## Membership set (kills join-cascade + ghost-quorum)
## Join and reconnect
- Join: `inSet=false, ready=false`. Catch up in background (open media,
seek to room edge, buffer). Flipping ready joins the set.
- Unready while in set: stays in set, room re-evaluates → HELD.
- Disconnect: removed from set, never counts toward quorum.
- Swap: all sets cleared, all flags false.
- Gate to PLAYING: set non-empty AND every in-set member
ready + buffered ≥ 4 s + fresh (not stale) AND quorum: in-set
size ≥ 2, unless no more than one member is fresh. A lone
watcher can play alone; the moment a second watcher is present,
one Space waits for the other. Empty set ⇒ HELD.
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the room
position and buffers. A joining member is `ready=false, inSet=false` until
the first `ready`. A socket that dies loses its token; reconnect is a fresh
`hello`, after which the client re-sends its prior ready flag.
## Clock rules (Syncplay port)
## Reserved, not implemented
Server owns the advancing clock:
`positionAt(now) = position + (playing ? now-serverTime : 0)`.
Plain reports are informational and can never move it. Only seek, swap,
and gate transitions write position/serverTime/eventId. Reports tagged
with `eventId < current` have their position ignored (no ping-pong);
buffered is still accepted. Pause (HELD) freezes position immediately;
resume sets serverTime=now — laggards seek forward, never drag back.
Tiered client correction on room state: |drift| > 5 s ⇒ seek;
ahead > 1.5 s ⇒ 0.95× until level; inside deadzone ⇒ nothing.
## Join state machine
connect → hello → welcome → open media (server mediaUrl or shared
url) → seek to room edge → buffer → not-ready catch-up → Space →
in set → HELD→PLAYING when gate passes. Reconnect: re-hello (tokens
die with the socket), prior flag restored, buffer gate holds playback
until truly caught up.
## Swap state machine
anyone sends swap → HELD, mediaId++, clock reset (0/now), sets
cleared, flags false → all clients re-run Join → auto-resume when the
gate passes. No authority check in v1 (same trust basis as force).
## Join / auth (v2–v3, unchanged)
proto must equal server proto; secret is the room password; avatar
≤64 KiB; failures get `err` + close. `sid="default"`, map keyed for
later multi-session. `mediaUrl` carries the playable URL: a built
`/m/…` URL when the server holds bytes, otherwise the shared link.
## Media endpoint (Phase 1, unchanged)
`GET /m/<sid>/<token>/<name>` Range table. 410/401/403/503 gates.
## Heartbeat (Phase 2 baseline, feeds the gate)
5 s WS pings; per-token lastSeen on every frame; silent >15 s ⇒ out
of the gate (`stale` feed); silent >30 s ⇒ socket closed, member
removed (`leave`), client reconnects via re-hello. No limbo sockets.
Closed sockets are swept every second (`isClosed` poll — transport
`onDone` is unreliable, so presence never depends on it).
Client-side reconnect loop (fixed polite interval + manual escape)
is app work.
## Playlist (deferred, space reserved)
Not v1. Shape when it lands: server-held `queue:[media…]` alongside
the current media; anyone appends links (`queue-add`), anyone skips
(`queue-skip`); reaching EOF auto-advances exactly like a swap
(HELD, mediaId++, sets cleared, Join rerun, gate resumes). No code
until then — `mediaId` + swap already carry it.
Server-held queue (`queue-add`, `queue-skip`) would auto-advance like a swap.
`RelayConstants.chunkBytes`, `creditWindowChunks`, `maxConcurrentOpen` and the
`open`, `ack`, `cancel`, `eof`, `buffered`, `state`, `event` kinds in
`MessageKind` are unused leftovers.
+87 -77
View File
@@ -1,87 +1,97 @@
# ROADMAP — sync_relay_project (backlog tracker)
# Roadmap
This is the durable source of truth for what is built and what is next.
Chat is ephemeral; this file + `HANDOFF.md` (root) + `PROTOCOL.md` /
`ARCHITECTURE.md` carry state across sessions. Check an item off when it
lands, same commit as the code. Proto bumps still require PROTOCOL.md
same-commit.
Backlog and epics, in order. Done work lives in git history. Check an item
off by deleting it in the same commit as the code. Wire changes also edit
[PROTOCOL.md](PROTOCOL.md).
Model: direct-serve (host opens ports, clients open nothing). The
brief's old WSS host-upload tunnel is obsolete unless a VPS tier
needs remote-host upload. Server = Relic, client WS =
web_socket_channel. Protocol still v4.
## Backlog
## Built (phases 0–4 + hardening — done, details in git history)
1. **Isolate server + dedicated server.** Replace host detection by loopback
peer (breaks behind a same-machine reverse proxy or tunnel) with an explicit
host token. Run the server on a Dart isolate
when hosting from the app; build the dedicated server as a separate
`dart compile exe` of `packages/server` (a Flutter binary cannot be
headless); drop `--headless`; rename "room" to "server" in code
(`RoomServer`, `RoomHub`, `RoomBroadcast`, wire `t:'room'`, `room` in
`/health`, config defaults) with the proto bump that implies; refresh
`deploy/` and the packaging.
Scaffold + CI, mpv Range contract, sessions/tokens/media endpoint,
sync engine + quorum, desktop shell (SyncClient, RoomDiff, media_kit,
avatars, toasts+sheet, reconnect), presence hardening, frameless
chrome, chat/idle fixes, chat double-row fix, avatar 256 KiB cap,
Linux tarball packager + Discord upload card, updater v1
(`--upgrade`: manifest check, download, sha256, apply-over).
WAN-tested 2-client. Counts at last green: core 42, server 41,
desktop 17 + 7 updater.
2. **Sync fixes.**
- Jumpy seek. Root cause: the media_kit seek bar calls `player.seek`
continuously while dragging; the coordinator only notices through its
1 s position poll (`_detectUserSeek`), so it fights the server echo and
the seek-landed retry (`_checkSeekLanded`).
- End-of-file stall. The 4 s buffered gate cannot be met in the last 4 s of
a file, the server never learns the duration, and there is no finished
state. Add duration reporting and a finished state.
- Tiny read-ahead. Our own pins (`cache-secs=10`,
`demuxer-readahead-secs=10` in `playback_controller.dart`) cap it; want
about two minutes for large files.
- Remote clock offset is not corrected: clients assume their clock equals
the server's.
- Stale-ready. A ready member who went stale (no reports for 15 s) should
return to the ready set when reports resume; the client re-sends ready as
a workaround (remove it then).
- Ghost member after an unclean drop until `dropAfter`.
## Backlog (priority order, each one sitting-sized)
3. **Rendering, then shaders.** media_kit falls back to software rendering
on the dev machine. Suspect Impeller on Linux against media_kit's GL
texture path; investigate first, since shaders need hardware rendering and
it may also cause the video freeze. Then shaders: per-viewer local setting
using mpv `glsl-shaders`, shipping Anime4K presets (bloc97, MIT).
1. First real release: `tool/release.sh` (derives the version,
writes the manifest, no typing) → review →
`git push --follow-tags` → CI (`release.yml`) builds the
tarball and publishes the Forgejo release. Verifies the whole
`--upgrade` chain end to end. Manual fallback is
`tool/package_linux.sh` + `tool/release_publish.sh <tag>`.
2. Jumpy seek: live repro with timeline log first, then fix the
seek-land retry vs deadband/debounce fight.
3. EOF stall (bug, dogfood 09-12): playback stops just before the
end and gates HELD ("waiting for X (3.9s)"). Suspect the
buffered≥4s readiness gate is unsatisfiable near end of media.
4. Tiny buffer (bug, dogfood 09-12): 1–3 s observed; want ~2 min
for a 1.4 GB / 24 min file (intro skip + surviving short
drops). Investigate demuxer forward-queue props + 64 MB
`bufferSize`, then raise.
5. Recovery controls: reload-current-media button + restart-player
button for when playback wedges.
6. Link conveniences: Ctrl+V pastes clipboard into the media
field; session link swaps update the remembered media (today
only the connect screen persists); session shows the open link
with an open-in-browser button.
7. Time UI: remaining time until end + scrubber preview (target
time while scrubbing).
8. Shortcut parity pass with mpv / mpv.net.
9. Sounds swap-in: replace `NoopSfxService` (flutter_soloud preload,
Kenney/ObsydianX/akx sources in HANDOFF §7); keep seek/pause/
resume cues muted or near-silent.
10. `/health` auth + stop broadcasting the full media URL (api_key
leaks onto the wire).
11. File-join wart: welcome mediaUrl advertises 127.0.0.1, remote file
joiners fail-open then self-heal on first room broadcast.
12. Cache-exhaustion long outage via `tool/shaper.dart --kill-after`.
13. Phase 5 chaos suite (host drop 5 s / 30 s, viewer stall, 20-seek
storm) + OOM / stuck-room / drift-recovery assertions.
14. Windows zip in packaging + Discord card.
15. TLS/Caddy doc.
16. Chat history pane (schema exists, Activity log only for now).
17. Shaders: anime4k upscaling via mpv glsl-shaders + UI toggle.
18. Avatar caching + static serving (worth-it decision open):
serve avatar bytes once over HTTP by hash instead of
base64-in-every-room-broadcast.
19. Backfill LAN soak + 1.4 GB test details (record-only, user ran
both 09-12).
4. **Windows build + CI.** There is no Windows runner. Try
`ubuntu2610/flutter_build` (LLVM-MinGW + Wine `gen_snapshot`) on the Linux
runner, fallback a GitHub Actions mirror. The manifest needs per-platform
URLs and `release_publish.sh` must handle a second platform. The updater's
Windows apply path exists but is untested. Desktop tests are not in CI
either.
## Epics (multi-sitting, scheduled whole — not before the backlog
friction items above, except Jellyfin which is next feature)
5. **Jellyfin** (epic, below).
- File relay (original brief, second half): links shipped, relay
unbuilt. Split into sub-tasks when scheduled.
- Jellyfin native — NEXT feature.
- Updater expansion: in-app prompt, Windows helper (locked-exe
dance), auto-restart after apply.
- Playlist (`queue-add`/`queue-skip`, shape reserved in PROTOCOL.md),
sidecar subtitles, local-file fallback (viewer plays matching
size+duration file locally, still synced), `HttpSource` (host
pastes a direct-stream URL, server fetches + re-serves).
6. **Smaller items**
- Link conveniences: Ctrl+V pastes into the media field; a link swap in
session updates the remembered media; show the open link with an
open-in-browser button.
- Time UI: remaining time, scrubber preview.
- mpv / mpv.net shortcut parity.
- Sounds: replace `NoopSfxService` (cues already fire). Candidates:
Kenney Interface Sounds (CC0), ObsydianX Pack 1 (CC0), akx/Notifications
(CC0); playback via flutter_soloud. Keep seek/pause/resume near silent.
- Chaos suite: host drop 5 s / 30 s, viewer stall, 20-seek storm, long
outage with cache exhaustion (`tool/shaper.dart --kill-after`); assert no
OOM, no stuck session, drift recovers.
- Chat history pane (today: activity log only).
- Avatar caching: serve avatar bytes once by hash instead of base64 in
every `room` broadcast.
- The `room` broadcast carries the full media URL, which can include an
`api_key`.
- TLS: document a reverse proxy in front of the server.
- Playlist: `queue-add`, `queue-skip`, auto-advance like a swap.
- Sidecar subtitles.
## Deferred (needs explicit approval; transcode is never)
## Epic: Jellyfin
P2P/WebRTC, multi-session, Android. Transcode: NEVER (user decision
09-12 — not the age for it anymore).
- Each viewer logs in to Jellyfin themselves (Quick Connect or password).
- Several Jellyfin servers can be linked, keyed by `ServerId`.
- The sync server shares only `{ServerId, ItemId}`. Pasted Jellyfin links are
recognised and reduced to that pair.
- Viewers with an account direct-play `/Videos/{id}/stream?static=true`
with their own token and report Playing / Progress / Stopped, so watched
state and Continue Watching update per account.
- Guests without an account play through the sync server, which fetches with a
credential that stays server-side (the host's own or a configured one).
- Next episode: the successor in `/Shows/{seriesId}/Episodes` (NextUp means
first unwatched, not next-after-X); audio and subtitle track matching by language, then title,
then codec/channels.
- Library browser; anyone can put up items.
Spike facts (Jellyfin 10.11.11): `/Videos/{id}/stream?static=true&mediaSourceId=`
serves the original with byte ranges (206). PlaybackInfo gives `PlaySessionId`
and DirectPlay. All audio and subtitle tracks are embedded in the mkv. Quick
Connect is enabled but `/QuickConnect/Initiate` returned 400: investigate
(likely missing client/device fields in the auth header).
## Deferred
P2P, Android. Never: transcoding, web clients.
-70
View File
@@ -1,70 +0,0 @@
# mpv HTTP contract (Phase 1, verified against Relic endpoint)
Spike: `tool/range_logger.dart` (Relic, `mountMediaRoute` + `onLog` hook).
Player: stock mpv CLI headless (`--vo=null --ao=null`). Server logs
`METHOD path range=<h> -> status bytes=<n>`.
## Real traces
mp4, 570692 B, full play (`--frames=300`):
```text
get /m/file range=- -> 200 bytes=570692
get /m/file range=bytes=0- -> 206 bytes=570692
```
One no-Range open (mpv probes full length), then a single open-ended
stream to EOF. Small file fits demuxer cache; one connection does it all.
mkv (ffmpeg remux of same file), 562197 B, play + `--start=8` seek:
```text
get /m/file range=bytes=0- -> 206 bytes=562197
get /m/file range=bytes=0- -> 206 bytes=562197
get /m/file range=bytes=562093- -> 206 bytes=104
get /m/file range=bytes=243694- -> 206 bytes=318503
```
Two `bytes=0-` opens (demuxer probe + playback), a 104 B tail read at
`size-104` (Cues-at-EOF probe — mpv expresses it open-ended, not as
`bytes=-n`; the server answers both identically), then a mid-file open
at a cluster boundary for the seek/start position. Seeks always arrive
as a fresh `bytes=N-` connection, never as a mutation of the old one.
Outage: server SIGINT-killed 5 s mid-play (looping file, comma-form
reconnect flags), restarted. mpv kept playing from cache, no error, no
reconnect storm; no new Range request inside the post-restart window.
Short drops are ridden out from cache — matches the north-star case.
Cache-exhaustion reconnect (long drop) is a Phase 5 chaos assertion;
`tool/shaper.dart` (KB/s cap + latency + `--kill-after`) exists for it.
## Server behavior table (all byte-exact tested, 16 server tests)
| Request | Response |
|---|---|
| no Range | 200, exact `Content-Length`, full bytes |
| `bytes=a-b` | 206, `Content-Range: a-b/size`, exact length |
| `bytes=a-` | 206 open-ended to EOF, exact length |
| `bytes=-n` | 206 tail `size-n..EOF` (Cues probe) |
| unsatisfiable start | 416 + `Content-Range: bytes */size` |
| host offline | 503 + `Retry-After: 2` (mpv retries with reconnect flags) |
| bad token / kicked / gone | 401 / 403 / 410 |
Always: `Accept-Ranges: bytes`; `Content-Type` from extension
(mkv `video/x-matroska`, mp4 `video/mp4`, webm, avi, mov, ts, mka/mp3).
## Relic notes
- Range arrives as typed `RangeHeader` (`req.headers.range`), not a raw
string. Multi-range takes the first entry; unit is preserved so
non-`bytes` units still 416. `resolveMediaRequest` in
`packages/server` stays framework-agnostic and is the tested truth.
- Reconnect flag syntax is build-sensitive: this mpv rejected the
colon-joined `--stream-lavf-o=a=1:b=1` form and accepted commas.
The exact media_kit property set is re-verified in Phase 4 through
mpv `set_property`, not CLI flags.
## Remaining manual (human + media_kit, Phase 4)
1.4 GB real file through the endpoint with the pinned buffer props;
long-outage reconnect with cache exhaustion via shaper.