diff --git a/AGENTS.md b/AGENTS.md index 8c6cdc2..321bc85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). diff --git a/HANDOFF.md b/HANDOFF.md deleted file mode 100644 index 65bd3d2..0000000 --- a/HANDOFF.md +++ /dev/null @@ -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/// (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. diff --git a/README.md b/README.md index 34ce89b..9daa16d 100644 --- a/README.md +++ b/README.md @@ -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--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). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e868c9b..702399a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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///`. 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:/…`) — 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///` serves it with a Range + table, gated by the viewer's token. The server builds the URL per viewer + (`http://:/m/default//`), so tokens never reach + other viewers. `` 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. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..c209091 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -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= +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/.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/`. +- `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. diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md new file mode 100644 index 0000000..c4a1397 --- /dev/null +++ b/docs/HOW-IT-WORKS.md @@ -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. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index f57f709..7e5a1f4 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -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: ` (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///` + 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|","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"|"","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":"","reason":"…"} v3, now by member id +{"t":"chat","text":"..."} +{"t":"kick","target":"","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":{}} +{"t":"room","eventId":7,"transport":"playing"|"held","position":12.3, + "serverTime":"","mediaId":1, + "media":{name,size,duration,url?}|absent, + "members":[{"id","name","avatar","ready","inSet","buffered","stale"}]} +{"t":"feed","actor":"","action":"","target":"","reason":"","at":""} +{"t":"chat","text":"...","from":""} +{"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///` 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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1416200..25af291 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 `. -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. diff --git a/docs/mpv-http-contract.md b/docs/mpv-http-contract.md deleted file mode 100644 index 72950f2..0000000 --- a/docs/mpv-http-contract.md +++ /dev/null @@ -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= -> status bytes=`. - -## 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.