Server on an isolate, dedicated server binary, room -> server (proto 5)
- serve(Map config, events) is the one server entrypoint: the app hosts it via Isolate.spawn (crash surfaced on the Host card, logs forwarded as [server]); bin/server.dart is the dedicated server, compiled into the bundle as watch-together-server. --headless gone. - Host identity via a per-hosting hostKey in hello, replacing the loopback heuristic. - Rename room -> server across code, files and wire (t:"state", /health name); protocol bumped to 5. - CI compiles the server binary; isolate smoke tests in server and desktop suites. Docs updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d8a0729029
commit
81cac1214d
48 files changed
+1073
-423
No files matched your search
+56
-37
@@ -7,40 +7,56 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
|
||||
|
||||
| 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 |
|
||||
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging | pure Dart, no Flutter |
|
||||
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
|
||||
| `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, updater | ships with `watch-together-server` |
|
||||
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
|
||||
| `deploy` | systemd unit for the standalone server (stale until the dedicated server exists) | |
|
||||
| `deploy` | systemd unit for the dedicated server | |
|
||||
|
||||
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
|
||||
the same rule; CI checks it with `dart compile exe` of `bin/server.dart`. 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`.
|
||||
One entrypoint: `serve(config, events:)` in `packages/server`. `config` is
|
||||
an `AppConfig.toMap()` (primitives only, so it crosses isolates). It starts a
|
||||
`SyncServer` (bound to all IPv4 interfaces) and reports on the `events` port:
|
||||
`ready` (with the port and a `stop` SendPort), `error` (start failed), and
|
||||
every log record as `log`. Sending `{'t':'stop'}` to the `stop` port closes
|
||||
all sockets and returns, and the isolate exits.
|
||||
|
||||
`--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).
|
||||
- **Hosting from the app**: `SessionState.host()` spawns an isolate running
|
||||
`serve` (`ServerIsolate`), waits for `ready`, then joins it over
|
||||
`127.0.0.1` like any other client. Server logs go to the app's log with a
|
||||
`[server]` prefix. The isolate runs with `errorsAreFatal: false`: an
|
||||
uncaught async error is logged and the server keeps going. If the isolate
|
||||
dies anyway, the session shows "server crashed: ..." and leaves; Home
|
||||
shows it on the Host card (during setup the host attempt fails with it).
|
||||
Stop (leave, close, cancel during setup, a failed own join, being kicked)
|
||||
sends `stop`, waits up to 3 s for exit, then kills the isolate.
|
||||
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
|
||||
and shipped next to the app as `watch-together-server`. It parses flags
|
||||
(`--port`, `--secret`, `--name`, `--media`, `--advertise-host`,
|
||||
`--config`; default port 8080) into the same map and calls `serve`;
|
||||
SIGINT and SIGTERM stop it. A Flutter binary cannot run without a
|
||||
display, which is why the server is its own build.
|
||||
|
||||
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).
|
||||
`SyncServer` runs a 1 s heartbeat: stale sweep, closed-socket sweep, silent
|
||||
socket drop, state + feed broadcast. There is one session, id `default`.
|
||||
|
||||
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.
|
||||
|
||||
Host: when hosting, the app generates a random `hostKey` per hosting, puts
|
||||
it in the server config and sends it in its own `hello`. The member whose
|
||||
key matches is the host and cannot be kicked. The address a connection
|
||||
comes from plays no part (so a reverse proxy or tunnel on the same machine
|
||||
does not make everyone the host). The dedicated server has no key, so it
|
||||
has no host. Kick = revoke token, send `kicked`, close; the person may
|
||||
rejoin at once with a new token.
|
||||
|
||||
## Sync model
|
||||
|
||||
@@ -63,20 +79,20 @@ Loosely modeled on Syncplay, with a different readiness design.
|
||||
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
|
||||
position and bumps `eventId`; flags are untouched. Clients diff each `state`
|
||||
broadcast by `eventId` (`StateDiff`): new event means seek to the computed
|
||||
server 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.
|
||||
more than 2 s from the server position) and sent as a `seek` intent; a retry
|
||||
(`_checkSeekLanded`) re-applies a server 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
|
||||
- **Client policies**: no state 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.
|
||||
@@ -96,14 +112,14 @@ Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit.
|
||||
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
|
||||
in `swap` and `state`, 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
|
||||
- **Host local file**: `SyncServer.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`
|
||||
(default empty) only overrides it when set explicitly. When a `state`
|
||||
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
|
||||
@@ -146,10 +162,10 @@ 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()`
|
||||
`StateDiff` actions or the play gate (`localPlayAllowed`: self in-set and
|
||||
ready). A server 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,
|
||||
(`mediaId` bump) the coordinator opens the new URL and `StateDiff` seeks to 0,
|
||||
holds and resets rate.
|
||||
|
||||
Frozen picture: `FrameWatchdog` samples about once a second. It hashes a
|
||||
@@ -186,7 +202,7 @@ 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`,
|
||||
displayable state. The server's `GET /health` returns JSON (`ok`, `name`,
|
||||
`proto`, `viewers`, `bytesServed`, `uptimeSecs`); it is unauthenticated and
|
||||
the app does not read it.
|
||||
|
||||
@@ -217,9 +233,12 @@ this repo, written by `tool/release.sh`.
|
||||
- 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 runs inside the app on an isolate, and ships as a separate compiled
|
||||
server: a Flutter binary cannot run headless, so a dedicated server needs
|
||||
its own build of `packages/server`. An isolate keeps the UI responsive and
|
||||
shares the process (sockets, file paths) without a second executable.
|
||||
- Host by key, not by address: loopback detection broke behind a
|
||||
same-machine reverse proxy or tunnel.
|
||||
- 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
|
||||
@@ -228,5 +247,5 @@ this repo, written by `tool/release.sh`.
|
||||
(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):
|
||||
- Avatars ride in the 1 Hz `state` broadcast (cap 256 KiB base64 per avatar):
|
||||
simple, costs host uplink; hash-based serving is on the roadmap.
|
||||
+31
-6
@@ -12,11 +12,14 @@ 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
|
||||
cd packages/server && dart compile exe bin/server.dart -o watch-together-server
|
||||
```
|
||||
|
||||
`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).
|
||||
tarball to Discord (never commit it). The packager always compiles the
|
||||
dedicated server (with the Flutter SDK's `dart`) into the tarball as
|
||||
`watch-together-server`, also with `--skip-build`.
|
||||
|
||||
## Dev loop
|
||||
|
||||
@@ -26,8 +29,10 @@ 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:
|
||||
This is the same `serve()` the app runs when hosting and the same code as
|
||||
the dedicated binary. Prints `READY port=... name=...` and stops on SIGINT
|
||||
or SIGTERM. 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`.
|
||||
|
||||
@@ -43,13 +48,18 @@ and `e2e_check` (simulated viewers against a live server).
|
||||
- `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.
|
||||
- CI (`.gitea/workflows/ci.yml`): analyze + test for core and server, plus
|
||||
`dart compile exe` of the dedicated server (fails if Flutter creeps into
|
||||
`packages/server`). Desktop tests are not in CI.
|
||||
- `packages/server/test/serve_isolate_test.dart` runs `serve` in a real
|
||||
isolate; `apps/desktop/test/server_isolate_test.dart` covers the app's
|
||||
host path (isolate server, raw WebSocket join, crash, rehost).
|
||||
- Desktop tests that write files use `WT_TEST_TMP` (default: system temp).
|
||||
|
||||
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.
|
||||
(`StateDiff`, 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.
|
||||
@@ -73,9 +83,24 @@ Tokens come from the environment (`RELEASE_TOKEN`, or Actions-provided
|
||||
updater skips the check when absent. Workflows live in `.gitea/workflows`
|
||||
(the forge reads `.gitea` before `.github`).
|
||||
|
||||
## Debugging the hosted server
|
||||
|
||||
When hosting, the server runs on its own isolate (`ServerIsolate`,
|
||||
`apps/desktop/lib/src/state/server_isolate.dart`), in the same process.
|
||||
Breakpoints in `packages/server` work under `flutter run`: the debugger
|
||||
attaches to spawned isolates too (it shows as `server`). Its log lines are
|
||||
printed with a `[server]` prefix. Hot reload reloads the server isolate's
|
||||
code too, but its state (members, clock, sockets) survives until hosting
|
||||
restarts; changes to startup code (`SyncServer.start`, routes) need a new
|
||||
Host. Without the app: `dart run packages/server/bin/server.dart`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Relic gives Range as a typed `RangeHeader`; convert from unit + first range.
|
||||
- A killed isolate (`Isolate.kill`) does not close its listening socket:
|
||||
the port stays bound in the process until exit. `SyncServer` binds with
|
||||
`shared: true` so a new hosted server in the same process can take it
|
||||
over; a normal stop closes it.
|
||||
- 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
|
||||
|
||||
@@ -33,8 +33,8 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
|
||||
|
||||
- **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).
|
||||
- **Dedicated server**: `watch-together-server`, a separate program in the
|
||||
release tarball, for an always-on box. See the README.
|
||||
|
||||
Friends join either way with the Join card on Home.
|
||||
|
||||
|
||||
+19
-14
@@ -1,31 +1,32 @@
|
||||
# Protocol (proto = 4)
|
||||
# Protocol (proto = 5)
|
||||
|
||||
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`.
|
||||
`packages/core/lib/src/protocol.dart` and `packages/server/lib/src/server_hub.dart`.
|
||||
|
||||
Any wire change bumps `RelayConstants.protoVersion` and edits this file in
|
||||
the same commit. Changes are additive.
|
||||
the same commit. Changes are additive where possible; a client and server
|
||||
with different `proto` refuse each other at `hello` (`proto mismatch`).
|
||||
|
||||
## Transport
|
||||
|
||||
- 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`.
|
||||
- Match incoming frames by `t`, never by order: `welcome` can race `state`.
|
||||
- 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`,
|
||||
- `GET /health` returns JSON (`ok`, `name`, `proto`, `viewers`, `bytesServed`,
|
||||
`uptimeSecs`; unauthenticated). `GET /m/<sid>/<token>/<name>`
|
||||
serves the host's file (Range table in ARCHITECTURE.md).
|
||||
|
||||
## Client to server
|
||||
|
||||
```
|
||||
{"t":"hello","proto":4,"secret":"","user":{"name":"","avatar":""},
|
||||
"media":{"name":"","size":0,"duration":0,"url":null}}
|
||||
{"t":"hello","proto":5,"secret":"","user":{"name":"","avatar":""},
|
||||
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"..."}
|
||||
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
|
||||
{"t":"force","target":"all"|"<memberId>","ready":true}
|
||||
{"t":"seek","position":123.4}
|
||||
@@ -37,8 +38,12 @@ the same commit. Changes are additive.
|
||||
|
||||
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.
|
||||
- `hello.hostKey` (optional, omitted when empty): the hosting app
|
||||
generates a random key per hosting, gives it to its server and sends it
|
||||
from its own client. The member whose key equals the server's is the host
|
||||
and cannot be kicked. A server without a key (the dedicated server) has no
|
||||
host. Where the connection comes from does not matter.
|
||||
- `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.
|
||||
@@ -49,8 +54,8 @@ Field notes:
|
||||
## Server to client
|
||||
|
||||
```
|
||||
{"t":"welcome","token":"...","memberId":"m1","mediaUrl":"...","state":{<room>}}
|
||||
{"t":"room","eventId":7,"transport":"playing"|"held","position":12.3,
|
||||
{"t":"welcome","token":"...","memberId":"m1","mediaUrl":"...","state":{<state>}}
|
||||
{"t":"state","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"}]}
|
||||
@@ -64,8 +69,8 @@ Field notes:
|
||||
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
|
||||
`welcome.state` has the same shape as `state`.
|
||||
- `state` 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.
|
||||
@@ -80,7 +85,7 @@ Field notes:
|
||||
|
||||
## Join and reconnect
|
||||
|
||||
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the room
|
||||
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the server
|
||||
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.
|
||||
@@ -90,4 +95,4 @@ the first `ready`. A socket that dies loses its token; reconnect is a fresh
|
||||
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.
|
||||
`MessageKind` are unused leftovers (the `state` frame does not use the enum).
|
||||
+10
-19
@@ -6,17 +6,7 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
||||
|
||||
## Backlog
|
||||
|
||||
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.
|
||||
|
||||
2. **Sync fixes.**
|
||||
1. **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
|
||||
@@ -34,22 +24,23 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
||||
a workaround (remove it then).
|
||||
- Ghost member after an unclean drop until `dropAfter`.
|
||||
|
||||
3. **Rendering, then shaders.** media_kit falls back to software rendering
|
||||
2. **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).
|
||||
|
||||
4. **Windows build + CI.** There is no Windows runner. Try
|
||||
3. **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.
|
||||
Windows apply path exists but is untested. The tarball's
|
||||
`watch-together-server` needs a Windows `dart compile exe` too. Desktop
|
||||
tests are not in CI either.
|
||||
|
||||
5. **Jellyfin** (epic, below).
|
||||
4. **Jellyfin** (epic, below).
|
||||
|
||||
6. **Smaller items**
|
||||
5. **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.
|
||||
@@ -63,8 +54,8 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
||||
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
|
||||
every `state` broadcast.
|
||||
- The `state` 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.
|
||||
|
||||
Reference in new issue
Block a user