Restructure step 3: your server, Invite, version gate, AppGraph
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m7s
ci / updater-windows (push) Failing after 6m4s

- 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:
foxyzandClaude Opus 5.5 committed 2026-10-15 22:52:52 +03:00
1 parent 629be0d9ed
commit 4d00b7455e
86 files changed
+2993 -2071

No files matched your search

+177 -104
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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