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:
1 parent
aeff087c7d
commit
d8a0729029
9 files changed
+680
-687
No files matched your search
+216
-68
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user