Restructure step 3: your server, Invite, version gate, AppGraph
- One server per app run (loopback, no password); Invite attaches an all-interfaces listener to the same server and Stop sharing detaches it (guests told with 4016); members, media and Up next never reset. No solo mode: you're home on your own server unless in a friend's. - Navigation: none / yours / friend; a join passes a version gate on /health first (clear "they/you need to update", update offer). - Invite as a value (host, port, name, password flag, version, fp reserved); addresses filtered (loopback, link-local, 198.18/15, bridges) and ranked; Sharing chip, Copy invite, Stop sharing. - Alone looks like a plain player (alone replaces solo). - AppGraph builds and disposes systems; widgets use AppScope; SessionState deleted; a test caps the remaining singletons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
629be0d9ed
commit
4d00b7455e
86 files changed
+2993
-2071
No files matched your search
+177
-104
@@ -9,7 +9,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
|
||||
|---|---|---|
|
||||
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`); `testing.dart` (test support for all three packages, not in `core.dart`: `FlakyProxy`) | pure Dart, no Flutter |
|
||||
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
|
||||
| `apps/desktop` | Flutter app: UI, `SyncClient`, `Connection`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, update UI (`UpdateController`) | ships with `starsync-server` |
|
||||
| `apps/desktop` | Flutter app: UI, `AppGraph` (composition root), `SyncClient`, `Connection`, `SessionCoordinator`, `Navigation`, `YourServer` (`ServerIsolate`), `PlaybackController` (media_kit/mpv), update UI (`UpdateController`) | ships with `starsync-server` |
|
||||
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
|
||||
| `deploy` | systemd unit for the dedicated server | |
|
||||
|
||||
@@ -23,40 +23,80 @@ WebSocket (`web_socket_channel`) lives in `apps/desktop`; Relic has no client.
|
||||
|
||||
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. `{'t':'file', 'path', 'reply'}`
|
||||
on the same port makes a local file the hosted one and answers its name and
|
||||
size (`ServerIsolate.setFile`): the host's in-session File swap then sends a
|
||||
`swap` with that name and size, which the server matches to the file.
|
||||
`SyncServer` (bound to all IPv4 interfaces, or 127.0.0.1 with an extra
|
||||
`'loopbackOnly': true` key) and reports on the `events` port: `ready`
|
||||
(with the port and a control SendPort), `error` (start failed), and every
|
||||
log record as `log`. On the control port: `{'t':'stop'}` closes all
|
||||
sockets and returns, and the isolate exits; `{'t':'file', 'path',
|
||||
'reply'}` makes a local file the hosted one and answers its name and size
|
||||
(`ServerIsolate.setFile`: a File put up here then sends a `swap` with that
|
||||
name and size, which the server matches to the file); `{'t':'share',
|
||||
'port', 'secret', 'jellyfin', 'reply'}` and `{'t':'unshare', 'reply'}`
|
||||
attach and detach the network listener (below).
|
||||
|
||||
- **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. Its log records go to the app's log
|
||||
as `server.<logger>` (see "Logging"). 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; the
|
||||
shell says so in a dialog (during setup the host attempt fails with it).
|
||||
Stop (leave, close, cancel during setup, a failed own join)
|
||||
sends `stop`, waits up to 3 s for exit, then kills the isolate.
|
||||
- **Solo session** (watching alone): the same hosting path, so nothing
|
||||
about solo is a second code path or on the wire. Putting something up
|
||||
while offline (`AppShellState._putUp`) calls `SessionState.startSolo`:
|
||||
a `ServerIsolate` with `loopbackOnly` (an extra `'loopbackOnly': true`
|
||||
key next to the `AppConfig` map; `SyncServer` binds 127.0.0.1 instead
|
||||
of every IPv4 interface), port 0 (a free one), no password, a host key;
|
||||
the app joins it and `SessionState.solo` is set. Everything a session
|
||||
has works (Jellyfin progress, next episode, skip pills, the player); a
|
||||
link goes up direct (no relay mode). Invite friends opens the Host card,
|
||||
whose submit (`SessionState.host` while solo) remembers the
|
||||
coordinator's `lastPick` (the last `swapLink` / `swapJellyfin` /
|
||||
`swapFile` of this connection) and position, leaves and stops the solo
|
||||
server, hosts on the card's port and password, swaps the same pick and
|
||||
seeks once a state shows it (`switching` is set meanwhile; the shell
|
||||
closes the player and reopens it as it was). If hosting fails or is
|
||||
cancelled, solo starts again with the same media. Stop leaves without
|
||||
asking.
|
||||
- **Your server** (`session/your_server.dart`, `YourServer`): every app
|
||||
runs one server for its whole life, started when the app starts
|
||||
(`Navigation.start`) as a `ServerIsolate` running `serve`: port 0 (a
|
||||
free one), loopback only, no password, a random `hostKey` per app run,
|
||||
named "<your name>'s server", its session log in the app's logs folder
|
||||
and its log records in the app's log as `server.<logger>` (see
|
||||
"Logging"). Nobody else can reach it. The isolate runs with
|
||||
`errorsAreFatal: false`: an uncaught async error is logged and the
|
||||
server keeps going. If the isolate dies anyway, `YourServer.crash` is
|
||||
set (the shell says so in a dialog) and, unless you are in a friend's
|
||||
server, the navigation starts a new one and goes home to it.
|
||||
- **Listeners**: `SyncServer.share(port, secret, jellyfin)` attaches a
|
||||
second listener on every IPv4 interface at the port from Settings →
|
||||
Party & network (default 8080) to the running server (Relic serves the
|
||||
same routes on both), sets the password and the Jellyfin relay login
|
||||
(the default account's, so guests without an account watch Jellyfin
|
||||
items through the server); `unshare` closes it. Nothing restarts for
|
||||
either: members on the loopback listener keep their sockets, the media
|
||||
and Up next stay. Unsharing sends everyone but the host out: each
|
||||
leaves at once (feed `leave`, reason `closed`) and its socket closes
|
||||
with 4016, so its app does not retry (see "Connection"); the password
|
||||
goes back to none. A port another program holds fails the share with
|
||||
its reason; nothing else changes.
|
||||
- **Invite** (`session/invite.dart`): `YourServer.invite()` shares the
|
||||
server if it is not yet and returns an `Invite`: the host, the port, the
|
||||
name, whether a password is needed (never the password), the version
|
||||
and an `fp` field reserved for a pinned certificate (`wss://` with a
|
||||
self-signed one, later; always null). The host is the best of
|
||||
`inviteAddresses` over `NetworkInterface.list`: IPv4 only, no loopback,
|
||||
link-local (169.254/16) or proxy fake-IP (198.18.0.0/15, what TUN
|
||||
proxies like Clash hand out) address and no container or VM bridge
|
||||
(`docker*`, `br-*`, `veth*`, `virbr*`, `vmnet*`, `vboxnet*`, `cni*`,
|
||||
`podman*`, `lxcbr*`, `lxdbr*`, `vEthernet`, VirtualBox, VMware); 192.168
|
||||
first, then 10/8 and 172.16/12, then a mesh VPN's 100.64/10, then any
|
||||
other (public) address. No public-address lookup: the members card says
|
||||
that friends elsewhere need the port forwarded or a server they can all
|
||||
reach. `Invite.text` is what Invite copies ("Join me on Starsync:
|
||||
192.168.1.20:8080 (ask me for the password) · Starsync 1.3.0");
|
||||
`addressIn` reads the address back out of a pasted invite (the Join
|
||||
card's address field). `stopSharing` unshares.
|
||||
- **Where you are** (`session/navigation.dart`, `Navigation`): `place` is
|
||||
`none` (starting, or between two servers), `yours` or `friend`. The app
|
||||
starts at home: your server, joined over 127.0.0.1 with the host key
|
||||
(the `hostKey` stands in for the password, so a password set later never
|
||||
locks this app out), `hostFile` set to `YourServer.setFile`. A join
|
||||
first reads the friend's `/health` (the version gate, below); an
|
||||
unreachable server stops there, still at home. Then this app leaves
|
||||
your server (`bye`) and connects to theirs; a join that fails or is
|
||||
cancelled goes home again. Leaving theirs (Leave), or theirs closing to
|
||||
friends (`ConnRefused` `closed`: "The host stopped sharing their
|
||||
server"), goes home. Your server stays up all the while: what plays
|
||||
there and Up next wait for you, and whoever you invited stays in it.
|
||||
A joined server's name and version (`serverLabel`, `serverVersion`)
|
||||
come from its `/health`; yours is this app's. The Join card's recents
|
||||
are the friends' servers that got in.
|
||||
- **Version gate**: `/health` gives `protoMin`, `protoMax` and `version`
|
||||
(a proto 15 server only `proto`, used for both). `versionFit` against
|
||||
this build's range: ranges that meet are fine; a server whose newest is
|
||||
older than this app's oldest "runs Starsync 1.2.1 — they need to
|
||||
update", one whose oldest is newer "— you need to update", and then the
|
||||
Join card offers "Check for the update" (the updater, see "Updater").
|
||||
Nothing is dialled for either. A server that says nothing about its
|
||||
version is left to the hello's refusal (see "Connection").
|
||||
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
|
||||
and shipped next to the app as `starsync-server`. It parses flags
|
||||
(`--port`, `--secret`, `--name`, `--media`, `--advertise-host`,
|
||||
@@ -65,6 +105,8 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a
|
||||
(`--upgrade` / `--check-update` run the [updater](#updater) instead);
|
||||
SIGINT and SIGTERM stop it. A Flutter binary cannot run without a
|
||||
display, which is why the server is its own build.
|
||||
- **App close**: after the players and the `bye` (see "Desktop UI",
|
||||
closing), your server stops (`stop`, then killed after 3 s).
|
||||
|
||||
`SyncServer` runs a 1 s heartbeat (`ServerHub.heartbeat`): closed-socket
|
||||
sweep, the engine's sweep (silence, joining, gone notices, grace),
|
||||
@@ -72,16 +114,34 @@ relay source state, warm relay, rates, 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.
|
||||
warning is logged). Otherwise one shared secret, compared at hello; your
|
||||
server's is the one from Settings, set when it is shared.
|
||||
|
||||
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 (`members[].host`: the crown, art rights). 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. Nobody removes anyone: members set each
|
||||
other away ("Presence" below), the host included, since the hosted
|
||||
server and its `/m/` file serving do not need the host's player.
|
||||
Host: the app generates a random `hostKey` per app run, puts it in your
|
||||
server's config and sends it in its own `hello`. The member whose key
|
||||
matches is the host (`members[].host`: the crown, art rights) and needs no
|
||||
password. 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. Nobody removes anyone:
|
||||
members set each other away ("Presence" below), the host included, since
|
||||
your server and its `/m/` file serving do not need the host's player;
|
||||
only stopping sharing sends guests out.
|
||||
|
||||
## Composition root
|
||||
|
||||
`AppGraph` (`app/app_graph.dart`) is built once in `main.dart`
|
||||
(`AppGraph.build`) and constructs and disposes the app's systems: the
|
||||
`Ports`, the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the
|
||||
`SessionCoordinator`, the connect form and recents (`ConnectStore`,
|
||||
`state/connect_prefs.dart`), `YourServer` and `Navigation`; it wires them
|
||||
through their constructors (and gives `Profile` the form and the push) and
|
||||
has no logic or signals of its own. Widgets reach it through one
|
||||
`AppScope` InheritedWidget (`AppScope.of(context)`, readable in
|
||||
`initState`); `shutDownApp` gets it from `main` (`closeAppWith`). Tests
|
||||
build their own (`test/test_graph.dart`). The app's remaining `.instance`
|
||||
singletons (settings, Jellyfin accounts, the updater, the profile, Files'
|
||||
folders, yt-dlp, recent links) may only lose uses: `singletons_test`
|
||||
fails when one gains a use or a new one appears.
|
||||
|
||||
## Sync model
|
||||
|
||||
@@ -252,7 +312,7 @@ Loosely modeled on Syncplay, with a different readiness design.
|
||||
it back `in` (without touching the ready flag), but only from an away
|
||||
it set itself: a manual one (own or someone else's) stays. Never when
|
||||
alone (`isAlone` in core: no other member connected, `in`, `joining` or
|
||||
`away`) and never in a solo session. The window changes come from
|
||||
`away`). The window changes come from
|
||||
`ShellPort.watchActivity` (focused, unfocused, minimised, hidden); the
|
||||
timings are `AutoSitOutTimes`.
|
||||
- **Nudge**: `nudge` names a member; the server passes it to that member
|
||||
@@ -272,7 +332,7 @@ Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit.
|
||||
## Connection
|
||||
|
||||
`Connection` (`session/connection.dart`) is the link to the server, made
|
||||
in the composition root (`SessionState.connection`) and passed to the
|
||||
in the composition root (`AppGraph`) and passed to the
|
||||
`SessionCoordinator`. It owns the `SyncClient` socket, the first connect
|
||||
(`begin` + `dial`), reconnects (`delayFor`: 1 s, doubling per failed
|
||||
attempt up to 15 s, each ±20 % jitter), the epoch (`invalidate`,
|
||||
@@ -302,8 +362,9 @@ dial), `ConnOnline` (welcome received, frames arrive), `ConnUnstable`
|
||||
socket closed or errored, or nothing for `silenceLimit`, 9 s: the socket
|
||||
is dropped, the next attempt scheduled), `ConnReconnecting(attempt)` (an
|
||||
attempt dialling, and after it failed until the next one),
|
||||
`ConnRefused(code, message)` (`version`, `secret`, or empty for a first
|
||||
dial that failed otherwise; nothing retries) and `ConnReplaced` (`err`
|
||||
`ConnRefused(code, message)` (`version`, `secret`, `closed` for close
|
||||
4016, the host stopped sharing their server, or empty for a first dial
|
||||
that failed otherwise; nothing retries) and `ConnReplaced` (`err`
|
||||
`replaced`, close 4013 or 4014: another window of this install holds the
|
||||
member; nothing retries, "Retry" takes it back). The silence and the
|
||||
lost sound are measured on `Connection.now` (injectable, UTC wall clock
|
||||
@@ -595,8 +656,9 @@ For viewers without an account on the item's Jellyfin
|
||||
(`packages/server/lib/src/jellyfin_relay.dart`). The server may hold one
|
||||
`JellyfinCredential` (config key `jellyfin`: `baseUrl`, `serverId`, `token`,
|
||||
`client`, `device`, `deviceId`, `version`; primitives, so it crosses the
|
||||
isolate boundary in the `serve()` map). The hosting app fills it with its
|
||||
own app-obtained token and that token's device fields; the dedicated server
|
||||
isolate boundary in the `serve()` map). The app sends it when your server
|
||||
is shared (`share`): its own app-obtained token and that token's device
|
||||
fields; the dedicated server
|
||||
reads it from `--jellyfin <file>` (YAML/JSON with those keys, keeps the
|
||||
token off the command line) or a `jellyfin:` map in `--config`. An empty
|
||||
`serverId` is looked up at start (`/System/Info/Public`); if that fails the
|
||||
@@ -631,7 +693,7 @@ What gets relayed (`SessionCoordinator.swapPicked`):
|
||||
|
||||
| Put up | Others get it |
|
||||
|---|---|
|
||||
| File, by the host | `/m` from the hosting app (no relay) |
|
||||
| File, on your own server | `/m` from your server (no relay) |
|
||||
| File, by anyone else | relayed from that member's computer |
|
||||
| Link, "Everyone fetches it" | each app fetches it |
|
||||
| Link, "Server fetches it" | the server reads it once into the buffer; the link is never broadcast; refused on an open server (it would fetch any address it can reach for anyone) |
|
||||
@@ -855,7 +917,7 @@ all three link modes. Nothing is bundled or installed by hand.
|
||||
it. `version`, `lastError`, `busy` and `changes` feed Settings → Player
|
||||
→ yt-dlp ("Update now" forces the check). Every failure is non-fatal.
|
||||
Folders: the app's is `<app support>/yt-dlp` (`AppYtDlp`, warmed up at
|
||||
start), shared with its hosted server; the dedicated server's is
|
||||
start), shared with your server; the dedicated server's is
|
||||
`--yt-dlp-dir`, else `$STATE_DIRECTORY/yt-dlp` (systemd), else
|
||||
`yt-dlp` next to the binary if writable, else
|
||||
`~/.cache/watch-together/yt-dlp`.
|
||||
@@ -874,7 +936,7 @@ all three link modes. Nothing is bundled or installed by hand.
|
||||
youtube.com and bilibili's CDNs under bilibili.com). The app: Settings
|
||||
→ Player → yt-dlp → Cookies (a browser and profile, or a file picked or
|
||||
dropped, kept whole: every site in it; "Remove cookies"; the shell's
|
||||
own drop target is off on Settings so only this box takes the file); its hosted server shares the folder,
|
||||
own drop target is off on Settings so only this box takes the file); your server shares the folder,
|
||||
so it uses them too. mpv's hook gets the same as `ytdl-raw-options`
|
||||
(`cookies=%N%<path>` / `cookies-from-browser=%N%<spec>`, length-quoted),
|
||||
re-set before each external link when it changed, after
|
||||
@@ -1316,7 +1378,7 @@ click on a chat toast); cleared on connect.
|
||||
|
||||
**Ports.** UI and session code reach the platform through interfaces
|
||||
(`platform/ports/`), made once in the composition root
|
||||
(`SessionState.ports`, `desktopPorts()` in `platform/desktop/`) and
|
||||
(`AppGraph.ports`, `desktopPorts()` in `platform/desktop/`) and
|
||||
handed on from there (the coordinator gets its `Notifier`, `Connection` and
|
||||
`LinkResolver` as constructor arguments):
|
||||
- `ShellPort` (`DesktopShell`): window start (remembered bounds, the
|
||||
@@ -1355,8 +1417,9 @@ caches.
|
||||
|
||||
## Desktop UI
|
||||
|
||||
One window, `AppShell` (`lib/src/shell/`), whatever the connection state
|
||||
(online = `SessionState.route` is `session`). The window comes back as it was
|
||||
One window, `AppShell` (`lib/src/shell/`), wherever the app is
|
||||
(`Navigation.place`: your server, a friend's, none while it moves). The
|
||||
window comes back as it was
|
||||
(`platform/desktop/window_memory.dart`, settings key `windowBounds`, saved on
|
||||
resize/move/maximize and close): its normal size and maximized state
|
||||
everywhere, its position where an app may place itself (Windows, X11;
|
||||
@@ -1396,27 +1459,34 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
- **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card; 42 px like the party's),
|
||||
the Library / Schedule / Settings tabs with a growing underline (an
|
||||
`HScroll` when narrow; the version in the Settings tab's tooltip, at
|
||||
the bottom of every Settings page and in About), then offline a grey dot,
|
||||
"Offline", Join a friend and Host (connect cards; icon-less "Join" and
|
||||
"Host" under 1100 px), solo a "Solo · only you" chip, Invite friends
|
||||
(the Host card, titled "Invite friends", "Start hosting") and Stop (no
|
||||
dialog; no avatars or chat), online the party: `AvatarStack` (host first, then
|
||||
ready; spreads on hover; 5 shown, 3 under 1100 px), the server name and
|
||||
`Status.party` ("ready n/m": who the ready counter counts,
|
||||
`countsForReady` in `session/party.dart`, on `PresencePolicy`, so not
|
||||
away, gone or errored; "connecting…", "reconnecting…"; hidden under
|
||||
1100 px), chat with an unread badge, and Leave (asks first). The stack
|
||||
opens the members card (the server's version under its title: a hosted
|
||||
server is this app's, a joined one's from `/health.version`; the source
|
||||
the bottom of every Settings page and in About), then the party. Alone
|
||||
on your server (D9, `SessionCoordinator.alone`: nobody else connected)
|
||||
it is a plain player's: no ring on your avatar, no avatars or chat, Join
|
||||
a friend (the Join card; "Join" under 1100 px) and Invite (disabled until
|
||||
your server runs), plus a "Sharing · port 8080" chip while it is shared
|
||||
(it opens the members card). With others there, or in a friend's server:
|
||||
`AvatarStack` (host first, then ready; spreads on hover; 5 shown, 3
|
||||
under 1100 px), the server name ("Your server", else the joined one's
|
||||
`/health` name, else its address) and `Status.party` ("ready n/m": who
|
||||
the ready counter counts, `countsForReady` in `session/party.dart`, on
|
||||
`PresencePolicy`, so not away, gone or errored; "connecting…",
|
||||
"reconnecting…"; hidden under 1100 px), chat with an unread badge, then
|
||||
Invite on yours or Leave on a friend's (asks first, then you are home).
|
||||
Invite (`inviteFriends`) shares your server if it is not yet and copies
|
||||
the invite ("Invite copied · 192.168.1.20:8080"); a port it cannot open
|
||||
is a notice pointing at Settings. The stack
|
||||
opens the members card (the server's version under its title: yours is
|
||||
this app's, a joined one's from `/health.version`; the source
|
||||
line, then per member status, route and buffer dot (see "Relay",
|
||||
Stats), their Starsync version (`members[].app`, amber when it is not
|
||||
this app's; also in the avatar's hover), host / relay pills; "joining…", "sitting out", or for a `gone` member
|
||||
"reconnecting… (2 min)" with how long; per member force ready, Nudge and Sit
|
||||
out (anyone, the host too), your own row Sit out / Join back; "Copy invite
|
||||
address": the dialled address when joined; hosting, `inviteAddresses`
|
||||
of `NetworkInterface.list` (IPv4, no loopback or link-local, private
|
||||
ranges first) with the Host port, and a note that friends outside the
|
||||
LAN need a forwarded port or a reachable server). Empty strip space is the window
|
||||
out (anyone, the host too), your own row Sit out / Join back; at the
|
||||
bottom, on yours: not shared, "Only you can reach it" and Invite;
|
||||
shared, the invite's address, Copy invite, Stop sharing and a note
|
||||
that friends outside the LAN need a forwarded port or a reachable
|
||||
server; on a friend's, "Copy invite address" (the dialled address)).
|
||||
Empty strip space is the window
|
||||
drag area; the window buttons sit at its end.
|
||||
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
|
||||
picture with a gap in the background colour (both scale with the size):
|
||||
@@ -1428,16 +1498,18 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
profile, anyone else's from `coordinator.avatars` by `avatarHash`.
|
||||
Colours are local: yours is picked in the profile, others' come from
|
||||
their name.
|
||||
- **Connect cards** (`connect_cards.dart`): Join lists recent servers as
|
||||
one-click rows that poll `GET /health` every 10 s while the card is open
|
||||
(`health_probe.dart`: online, name, viewer count, "needs update" when
|
||||
`proto` differs from ours), then address, port and password. Host takes
|
||||
a port and a password (hosted ports as chips) and puts nothing up: what
|
||||
to watch is picked in the library afterwards. Submit state (busy,
|
||||
cancel, per-card errors) is `ConnectFlow`, owned by the shell so an
|
||||
attempt and its error outlive the card; a card closes itself once
|
||||
in a server that is not solo. Join says that a friend gives the address
|
||||
and that no account or Jellyfin is needed.
|
||||
- **Join card** (`connect_cards.dart`): recent servers as one-click rows
|
||||
that poll `GET /health` every 10 s while the card is open
|
||||
(`health_probe.dart`: online, name, viewer count, "needs update" with
|
||||
the version gate's sentence as its hint), then address, port and
|
||||
password; an invite pasted into the address fills the port too
|
||||
(`addressIn`). Submit state (busy, cancel, the error) is `ConnectFlow`,
|
||||
owned by the shell so an attempt and its error outlive the card; the
|
||||
version gate's refusal says who has to update and, when it is you,
|
||||
offers "Check for the update". The card closes itself once in the
|
||||
friend's server. It says that a friend gives the invite and the
|
||||
password, that no account or Jellyfin is needed and that your own
|
||||
server stays as it is.
|
||||
- **Profile** (`profile_card.dart`, `session/identity.dart`): a card
|
||||
tinted with your colour: picture (hover "Change" opens `CropPicker`:
|
||||
drag, wheel or slider zoom, previews at 88/42/24 px, exports a 256 px
|
||||
@@ -1446,13 +1518,13 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
the `connect_form` box (an old picture path is shrunk once and kept as
|
||||
bytes). Online, a name change (once typing settles) or picture change
|
||||
goes out through `Profile.push` (`coordinator.updateProfile`).
|
||||
- **Welcome** (`welcome.dart`): offline the Library opens with
|
||||
`WelcomeBand` over the source switch, only for newcomers (no Jellyfin
|
||||
account, no recent server, not dismissed with "Don't show again";
|
||||
sizes away when online): the app
|
||||
- **Welcome** (`welcome.dart`): alone on your server the Library opens
|
||||
with `WelcomeBand` over the source switch, only for newcomers (no
|
||||
Jellyfin account, no recent server, not dismissed with "Don't show
|
||||
again"; sizes away once others are there): the app
|
||||
icon, Starsync and "Watch together, in sync", then three `WayCard`s,
|
||||
Join a friend first (filled), Host, Watch solo (opens Files, or
|
||||
Jellyfin when linked); beside the name from 940 px, under it below,
|
||||
Join a friend first (filled), Invite friends, Watch on your own (opens
|
||||
Files, or Jellyfin when linked); beside the name from 940 px, under it below,
|
||||
stacked under 520 px. With no Jellyfin server linked the Jellyfin source
|
||||
is `NoJellyfinPanel`: Files and Link cards ("No account needed") and an
|
||||
optional Jellyfin card whose "Link a server" opens the sign-in in place.
|
||||
@@ -1490,9 +1562,10 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
an isolate, videos grouped by series; series start collapsed, a header
|
||||
click opens one and open ones stay open for the app session
|
||||
(`showFilesFolder` opens a folder from outside). With nobody else
|
||||
watching (`solo` on the Library, Files and Schedule: offline or solo)
|
||||
the buttons say Play, Link has no relay modes and Files no relay notice;
|
||||
offline a put-up starts a solo session. Schedule (`schedule/`) is a week of day columns built
|
||||
in the server (`alone` on the Library, Files and Schedule, D9) the
|
||||
buttons say Play, Link has no relay modes and a link goes up direct,
|
||||
Files has no relay notice; a put-up before your server is up waits for
|
||||
it. Schedule (`schedule/`) is a week of day columns built
|
||||
from AniList's airing schedule (`anilist.dart`: pages cached 10 min in
|
||||
memory and 1 h on disk, stale data shown on 429 or failure) matched
|
||||
against AnimeFlow (`animeflow.dart`, app API with the Jellyfin token;
|
||||
@@ -1530,7 +1603,8 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
Settings (`settings_tab.dart`) has a left nav with icons
|
||||
(icon chips under 760 px) over pages of rounded Material cards
|
||||
(`SettingsPanel`), one help line under every setting: profile, party &
|
||||
network (Host card port and password in the connect form, recent
|
||||
network (your server's port and password, used the next time it is
|
||||
shared, in the connect form; recent
|
||||
servers), player (the shared rows below, the controls' choices, custom
|
||||
shader files),
|
||||
Jellyfin & integrations (linked servers, AnimeFlow's `/me` via
|
||||
@@ -1544,7 +1618,7 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
into videos (the Files extensions, `kindOf`), a folder, an http(s) link,
|
||||
an image or nothing; videos win over folders, folders over images.
|
||||
`HandoffCard` opens as a popover at the pointer: a video goes up through
|
||||
the shell's put-up (Play when alone; offline that starts solo) or adds
|
||||
the shell's put-up (Play when alone) or adds
|
||||
its folder to Files; a folder is added to Files and opened; a link fills
|
||||
Library → Link (with others the relay mode is picked there). In a
|
||||
server, videos and links also get Add to queue / Play next. Images are ignored.
|
||||
@@ -1767,9 +1841,9 @@ Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
|
||||
- Picking an item gives a `JellyfinMedia` (serverId, baseUrl, itemId,
|
||||
mediaSourceId?, title, subtitle, runtime; no credentials). The library
|
||||
remembers the last pick in the `jellyfin` box and puts it up as a ref
|
||||
(`putUpJellyfinMedia`, `swapPicked`); a hosted server gets the relay
|
||||
credential (`relayCredentialFor`: the account of the
|
||||
item's server, else the default one, not expired; its token with
|
||||
(`putUpJellyfinMedia`, `swapPicked`); your server gets the relay
|
||||
credential when it is shared (`relayCredentialFor`: the default
|
||||
account, not expired; its token with
|
||||
exactly `JellyfinAccounts.clientInfo`'s device fields).
|
||||
- Browser location: every navigation saves the route stack per server
|
||||
(`browse:<serverId>` in the `jellyfin` box, `encodeBrowseStack`: ids,
|
||||
@@ -1802,7 +1876,7 @@ Player controls (`lib/src/controls/`): one controls model, two skins.
|
||||
play is always first and fullscreen last. `menuActions` is the ⚙ menu
|
||||
(`ControlAction`, fixed order): chapters, the hidden track lists,
|
||||
Skip 1:30, upscaling shader, the hidden chat, change video, copy link
|
||||
(when `currentLink` has one), reload, mark everyone ready (not solo),
|
||||
(when `currentLink` has one), reload, mark everyone ready (not alone),
|
||||
mark a problem, player settings, leave. Icons, tooltips (with keys) and
|
||||
the clock text (`clockText`) are defined there too, once.
|
||||
- **Shared pieces**: `ControlsFrame` (`frame.dart`): when the controls
|
||||
@@ -1887,10 +1961,10 @@ and the sound engine (500 ms), side by side; on Windows 300 ms more,
|
||||
since media_kit_video frees a video output on a thread of its own after
|
||||
dispose returns, and an output still up when the engine stops blocks the
|
||||
plugin's teardown (`~VideoOutput` waits on a texture callback that never
|
||||
comes). Then `SessionState.leave`
|
||||
comes). Then `Navigation.leave`
|
||||
(Jellyfin stop report, last diagnostics, then `bye`, flushed: the app
|
||||
waits up to 600 ms for the server to close the socket, so others see
|
||||
"left" at once) and the hosted server's stop, 3 s in all. Update and
|
||||
"left" at once) and your server's stop (`YourServer.stop`), 3 s in all. Update and
|
||||
restart leaves without `bye` (`shutDownApp(restart: true)`): the member
|
||||
stays `gone`, and the restarted app, with the same client id, takes it
|
||||
back within the grace when it joins again; it then closes Hive (the
|
||||
@@ -1919,15 +1993,14 @@ dedicated server (`package:logging` loggers named by area: `relay`,
|
||||
- **Where**: the app writes `<app support>/logs/app-YYYY-MM-DD.log`
|
||||
(Linux `~/.local/share/dev.foxyz.starsync/logs`; with a
|
||||
profile `<app support>/profiles/<name>/logs`, see `AppDirs` under
|
||||
"Desktop UI"); its hosted server's records arrive over the isolate's
|
||||
"Desktop UI"); your server's records arrive over the isolate's
|
||||
`events` port and go in the same file as `server.<logger>` (`relogLine`). The dedicated server
|
||||
writes `server-YYYY-MM-DD.log` in `--log-dir`, else `logs` next to
|
||||
`--config`, else `logs` next to the binary (systemd:
|
||||
`$STATE_DIRECTORY/logs`), else `~/.cache/watch-together/logs`; it
|
||||
prints the folder at start. Both also print every line to stdout.
|
||||
The session log (below) sits next to them: the hosting app's server
|
||||
writes it in the app's logs folder (not for a solo session), the
|
||||
dedicated one in its log folder.
|
||||
The session log (below) sits next to them: your server writes it in
|
||||
the app's logs folder, the dedicated one in its log folder.
|
||||
- **Files** (`LogFiles`): one per day, the next `.1.log`, `.2.log` once a
|
||||
file passes 10 MiB; files older than 7 days, then the oldest beyond
|
||||
100 MiB in all (the app's or server's files and the session log's
|
||||
@@ -1974,7 +2047,7 @@ dedicated server (`package:logging` loggers named by area: `relay`,
|
||||
`packages/core/lib/src/diag_share.dart`): one shared log per server so
|
||||
a problem can be read afterwards from every side. While in a session
|
||||
the app (`DiagUploader`, owned by the `Connection`) keeps its own
|
||||
records (not its hosted server's `server.*`, which the server logs
|
||||
records (not your server's `server.*`, which the server logs
|
||||
itself), redacted, and sends them every 10 s from the connection's 1 s
|
||||
heartbeat and when it leaves (before its token goes, at most 1.5 s), as
|
||||
gzip batches to `POST /l/<sid>/<token>` (PROTOCOL
|
||||
|
||||
+35
-22
@@ -86,10 +86,14 @@ Three suites, run from each package's folder:
|
||||
on loopback: auth and version refusal, identity, takeover and grace
|
||||
(`presence_test` on a fake clock), media and relay byte serving with
|
||||
source drops, Jellyfin relay against a fake, queue, session log,
|
||||
isolate `serve`.
|
||||
isolate `serve`, sharing (`share_test`: a listener attached and detached
|
||||
under the same members and Up next, the host key standing in for the
|
||||
password).
|
||||
- `cd apps/desktop && flutter test test/<file>`: widgets (shell, library,
|
||||
controls, settings, schedule), pure app logic (`StateDiff`, name parser,
|
||||
shader list, sound engine with a fake backend) and the real-mpv `*_e2e`
|
||||
shader list, sound engine with a fake backend, invite addresses, the
|
||||
version gate), `singletons_test` (the `.instance` count, see
|
||||
ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e`
|
||||
tests plus `coord_presence`. The whole suite takes a couple of minutes;
|
||||
run it only before a restructure step lands, otherwise only the files
|
||||
related to what you changed.
|
||||
@@ -155,10 +159,10 @@ in [ROADMAP.md](ROADMAP.md)):
|
||||
|---|---|---|
|
||||
| Drop and reconnect while sharing a relay | server `flaky_link_test` (blackhole: `gone` with ready, queue item and relay kept, the same member back; reset), `sync_wave_test` (takeover by client id, `replaced`, `stale`), `presence_test` (grace on a fake clock, restart), `relay_test` "the source drops (D6)" (`sourceGone`, buffer served, same `info` continues, another 4002, a second channel 4003, leave ends it); desktop `relay_e2e` "the source drops uncleanly and comes back" (gone past the server's silence, the relay kept, the source re-dials and continues), `coord_presence` (a half-open drop while playing: same member, no reopen, the state kept; 10 flaps: at most one lost/back sound; `replaced` is terminal; a restarted server gets the ready again), `connection_test` (the hello's id, `seq`, caps, `feedAfter`) | |
|
||||
| Join mid-playback | core `sync_engine_test` "D2: joiners" (never changes the transport while playing, at most 15 s while held, joining ends on error or timeout), `presence_test` (the table); server `relay_test` "a late joiner gets its own /r/ URL"; desktop `coord_presence` (the returning member plays on in sync) | |
|
||||
| Sit out / Away and back | core `sync_engine_test` "away" (only oneself comes back, through joining), "return" (away survives a drop), `presence_test` (`isAlone`); server `hardening_test` (anyone sets anyone away, `forbidden`); desktop `server_isolate_test` (host sat out through `presence`), `notice_table_test` (the feed actions one-to-one, matched by id; sitting out gets only chat, nudges, "starting now", Up next and the chosen joins and leaves; alone no social sounds), `session_logic_test` (auto sit-out on a fake clock: never alone, a manual away stays), `player_status_test` / `shell_test` (the ready counter leaves away and gone out) | step 6: not tracked as watched (P7) |
|
||||
| Sit out / Away and back | core `sync_engine_test` "away" (only oneself comes back, through joining), "return" (away survives a drop), `presence_test` (`isAlone`); server `hardening_test` (anyone sets anyone away, `forbidden`); desktop `server_isolate_test` (the host sat out through `presence` on your shared server), `notice_table_test` (the feed actions one-to-one, matched by id; sitting out gets only chat, nudges, "starting now", Up next and the chosen joins and leaves; alone no social sounds), `session_logic_test` (auto sit-out on a fake clock: never alone, a manual away stays), `player_status_test` / `shell_test` (the ready counter leaves away and gone out) | step 6: not tracked as watched (P7) |
|
||||
| Swap, also while someone is Away or gone | core `sync_engine_test` "swap" (a gone member stays out of the gate, comes back joining), "away" (a swap leaves it away), `queue_test` (a swap takes its item, drops the `auto` one); server `queue_test` (a gone adder's item waits), `relay_test` (a swap drops the buffer); desktop `sync_playback_e2e` | step 5: no commands reach a player mid-switch (P15) |
|
||||
| Up next advance | core `queue_test` (skip, play request timeout, `auto` and `cancel`); server `queue_test`, `relay_test` "warm relay"; desktop `jellyfin_session_test` (auto-advance swapper, end card countdown), `queue_switch_e2e` | step 5: preparing it through the media pipeline; step 6: the countdown as server state (`state.advance`) |
|
||||
| Close the app cleanly, incl. `bye` | server `lifecycle_test` (a close keeps the member `gone`, `bye` leaves at once, server stop closes sockets); desktop `server_isolate_test` (stop frees the port), `coord_presence` (cancelled host setup releases the port; leave sends `bye` and others see "left" at once, leaving for a restart keeps the member `gone`) | step 5: the Windows close smoke job (P32) |
|
||||
| Close the app cleanly, incl. `bye` | server `lifecycle_test` (a close keeps the member `gone`, `bye` leaves at once, server stop closes sockets); desktop `server_isolate_test` (stop frees the port; Stop sharing tells guests with 4016 and keeps you and Up next; a friend's server and back keeps yours), `coord_presence` (Stop sharing and your server's stop release their ports; leave sends `bye` and others see "left" at once, leaving for a restart keeps the member `gone`) | step 5: the Windows close smoke job (P32) |
|
||||
|
||||
A test file whose first line is `// restructure: rewrite in step N` pins
|
||||
behaviour that step of the restructure ([ROADMAP.md](ROADMAP.md)) changes;
|
||||
@@ -223,19 +227,22 @@ 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
|
||||
## Debugging your 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
|
||||
carry `server.<logger>` (e.g. `server.relay`). 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. Hot restart kills the server isolate (flutter_tools kills every
|
||||
non-UI isolate first), so a `[server] server crashed: the server stopped
|
||||
unexpectedly` line just before it is expected. Without the app:
|
||||
`dart run packages/server/bin/server.dart`.
|
||||
The app's own server runs on its own isolate (`ServerIsolate`,
|
||||
`apps/desktop/lib/src/state/server_isolate.dart`, owned by `YourServer`)
|
||||
in the same process, from start to close. Breakpoints in
|
||||
`packages/server` work under `flutter run`: the debugger attaches to
|
||||
spawned isolates too (it shows as `server`). Its log lines carry
|
||||
`server.<logger>` (e.g. `server.relay`). Hot reload reloads the server
|
||||
isolate's code too, but its state (members, clock, sockets) survives;
|
||||
changes to startup code (`SyncServer.start`, `share`, routes) need a
|
||||
restart of the app. Hot restart kills the server isolate (flutter_tools
|
||||
kills every non-UI isolate first), so a `[server] server crashed: the
|
||||
server stopped unexpectedly` line just before it is expected. Its
|
||||
loopback port changes every run (the log's `listening on` line has it);
|
||||
`STARSYNC_PROFILE` gives a second window its own server. Without the
|
||||
app: `dart run packages/server/bin/server.dart`.
|
||||
|
||||
## Logs
|
||||
|
||||
@@ -243,7 +250,7 @@ After a session, read the log files rather than reproducing
|
||||
("Logging" in [ARCHITECTURE.md](ARCHITECTURE.md) has what goes in them):
|
||||
the app's are `~/.local/share/dev.foxyz.starsync/logs/app-*.log`
|
||||
on Linux (on Windows `%APPDATA%\foxyz\Starsync\logs`; Settings → About → "Open logs
|
||||
folder" finds it anywhere), with the hosted server's lines
|
||||
folder" finds it anywhere), with your server's lines
|
||||
in the same file; a dedicated server prints its folder at start
|
||||
(`--log-dir`). Users send theirs with Settings → About → "Copy
|
||||
diagnostics".
|
||||
@@ -285,8 +292,8 @@ is off), not software rendering.
|
||||
- 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.
|
||||
`shared: true` (both listeners) so a new one 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
|
||||
@@ -318,9 +325,15 @@ is off), not software rendering.
|
||||
raw pointer observer (`DoubleClickListener`).
|
||||
- The window is frameless: the party strip is the only drag area and holds
|
||||
the window buttons.
|
||||
- Signal-backed singletons (`Profile`, `SessionState`) live across tests;
|
||||
never cache a `Future` in them (each test runs in its own zone, and a
|
||||
Future from an earlier test never completes in the next).
|
||||
- Signal-backed singletons (`Profile`, `AppSettings`) and a test file's
|
||||
`AppGraph` live across tests; never cache a `Future` in them (each test
|
||||
runs in its own zone, and a Future from an earlier test never completes
|
||||
in the next).
|
||||
- Widgets read the systems from `AppScope`: a widget test pumps its tree
|
||||
under one (`scoped(graph, ...)` from `test/test_graph.dart`; one
|
||||
`testGraph()` per file). A `State` that needs a system in `dispose`
|
||||
reads it before (`late final` touched in `initState`): the scope cannot
|
||||
be looked up from a deactivated element.
|
||||
- Chat arrives twice from the server (see PROTOCOL.md); the client drops the
|
||||
textless feed copy.
|
||||
- Tab traversal is disabled in the session player on purpose; Tab toggles
|
||||
|
||||
+10
-8
@@ -40,18 +40,20 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
|
||||
|
||||
## 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.
|
||||
- **Your server**: every app runs its own server, quietly, from start to
|
||||
close, reachable only from that computer. You are on it when you are
|
||||
not in a friend's, so watching alone works like a session (progress,
|
||||
next episode). Invite opens it to friends on your network (a second
|
||||
listener on the same server: nothing restarts, the video and Up next
|
||||
carry on) and copies an invite; Stop sharing closes it again.
|
||||
- **Dedicated server**: `starsync-server`, a separate program in the
|
||||
release tarball, for an always-on box. See the README.
|
||||
|
||||
Friends join either way with Join a friend in the strip at the top of the
|
||||
window; guests need no Jellyfin or account.
|
||||
|
||||
- **Watch solo**: put something up without joining or hosting and the
|
||||
app hosts a private server reachable only from this computer, so
|
||||
watching alone works like a session (progress, next episode). Invite
|
||||
friends turns it into a normal hosted server, video and position kept.
|
||||
window; guests need no Jellyfin or account. Before connecting, the app
|
||||
checks the server's version and says who has to update when they do not
|
||||
match. Joining a friend leaves your server (it stays up); leaving theirs
|
||||
brings you back.
|
||||
|
||||
## One session, start to finish
|
||||
|
||||
|
||||
+16
-7
@@ -28,7 +28,9 @@ yet; the restructure step named next to them does.
|
||||
`name`, `proto` (= `protoMax`), `protoMin`, `protoMax`, `version` (the
|
||||
server's app version, e.g. `1.3.0`), `tls` (**reserved**: always false;
|
||||
a server with TLS would serve the same endpoints over `wss://` /
|
||||
`https://`), `viewers`, `bytesServed`, `uptimeSecs`.
|
||||
`https://`), `viewers`, `bytesServed`, `uptimeSecs`. The app reads it
|
||||
before joining and does not dial a server whose range does not meet its
|
||||
own (the version gate, see ARCHITECTURE "Server lifecycle").
|
||||
- `GET /m/<sid>/<token>/<name>` serves the host's file (Range table in
|
||||
ARCHITECTURE.md). `GET /j/<sid>/<token>/<itemId>` relays the current
|
||||
Jellyfin media and Up next's first one (see "Jellyfin media" below).
|
||||
@@ -80,6 +82,7 @@ yet; the restructure step named next to them does.
|
||||
| 4013 | `/sync` | `err` `replaced`: another connection of the same client holds the member |
|
||||
| 4014 | `/sync` | refused: `err` `stale` |
|
||||
| 4015 | `/sync` | silent for 15 s: the member is `gone` |
|
||||
| 4016 | `/sync` | the hosting app stopped sharing its server: the member has left (`leave`, reason `closed`); the client does not reconnect |
|
||||
|
||||
## Client to server
|
||||
|
||||
@@ -114,12 +117,15 @@ Field notes:
|
||||
and refuses with `err` `version` when the ranges do not meet.
|
||||
`hello.media` is ignored; a proto 16 client still sends `{}` so a proto
|
||||
15 server, which requires it, answers with its own `proto mismatch`.
|
||||
- `hello.secret`: the server password; a server with an empty secret is open.
|
||||
- `hello.secret`: the server password; a server with an empty secret is
|
||||
open. The password may change while members are in (the hosting app
|
||||
sets it when it opens its server to friends).
|
||||
- `hello.hostKey` (optional, omitted when empty): the hosting app
|
||||
generates a random key per hosting, gives it to its server and sends it
|
||||
generates a random key per run, gives it to its server and sends it
|
||||
from its own client. The member whose key equals the server's is the host
|
||||
(`members[].host`). A server without a key (the dedicated server) has no
|
||||
host. Where the connection comes from does not matter.
|
||||
(`members[].host`), and its hello needs no `secret`. A server without a
|
||||
key (the dedicated server) has no host. Where the connection comes from
|
||||
does not matter.
|
||||
- `hello.client` (optional): a random id per install, 16 to 64
|
||||
characters of `[A-Za-z0-9_-]` (the app uses 22 characters of
|
||||
base64url), else `err` `bad`. Private: never broadcast, never logged.
|
||||
@@ -184,7 +190,10 @@ Field notes:
|
||||
feed line. Unknown targets and no-op changes are ignored.
|
||||
- `bye`: the member leaves now (the Leave button, the app closing): feed
|
||||
`leave` (reason `bye`), its token stops working, the socket closes
|
||||
(1000). Anything else that ends a socket keeps the member (`gone`).
|
||||
(1000). Anything else that ends a socket keeps the member (`gone`),
|
||||
except the hosting app closing its server to friends: everyone but the
|
||||
host leaves at once (feed `leave`, reason `closed`) and the socket
|
||||
closes with 4016.
|
||||
- `nudge`: the server sends `{"t":"nudge","from":<memberId>,"name":<name>}`
|
||||
to `target` only (no feed). At most 3 per second and 20 per minute per
|
||||
sender; the rest, nudges to oneself and to unknown targets are dropped
|
||||
@@ -284,7 +293,7 @@ Field notes:
|
||||
server's own lines; omitted when none): match people by id, not by
|
||||
name. `action` values: `ready`, `unready`, `force-ready`, `force-unready`,
|
||||
`force-play`, `force-hold`, `join` (a new member only), `leave` (for
|
||||
good: `reason` `bye` or `grace`), `gone` (10 s after the member went
|
||||
good: `reason` `bye`, `grace` or `closed`), `gone` (10 s after the member went
|
||||
`gone`; a quicker return says nothing), `return` (back within the grace
|
||||
after a `gone` line), `away` (`target` the member set away, absent when
|
||||
the actor set itself; `reason` `idle`/`minimised`/`bye`, empty for
|
||||
|
||||
+17
-14
@@ -44,10 +44,6 @@ Problems are numbered; delete a line when its fix ships.
|
||||
the player instead of being one media pipeline.
|
||||
- P18 Bilibili "no video formats" with browser cookies set; the hint
|
||||
wording misleads (D11).
|
||||
- P22 Friends on different versions (D8). (P20 version visible and P21
|
||||
update band: fixed in 1.2.1-dev.3; check the band in real use.)
|
||||
- P23 Solo mode you enter and leave; going hosted loses the queue (D9).
|
||||
- P24 The invite address list is unhelpful (shows the proxy's 198.18.x).
|
||||
- P25 ssh to Gitea blocked through the proxy (pushes go over https).
|
||||
- P28 Log noise: "Failed to create file cache"; an idle relay channel for
|
||||
Jellyfin items everyone has an account for.
|
||||
@@ -75,11 +71,6 @@ the automatic continuation and countdown, relay state), the device
|
||||
(client id, library and folders, match corrections, watch history,
|
||||
settings, notification prefs, what this app is sourcing) and the
|
||||
integrations (Jellyfin played/resume, AniList list).
|
||||
3. Your server: one hub, listeners attached and detached without members
|
||||
reconnecting (old isolate path kept until that's proven); Invite as a
|
||||
value (filtered addresses, version gate from `/health.version`, an `fp`
|
||||
field reserved for pinned certificates later); `SessionState` is
|
||||
deleted here. Fixes P22 P23 P24; D9.
|
||||
4. Library + Media identity: L1 local grouping (kinds, folder hints,
|
||||
gaps, merge across folders) and L2 match (AniList/TVDB, art, names,
|
||||
manual pin, cache); `MediaRef` in swap/queue media. Fixes P34.
|
||||
@@ -96,11 +87,11 @@ integrations (Jellyfin played/resume, AniList list).
|
||||
7. Release 1.3.0: publish the manifest (protocol 16 makes it a required
|
||||
update for 1.2.x) and update the VPS dedicated server in the same
|
||||
window; check a 1.2.x app gets a clear "update the app" message.
|
||||
Composition root: an `AppGraph` built in `main.dart` only constructs and
|
||||
disposes systems (no logic, no signals); systems get dependencies through
|
||||
constructors, widgets through one `AppScope`; a test fails if the count
|
||||
of `.instance` singletons grows (today: SessionState 94, AppSettings 51,
|
||||
JellyfinAccounts 10, Profile 7).
|
||||
Composition root (in place, ARCHITECTURE "Composition root"): the
|
||||
remaining `.instance` singletons move into `AppGraph` as the steps touch
|
||||
them; `singletons_test` holds the count (today: AppSettings 55,
|
||||
JellyfinAccounts 10, UpdateController 8, Profile 8, FolderStore 6,
|
||||
AppYtDlp 4, RecentLinks 3).
|
||||
Not in the restructure: P25 (ops).
|
||||
|
||||
|
||||
@@ -130,6 +121,18 @@ restarted app, but the app starts on Home (it does not rejoin by itself)
|
||||
and does not remember what it was passing on, so a relay it served ends
|
||||
with its grace unless it is put up again; worth doing only if updates
|
||||
ever happen mid-session (D8 says never during playback).
|
||||
Left of step 3 (your server): a human check of Invite (Linux and, at
|
||||
1.3.0, Windows' firewall prompt, now on Invite rather than at start;
|
||||
that the invite skips the proxy's 198.18.x here) and of the update band
|
||||
from 1.2.1-dev.3 in real use. No public address lookup or UPnP: the
|
||||
members card only says friends elsewhere need the port forwarded. Your
|
||||
server's `/health` name is the profile name at start (a rename shows
|
||||
after a restart). Guests' Jellyfin relay uses the default account (no
|
||||
longer the put-up item's server). Joining a friend while others are on
|
||||
your server leaves them there without you, unwarned; a join refused
|
||||
after dialling (wrong password) costs your own member there (`bye`, back
|
||||
as a new member). Your server writes its session log even when you are
|
||||
alone.
|
||||
Left of step 2: system notifications need a human check (Linux: the
|
||||
notification shows and a click brings the window up, also on Wayland,
|
||||
where focus may be refused without an activation token; Windows: toasts
|
||||
|
||||
Reference in new issue
Block a user