Files
sync_replay_project/docs/ARCHITECTURE.md
T
foxyzandClaude Opus 5.5 d8a0729029 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>
2026-10-07 00:42:28 +03:00

13 KiB

Architecture

Plain overview first: HOW-IT-WORKS.md. Wire format: PROTOCOL.md. Plans: ROADMAP.md.

Packages

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)

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.

Server lifecycle

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.

--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).

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.

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.

Sync model

Loosely modeled on Syncplay, with a different readiness design.

  • 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).

Additive wire changes only; bump proto and PROTOCOL.md in the same commit.

Media

Two sources, same sync.

  • 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.