Restructure step 1: lasting identity, presence and protocol 16
ci / core (push) Successful in 28s
ci / server (push) Successful in 1m5s
ci / updater-windows (push) Successful in 5m48s

Server and core: version ranges on both sides with a coded refusal; a
client id maps to the same member and token for the server's life; a
drop is `gone` for a 10-minute grace keeping ready, presence, queue
items, relay and avatar; `bye` leaves at once; ordered takeover (seq,
replaced/stale); presence joining/in/away/gone with one PresencePolicy;
joiners never pause playback and hold a held gate at most ~15 s;
state.hold; feed ids, seq and replay; the relay lives through its
source's grace and continues on re-dial; queue `by` lasting, auto
items and cancel; MediaRef carried; client ids never logged.

App: persisted client id and seq; Connection with unstable/lost/
replaced states, backoff with jitter, no lost/back pair per flap; a drop
keeps the session (state, player, media, relay source); presence in the
strip and members card; sit out = away intent; feed matched by id;
auto sit-out (idle when not playing, minimised for a while; never when
alone) replaces auto-unready; bye on close and Leave, not on update
restart; relay source re-dials through its grace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 21:39:47 +03:00
1 parent 51c1c5ab78
commit 9f2586b7ff
85 files changed
+5198 -1902

No files matched your search

+236 -134
View File
@@ -66,8 +66,10 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a
SIGINT and SIGTERM stop it. A Flutter binary cannot run without a
display, which is why the server is its own build.
`SyncServer` runs a 1 s heartbeat: stale sweep, closed-socket sweep, silent
socket drop, state + feed broadcast. There is one session, id `default`.
`SyncServer` runs a 1 s heartbeat (`ServerHub.heartbeat`): closed-socket
sweep, the engine's sweep (silence, joining, gone notices, grace),
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.
@@ -77,8 +79,8 @@ 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 sit each
other out ("Sitting out" below), the host included, since the hosted
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.
## Sync model
@@ -108,47 +110,80 @@ Loosely modeled on Syncplay, with a different readiness design.
Besides the 1 s tick, a report goes out right after a new media is
open and, while HELD, the moment the buffer reaches `need`, so the gate
does not wait for the next tick.
- **Ready gate** (HELD to PLAYING): the in-set is non-empty and every
in-set member is ready, fresh and has `buffered >= need`. `need` is 4 s
- **Ready gate** (HELD to PLAYING): the members that count for the gate
(`SyncEngine.countsForGate`: in the set, no player error, and their
presence counts, see "Presence") are not empty and every one is ready
and has `buffered >= need`. `need` is 4 s
(`minBufferSecs`), or near the end what is left minus 2 s
(`endSlackSecs`, floor 0), since mpv cannot buffer past the end. So a
lone viewer plays alone; with others present everyone must ready.
- **In-set** (who the gate waits for): a member joins it on joining, or
on coming back from stale, while not PLAYING (any media, the one the
server started with included); joining while PLAYING it stays out
(`ready=false`) until it readies: a late joiner never pauses anyone. A
swap puts every fresh member in the set and keeps everyone's ready flag,
so the next video starts once everyone ready has buffered (the client
setting "Unready when the video changes", `AppSettings.unreadyOnSwap`,
default off, sends `ready=false` itself on a new `mediaId`; not for the
first state of a session, and a reconnect does not restore a ready that
a swap meanwhile would have dropped). Stale, leave, sitting out and a
player error remove from the set.
While HELD, `state.hold` says why (`HoldReason`: a joiner within its
bound, someone unready, a short buffer, the same with the relay's
source gone, or nobody to start).
- **In-set** (`EngineMember.inSet`, who the gate may wait for): a member
joins it on joining, or on coming back from `gone` or `away`, while not
PLAYING (any media, the one the server started with included); joining
while PLAYING it stays out (`ready=false`) until it readies. A swap puts
every member in the set and keeps everyone's ready flag, so the next
video starts once everyone ready has buffered (the client setting
"Unready when the video changes", `AppSettings.unreadyOnSwap`, default
off, sends `ready=false` itself on a new `mediaId`; not for the first
state of a session, and a reconnect does not restore a ready that a
swap meanwhile would have dropped). A player error removes from the
set; presence decides whether a member in the set counts.
- **Identity**: an install sends a random client id (`hello.client`,
private: never broadcast, never logged) and a `seq` that rises with
every connection. The server maps the client id to one member for its
life: the same id and token on every reconnect (`welcome.resumed`),
so the member keeps its ready flag, presence, queue items (`by`), the
relay it serves and its picture. A hello takes the member over when its
`seq` is not older than the member's; the older socket gets `replaced`
and closes, an older hello gets `stale` (so two windows of one install
cannot fight: the newest wins, the client stops on `replaced`).
`welcome.instance` changes per server boot, so a client tells "the
server restarted" from "my grace ran out" (both: `resumed` false).
- **Presence** (`Presence`, `members[].presence`; D1, D2):
`joining` -> `in`, `away`, `gone`. What each counts for is one table,
`PresencePolicy.countsFor(presence, Purpose)` in core
(`presence.dart`), shared by the server and the app: `in` counts for
the gate, the ready counter, watched tracking, preloads and social
notifications; `joining` for all but tracking, and for the gate only
while HELD and for at most `joinerHold` (15 s) after it started
joining, never while PLAYING; `away` and `gone` for none.
- `joining`: a new member, one back from `gone`, or one back `in` from
`away`, while media is up (with nothing up it is `in` at once). It
loads in the background; a report that holds the current media at
the server position (within the 5 s seek threshold) with `need`
buffered makes it `in`, already in sync, so a joiner during playback
never pauses anyone (D2). A report error or `joiningTimeout` (60 s)
ends joining too. Coming `in` while PLAYING it stays out of the set
until it is ready (its own auto-ready follows its buffer); while
HELD it keeps its place in the set. While HELD a joiner holds the
start at most 15 s (bounded hold), then the others go on without it.
- `away` (sat out): anyone sets anyone away (`presence` `away`, the
host included); only the member itself comes back (`in`, through
`joining`). Its ready flag and reports are kept but never count;
`force` leaves it alone; a swap leaves it away. Away survives a drop
(back from `gone` it is `away` again).
- `gone`: its socket closed or fell silent. Kept for the grace
(`Liveness.grace`, 10 min) with everything it had, its token working
(`/m` `/r` `/j` URLs, the relay channel); out of the gate. The feed
says `gone` only after `goneNotice` (10 s), so a quick reconnect is
silent; back after that line it says `return`. After the grace it
leaves (feed `leave`, reason `grace`); `bye` leaves at once (reason
`bye`). Only a hello brings it back; frames of a socket that no longer
holds the member are ignored.
- **Player error** (`EngineMember.error`, `report.error`,
`state.members[].error`): a member whose player cannot open the current
media says why in its reports; the server takes it out of the set at
once (like sitting out, but automatic: `ready`/`force` do not put it
back, its ready flag is kept), so the others start without it. A report
without it (the media opened, after a reload too) or a swap clears it:
back in the set like a join while not PLAYING; while PLAYING from its
next frame if it is ready (the others then hold for its buffer, as for a
stale member coming back), else from its `ready`.
- **Sitting out** (`EngineMember.out`, `state.members[].out`): a member
stays connected (chat, feed, party, nudges) but is never in the set and
never ready: no `ready` or `force` reaches it, a swap leaves it out, its
reports keep it fresh but their `buffered` counts as 0. Anyone can sit
anyone out (`out` with `out:true`, the host included); only the member
itself comes back (`out:false`): it is then in the set like a fresh join
(unless PLAYING) and still unready. The app: an out member's player has
nothing open (no stream or relay pulls; a relay it serves goes on) and
shows the join-back card (cover, title, episode, the server position and
playing/paused). "Join back" or Space sends `out:false` and then
`ready`: at once while HELD (the gate waits for its buffer), while
PLAYING once the player has buffered `need` (at most 15 s), so the others
do not pause for the load. Putting something up while out comes back
first. A reconnect that lands as a new member sits out again.
- **Pause**: while PLAYING, any in-set member who becomes not ready, under
`need` buffered or stale flips the server to HELD (feed action `pause` by
once (like away, but automatic: `ready`/`force` do not put it
back, its ready flag is kept), so the others start without it; it also
ends a `joining`. A report without it (the media opened, after a reload
too) or a swap clears it: back in the set like a join while not
PLAYING; while PLAYING at once if it is ready (the others then hold for
its buffer), else from its `ready`.
- **Pause**: while PLAYING, any member counting for the gate who becomes
not ready or under `need` buffered flips the server to HELD (feed action `pause` by
`server`). Recovery resumes by itself when the gate passes.
- **End**: while PLAYING with a known duration, reaching it sets FINISHED
(feed `finished` by `server`, position = duration). Clients pause where they
@@ -178,36 +213,48 @@ Loosely modeled on Syncplay, with a different readiness design.
landed after 1.5 s is re-applied once (`_checkSeekLanded`), to the server
position recomputed now, and only while its event is still current and the
user is not seeking.
- **Liveness**: WS ping every 5 s; silent over 15 s means stale (out of the
gate, `stale` feed); over 30 s the socket is closed and the member removed.
The durations are a `Liveness` (core, `sync_engine.dart`; defaults
`RelayConstants.staleAfterSecs` / `dropAfterSecs` / `pingIntervalSecs`)
given to `SyncEngine(liveness:)` and `SyncServer(liveness:)`; stale and
drop are measured on the engine's clock (`SyncEngine.clock`,
`SyncServer(clock:)`) by the 1 s heartbeat sweep, so a test with a fake
clock steps through them; the ping is the socket's own real timer.
A stale member goes back into the set with its next frame if it is ready
(also one stale at a swap) or the server is not PLAYING.
Closed sockets are polled with `isClosed` every second: Relic's server-side
`onDone` does not fire for dead peers, so presence never relies on it.
- **Resume**: a reconnecting client sends its previous token as
`hello.resume`. If the server still has that member (an unclean drop it has
not noticed), the new socket takes it over: same token, member id, ready
flag and media URL (the player keeps streaming), no join/leave feed, and the
dead socket is closed without removing the member. An unknown token joins
as a new member.
- **Client policies**: no state broadcast for 5 s means connection lost, clear
the server state and reconnect every 1.5 s until cancelled (an epoch counter makes
a cancelled attempt stale). A closed socket means lost at once (the
server state stays until the 5 s pass). After a reconnect the ready flag is
restored from the server's state. Cancel during setup aborts the join.
Closing the window leaves gracefully (see "App close" under Desktop UI).
Where it lives: see "Connection" below.
- **Idle** (`IdleTracker`): measured from the later of the last input and the
moment playback stopped. While HELD and self ready: warning at 8 min,
auto-unready at 10 min, and never less than 2 min after the warning.
With "When idle, sit out instead of just unready"
(`AppSettings.idleSitsOut`, default off) the 10 min step sits out.
- **Liveness**: WS ping every 5 s; a socket without any frame for 15 s
(`silence`) is closed (4015) and its member goes `gone`; the grace, the
`gone` notice, the joiner bound and the joining timeout follow. The
durations are a `Liveness` (core, `sync_engine.dart`; defaults
`RelayConstants.silenceSecs` / `graceSecs` / `goneNoticeSecs` /
`joinerHoldSecs` / `joiningTimeoutSecs` / `pingIntervalSecs`) given to
`SyncEngine(liveness:)` and `SyncServer(liveness:)`; all but the ping
are measured on the engine's clock (`SyncEngine.clock`,
`SyncServer(clock:)`) by the 1 s heartbeat sweep, so a test with a
fake clock steps through them; the ping is the socket's own real
timer. Any frame counts (`SyncEngine.touch`). Closed sockets are polled
with `isClosed` every second: Relic's server-side `onDone` does not
fire for dead peers, so presence never relies on it. A half-open link
is caught by the silence.
- **Client policies**: a drop is not the end of the session. The app
keeps the last state (`SessionCoordinator.stateStale` while it
reconnects or after the connection ended; the strip says
"reconnecting…"), the player and its media, and a relay it
serves; it reconnects with backoff and jitter and comes back as the same
member (see "Connection" below). No frame for 3 s is "connection
unstable", 9 s is lost (well under the server's 15 s). A welcome that
resumed needs nothing re-sent; a new member (grace over, server
restarted) gets the app's ready and sitting out again, and the media
opens again only when it changed (or its URL named the old token). The
app's own `ping` every second keeps it under the server's silence limit
even with nothing playing. Cancel during setup aborts the join. Leave
and closing the window send `bye` (see "App close" under Desktop UI);
Update and restart does not.
- **Auto sit-out** (`AutoSitOut`, `session/auto_sit_out.dart`; Settings
"Auto sit-out", `AppSettings.autoSitOut`, default on): not playing and
no input for 5 min sets this member `away` (reason `idle`); minimised
or hidden for 10 min does, playing or not (`minimised`): while playing
idle input does not count, and watching is activity, so a pause after
a long watch starts a fresh clock. Input (any key or pointer use in the
window, `markInput`, from `AppShell` and the controls) or the window's
focus brings
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
`ShellPort.watchActivity` (focused, unfocused, minimised, hidden); the
timings are `AutoSitOutTimes`.
- **Nudge**: `nudge` names a member; the server passes it to that member
only (no feed), at most 3 per second and 20 per minute per sender
(`nudgesPerSec`, `nudgesPerMin`; the rest is dropped silently). The
@@ -227,41 +274,66 @@ Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit.
`Connection` (`session/connection.dart`) is the link to the server, made
in the composition root (`SessionState.connection`) and passed to the
`SessionCoordinator`. It owns the `SyncClient` socket, the first connect
(`begin` + `dial`), reconnects (`reconnectDelay`, 1.5 s; the previous
token as `hello.resume`), the epoch (`invalidate`, `isCurrent`) that makes
a cancelled connect or attempt stale, the 1 s heartbeat (clock `ping`,
the diagnostics tick, the silence check: `silenceLimit`, 5 s), the
(`begin` + `dial`), reconnects (`delayFor`: 1 s, doubling per failed
attempt up to 15 s, each ±20 % jitter), the epoch (`invalidate`,
`isCurrent`) that makes a cancelled connect or attempt stale, the 1 s
heartbeat (clock `ping`, the diagnostics tick, the silence check), the
diagnostics uploader (`diag`, started with the session, stopped by
`close` before the socket), "Stop reconnecting" (`cancelReconnect`),
"Retry" (`retry`) and setup cancel (`cancelConnect`). The `hello` is
built by an injectable `HelloBuilder` (`defaultHello`). The silence is
measured on `Connection.now` (injectable, UTC wall clock by default); the
heartbeat and the reconnect delay are real timers. The incoming
streams (`states`, `feeds`, `nudges`, `errors`), the send methods, the
`token`, `memberId` and `serverNow()` are read through it.
"Retry" (`retry`) and setup cancel (`cancelConnect`).
Each hello is built by an injectable `HelloBuilder` (`defaultHello`):
this build's version range (`RelayConstants.protoMin` .. `protoMax`), the
install's `ClientIdentity` (`session/identity.dart`: a random 22
character base64url client id and a `seq` that rises with every
connection, both in the settings box, so per data folder and per
`STARSYNC_PROFILE`; tests get a fresh in-memory one per `Connection`),
`app` (the app version), `platform`, `caps` (source always; resolve when
the app's yt-dlp is there; jellyfin when an account is linked, checked by
the coordinator at each connect) and, on a reconnect to a server seen
before, `feedAfter` (the last `feed.seq`). The replay (`welcome.feed`)
reaches the activity log only, marked `replayed`; a welcome of another
`instance` (a restarted server) drops it and counts from 1 again.
`Connection.state` (a signal of the sealed `ConnState`): `ConnClosed`
(before a connect, after leave or a cancel), `ConnConnecting` (the first
dial), `ConnOnline` (welcome received), `ConnLost` (dropped: the socket
closed or the silence limit passed; the next attempt is scheduled),
`ConnReconnecting(attempt)` (an attempt dialling, and after it failed
until the next one), `ConnRefused(code, message)` (the first dial failed:
`version`, `secret` or empty, and the plain-words reason; nothing
retries) and `ConnReplaced` (not produced yet). Every change notifies,
a repeat too. The coordinator maps it to `health` (`connectionHealth`:
"connecting…", "connected", "connection lost — retrying…",
"reconnecting…", the reason; closed writes nothing, a cancel writes
"cancelled", "Retry" "retrying…") and to `reconnecting` (lost or
reconnecting). The first drop of a connection raises `ConnectionLost`,
the reconnect that follows `ConnectionBack` (each once per drop, through
the Notifier).
dial), `ConnOnline` (welcome received, frames arrive), `ConnUnstable`
(the same socket, nothing for `unstableAfter`, 3 s), `ConnLost` (the
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`
`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
by default); the heartbeat and the reconnect delay are real timers.
The session's part of a reconnect is the coordinator's
`onReconnect` handler: before an attempt dials it reads what to restore
(ready, sat out, the media seen), once online it opens the media again
if its URL changed, sends `out`/`ready` back and restarts the session's
ticker. The silence (`Connection.silence`) clears the coordinator's
server state (`_onConnectionSilent`).
Refusals in plain words (`connectErrorText`, `versionText`): `version`
says whether the host runs an older or a newer Starsync (from the
server's `min`/`max`; a proto 15 server's codeless `proto mismatch` is
always an older host), `secret` a wrong password, `replaced` "Starsync
was opened in another window". Close code 4015 (the server found it
silent) and any other drop reconnect.
The coordinator maps the state to `health` (`connectionHealth`:
"connecting…", "connected", "connection unstable…", "connection lost —
retrying…", "reconnecting…", the reason; closed writes nothing, a cancel
writes "cancelled", "Retry" "retrying…"), to `reconnecting` (lost or
reconnecting), `unstable` and `stateStale`. A drop that lasted
`lostSoundAfter` (4 s; a silent link counts from its last frame) raises
`ConnectionLost`, the reconnect that follows `ConnectionBack` (each once
per drop, through the Notifier): a quick flap makes no sound.
The session's part of a welcome is the coordinator's `onWelcome`
handler (`_onWelcome`), called before the welcome's state goes out: the
first welcome and a resumed one need nothing; a new member after a drop
gets the app's intent again (`ready`, `presence` `away`) and the media is
forgotten (opened again from the state) only when it changed. The
incoming streams (`states`, `feeds`, `nudges`, `errors`), the send
methods, the `token`, `memberId` and `serverNow()` are read through it.
`close(bye:)` sends `bye` and waits briefly for the server's close, so
the frame goes out before the socket does.
## Media
@@ -425,8 +497,9 @@ output (`generation`), volume (and so mute) and shaders carry over, and
the old player is paused and disposed once the view let go of its
texture (as on leave). A fresh player waits for the previous old one to
be gone, so at most one old player lingers. The first open after join or
Reload video uses the player just created; a reconnect or joining back to
the media the player held keeps it (`_openedFor`). Cost (`SwitchTrace`
Reload video uses the player just created; joining back to the media the
player held keeps it (`_openedFor`); a dropped connection never touches
the player. Cost (`SwitchTrace`
`player`, headless, so without the `VideoController`): 45 to 65 ms per
swap for one app in the process (mpv created and initialised, then
pinned), about 150 ms with three in one process
@@ -436,8 +509,7 @@ switch of about 1.5 s. `openUrl` opens with `play: false`; play/pause/seek/rate
ready) or the user's own seeks (see Sync model). A server seek within 0.5 s of the current position is skipped. `leave()`
stops the player (otherwise audio keeps playing). On media change
(`mediaId` bump) the coordinator stops the previous media at once (no old
picture or sound while the new one resolves, is fetched or opens; a
reconnect to the media the player holds keeps it), then opens the new URL
picture or sound while the new one resolves, is fetched or opens), then opens the new URL
(or promotes the preload holding it) and `StateDiff` seeks to 0, holds
and resets rate.
@@ -700,12 +772,24 @@ How bytes move:
the server has); Up next preloads wait the same way. The putter starts
its source before opening its own player, and the source reads its
tracks' sizes at once.
- Source away: viewers keep playing from the buffer; a request waits up
to 60 s for missing bytes. The source reconnects with backoff (same
token after a resume); a different size (or piece count and roles)
drops that track's buffer.
- Source away (D6): viewers keep playing from the buffer; a request
waits up to 60 s for missing bytes. The source member going `gone`
does not end the relay: it lives through the source's grace
(`media.relay.sourceGone`, `ServerHub.refreshRelaySource`). The source
(`RelaySource`, kept by the coordinator through the app's own drop)
reconnects its channel with backoff (its token lasts through the
grace; a half-open channel is caught by its 5 s WebSocket ping) and
sends its `info` again, the same on every dial (the coordinator passes
the media's `name` and `duration`): the same as its first (sizes, piece
shapes, kinds, and name and duration when given) carries on, the
buffer filler pulling what is missing; another one is closed with
4002 and ends the relay (`ServerHub.relayOtherMedia`); a second channel
replaces the first (4003). The source's leave (grace over, `bye`) or a
swap ends the relay: its buffer goes, `live` drops, `/r` answers 404.
`state.media.relay.live` follows the channel, and viewers show "waiting
for <name> to pass the video on" while it is down, "<name> left:
for <name> to pass the video on" while it is down, "<name> lost
signal: playing what the server has" while the source is `gone` (a
viewer opening then opens the buffer instead of waiting), "<name> left:
playing what the server has" once the source left the server.
- Stats (`relay_stats.dart`, `ServerHub.sampleRates` on the 1 s
heartbeat; wire in [PROTOCOL.md](PROTOCOL.md)): the source's rate is
@@ -942,7 +1026,8 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
next episode, every client with the setting on shows a 10 s countdown
card (Cancel / Play now; without the setting, a "Next" card with Play
next). Exactly one client swaps: `autoAdvanceSwapper`, the lowest member
id among non-stale members whose `jf` for that server is true. Only what
id among present members (`in` or `joining`) whose `jf` for that
server is true. Only what
every client sees in `state` decides it (who put the item up is not in
`state`; a late joiner never saw that swap), so clients agree without a
wire change. Cancel is local: it stops the countdown on that screen, and
@@ -985,15 +1070,16 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
memory under it and nothing else leaves it. The adder gets a toast
"Added … to Up next"; everyone else an activity-log line.
- **Who puts an item up** (`queuePerformer`, from `state` alone, so all
clients agree): a link passed on or a file, only its adder (gone:
skipped, "Bob left"); a Jellyfin item, its adder when it plays that
server with its own account, else the lowest member id that does
(`jf`, which lists every working account; nobody: skipped); a link
everyone opens or the server fetches, its adder when present, else the
lowest active member id; for these, stale or sitting out counts as
away (an adder who is stale is waited for until it is gone). A skip is
a `remove` with the reason from the lowest member id that is not stale
(`queueJanitor`); the feed's `queue-skip` toasts on every screen.
clients agree): a link passed on or a file, only its adder (`gone`:
the item waits for it through its grace; left for good: skipped, "Bob
left"); a Jellyfin item, its adder when it plays that server with its
own account, else the lowest present member id that does (`jf`, which
lists every working account; nobody: skipped); a link everyone opens
or the server fetches, its adder when present, else the lowest present
member id (present: `in` or `joining`, `isPresent` in
`session/party.dart`). A skip is a `remove` with the reason from the
lowest member id that is not `gone` (`queueJanitor`); the feed's
`queue-skip` toasts on every screen.
- **Advancing**: at `finished` with Up next waiting, every screen shows
`UpNextCard` (picture, title, who added it and how; with auto-advance on
a 10 s countdown with Cancel, else Play now only); at zero the app that
@@ -1136,12 +1222,14 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
**Events.** Everything that may become a toast, a sound, a window
attention request or an activity-log line is an `AppEvent`
(`session/app_events.dart`, a sealed type, pure Dart): the server feed as
typed `FeedEvent`s (`FeedEvent.of`: joins, leaves, stale, sat out, back,
typed `FeedEvent`s (`FeedEvent.of`, one per feed action: joins, leaves
(`bye` or `grace`), gone (lost signal), return, sat out (`away`: by
oneself, by someone else, or by this app's auto sit-out), back (`in`),
ready/unready, force ready/play/hold, swap, seek, pause, resume,
finished, chat, rename, avatar, queue add/remove/skip, fetch failed),
the session's own (nudged, a member can't play, slow source, relay
started/failed, idle warning, connection lost/back, Jellyfin progress
not saved) and the app's (shader message, added to Up next, a put-up or
finished, chat, rename, avatar, queue add/remove/skip, fetch failed;
people are matched by `actorId` / `targetId`, chat by `fromId`, never by
name), the session's own (nudged, a member can't play, slow source, relay
started/failed, connection lost/back, Jellyfin progress not saved) and the app's (shader message, added to Up next, a put-up or
queue add that failed, Schedule results, problem marked, copied / copy
failed, update available). Code with news calls
`SessionCoordinator.notify` (= `Notifier.notify`, `session/notifier.dart`);
@@ -1149,8 +1237,10 @@ nothing else writes toasts or plays sounds (Settings' sound preview
aside). `noticeFor` is the whole policy, one `Notice` per event: the
toast text and its actor, the `SfxCue`, attention, the feed line for the
activity log. Today: every feed line is logged (`Notifier.feedLog`, the
last 100); joins, leaves, swaps, chat, being sat out by someone else and
Up next skips toast; the sounds are listed under "Sounds"; a nudge also
last 100); lines a reconnect replayed (`replayed`) are only logged;
joins, leaves, swaps, chat, being sat out by someone else, being sat out
by auto sit-out and Up next skips toast (a toast keeps its actor's member
id, so its avatar is found by id); the sounds are listed under "Sounds"; a nudge also
asks for attention; an available update does nothing (the update band
shows it). `Notifier.toasts` holds the toasts, dropped 4 s after they came
(`expire`, the session's 1 s tick), cleared on connect. The Library
@@ -1163,7 +1253,10 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and
`LinkResolver` as constructor arguments):
- `ShellPort` (`DesktopShell`): window start (remembered bounds, the
close request routed to `shutDownApp`), fullscreen, minimize/maximize
and their changes, dragging (`WindowDragArea`), close, the way out
and their changes, the window's activity for auto sit-out
(`watchActivity`: focused, unfocused, minimised, hidden; window_manager's
focus, blur, minimise and restore, and the shell's own `hide`, wired to
`SessionCoordinator.windowActivity` in `main`), dragging (`WindowDragArea`), close, the way out
(save, hide, quit), attention, the clipboard paste (files, else text)
and drop targets (fox_utils).
- `UrlOpener` (`DesktopUrls`): `xdg-open`, `open`, Windows' `url.dll`.
@@ -1233,10 +1326,13 @@ panel) and chat messages (Copy message, and a link in it).
(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
"ready n/m" (hidden under 1100 px), chat with an unread badge, and Leave
"ready n/m" (who the ready counter counts: `countsForReady` in
`session/party.dart`, on `PresencePolicy`, so not away, gone or
errored; hidden under 1100 px), chat with an unread badge, and Leave
(asks first). The stack opens the members card (the source line, then
per member status, route and buffer dot (see "Relay", Stats), host /
relay pills, "sitting out"; per member force ready, Nudge and Sit
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
@@ -1245,8 +1341,9 @@ panel) and chat messages (Copy message, and a link in it).
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):
green ready, amber buffering (with a spinner), grey not ready, dimmed
stale, dimmed with a moon ("away") sitting out (last in the stack); a crown for the host (`members[].host` in the state; your own
green ready, amber buffering (with a spinner), grey not ready, a little
faded joining, dimmed with a dark ring `gone` (reconnecting, last in
the stack), dimmed with a moon ("away") sitting out; a crown for the host (`members[].host` in the state; your own
app also knows before the first state) and a tower for the member
passing the video on. `memberAvatarImage` is the one picture lookup: yours from the
profile, anyone else's from `coordinator.avatars` by `avatarHash`.
@@ -1262,7 +1359,7 @@ panel) and chat messages (Copy message, and a link in it).
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.
- **Profile** (`profile_card.dart`, `screens/home/profile.dart`): a card
- **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
PNG), name edited in place, colour swatches, links to the Settings pages.
@@ -1375,7 +1472,8 @@ panel) and chat messages (Copy message, and a link in it).
- **Sounds** (`sfx/`): the `Notifier` plays an `SfxCue` for ready,
unready, force-ready, join, leave, swap and others' chat (feed
events), being sat out by someone (the `kick` sound), a nudge (always,
whatever its switch), the idle warning, and connection lost / back (once per drop).
whatever its switch), auto sit-out setting you away (`warn`), and
connection lost / back (once per drop that lasted a few seconds).
Nothing for what the video already says (play, pause, seeks, the end).
The sounds are synthesized, not sampled: `synth.dart` renders voices
(plucked strings with bends, glass and star bells, a singing bowl,
@@ -1703,9 +1801,13 @@ 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`
(Jellyfin stop report, last diagnostics, the goodbye, so others see
"left") and the hosted server's stop, 3 s in all; an update restart then
closes Hive and starts the new binary. Last the window closes the normal
(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
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
identity's `seq` is stored) and starts the new binary. Last the window closes the normal
way (`setPreventClose(false)` + `close`), so the engine and plugins are
torn down inside the platform's main loop, and the runners end the
process without `exit()`'s handlers: Linux `_exit` after
+27 -16
View File
@@ -57,8 +57,9 @@ the timings of each media change (`SwitchTrace`).
Two instances on one machine: `STARSYNC_PROFILE=<name>` (letters, digits,
`-`, `_`; at most 32) gives an instance its own Hive boxes (settings,
profile, connect form, window bounds, folders, Jellyfin sign-ins; the
client id from restructure step 1 on) and logs under
profile, connect form, window bounds, folders, Jellyfin sign-ins, the
client id: two instances without it are one install, and the second takes
the first's member over) and logs under
`<app support>/profiles/<name>/`, and ` [<name>]` in the window title;
yt-dlp, shaders and caches stay shared (`AppDirs`, ARCHITECTURE "Desktop
UI"). Without it nothing changes. Run one plain instance and one with
@@ -77,12 +78,15 @@ and `e2e_check` (simulated viewers against a live server).
Three suites, run from each package's folder:
- `cd packages/core && dart test` (a few seconds): protocol round-trips,
the sync engine (clock, gate, correction, end of file), queue model,
the sync engine (clock, gate, presence and grace, correction, end of
file), the `PresencePolicy` table, queue model,
relay pieces, updater, logging redaction, watch rule, yt-dlp and
Jellyfin client parsing against captured fixtures.
- `cd packages/server && dart test` (well under a minute): live servers
on loopback: auth, media and relay byte serving, Jellyfin relay against
a fake, queue, session log, isolate `serve`.
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`.
- `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`
@@ -95,7 +99,9 @@ server `jellyfin_relay_live` against a real Jellyfin; read their headers),
`YTDLP_LIVE=1` (downloads yt-dlp and resolves a real tweet). Everything
else is offline: Jellyfin and link hosts are local fakes.
Real-mpv tests: `sync_playback_e2e`, `coord_presence`, `video_reload_e2e`,
Real-mpv tests: `sync_playback_e2e`, `coord_presence` (drops through a
`FlakyProxy`: a half-open link and return, 10 flaps, `replaced`, `bye`,
a dead or restarted server), `video_reload_e2e`,
`seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `chapters_mpv_e2e`,
`ytdl_headers_e2e`, `relay_e2e` (a dedicated server with no file of its
own: a guest's file, links passed on or fetched by the server, a late
@@ -131,10 +137,15 @@ the server: server `flaky_link_test`. For manual runs with real apps use
Long durations on a fake clock: `SyncServer(clock:, liveness:)` and
`SyncEngine(clock:, liveness:)` take the engine clock and the server's
silence limits (`Liveness`, ARCHITECTURE "Sync model"), so a test can
pass short limits or step a fake clock through minutes (the 1 s heartbeat
still ticks in real time and reads the clock); `Connection(now:,
silenceLimit:, reconnectDelay:)` on the app side.
limits (`Liveness`: silence, grace, gone notice, joiner hold, joining
timeout; ARCHITECTURE "Sync model"), so a test can pass short limits or
step a fake clock through minutes: move the clock, then call
`ServerHub.heartbeat()` (or `SyncEngine.tick()`) yourself; the real 1 s
heartbeat still ticks and reads the same clock. On the app side
`Connection(now:, unstableAfter:, silenceLimit:, lostSoundAfter:,
reconnectDelay:, identity:)` (`connection_test` drives one against a
scripted fake server), and `AutoSitOut` takes the time on every call
(`session_logic_test` steps it through minutes).
Safety-net scenarios: the core flows every restructure step must keep
working. What covers them today and which step adds the rest (steps as
@@ -142,12 +153,12 @@ in [ROADMAP.md](ROADMAP.md)):
| Scenario | Covered today | Missing, added in |
|---|---|---|
| Drop and reconnect while sharing a relay | server `relay_test` "source drops and comes back", `sync_wave_test` (resume after an unclean drop), `flaky_link_test` (blackhole, reset); desktop `relay_e2e` "the source drops uncleanly and resumes", `coord_presence` (dead server, reconnect restores ready) | step 1: the same member back after a closed socket (grace, P1), the source re-dialling its relay (D6), the player and state kept through the drop, through `FlakyProxy` and a fake clock |
| Join mid-playback | core `sync_engine_test` "late joiner does not halt playback", "a joiner while playing never holds anyone back"; server `relay_test` "a late joiner gets its own /r/ URL" | step 1: `joining` until in sync, loading in the background while playing (D2, P3), the ~15 s hold while not playing |
| Sit out / Away and back | core `sync_engine_test` "sitting out"; server `hardening_test` "anyone sits anyone out"; desktop `server_isolate_test` (host sat out), `notice_table_test` (sat you out) | step 1: presence `away`/`in`, the ready counter (P6), auto sit-out; step 2: notifications while away (P8); step 6: not tracked as watched (P7) |
| Swap, also while someone is Away or gone | core `sync_engine_test` "swap" (a stale member stays out), `queue_test` (a swap takes its item); server `relay_test` (a swap drops the buffer); desktop `sync_playback_e2e` | step 1: a swap with a member `away` (stays away) or `gone` (in grace, its items wait); step 5: no commands reach a player mid-switch (P15) |
| Up next advance | core `queue_test` (skip, play request timeout); 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 continuation and the countdown as server state with a shared cancel |
| Close the app cleanly, incl. `bye` | server `lifecycle_test` (clean close removes the member, server stop closes sockets); desktop `server_isolate_test` (stop frees the port), `coord_presence` (cancelled host setup releases the port) | step 1: `bye` leaves at once, any other close keeps the member in grace, update-and-restart sends no `bye`; step 5: the Windows close smoke job (P32) |
| 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), `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 2: notifications while away (P8); 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) |
A test file whose first line is `// restructure: rewrite in step N` pins
behaviour that step of the restructure ([ROADMAP.md](ROADMAP.md)) changes;
+6 -4
View File
@@ -76,10 +76,12 @@ window; guests need no Jellyfin or account.
6. **Swap.** Anyone can change the video by pasting another link. Everybody
stops at 0 and the gate runs again for everyone there; ready flags stay
as they were (a setting unreadies you instead).
7. **Leave.** Closing or leaving drops you from the server; the others keep
going. If you vanish without saying goodbye, you are marked stale after 15 s
(stops blocking) and removed after 30 s. The app reconnects by itself and
takes over your old place (same member, ready flag and video).
7. **Leave.** Closing the app or Leave says goodbye and drops you from the
server; the others keep going. If you vanish without a goodbye, after 15 s
of silence you are "gone": you stop blocking but keep your place
(ready flag, sitting out, Up next items, the video you pass on) for
10 minutes. The app keeps playing and reconnects by itself, with an id
of its own, and takes that place back.
There are no roles: anyone can seek, swap, force-ready others, nudge
anyone, and sit anyone out (the host too). Someone sitting out stays
+284 -88
View File
@@ -1,41 +1,93 @@
# Protocol (proto = 15)
# Protocol (proto = 16)
Wire format only. Behavior (gate, clock, timeouts) is in
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
`packages/core/lib/src/protocol.dart` and `packages/server/lib/src/server_hub.dart`.
Any wire change bumps `RelayConstants.protoVersion` and edits this file in
the same commit. Changes are additive where possible; a client and server
with different `proto` refuse each other at `hello` (`proto mismatch`).
the same commit. Versions are ranges: a server accepts
`[protoMin, protoMax]` (`RelayConstants.protoMin` / `protoMax`, both 16
today), a client sends its own range (`hello.protoMin` .. `hello.proto`),
and the connection uses the highest version both have (`welcome.proto`).
Later additions raise `protoMax` only, so they stay additive. Fields
marked **reserved** are parsed and documented but nothing produces them
yet; the restructure step named next to them does.
## Transport
- WebSocket at `/sync`, JSON text frames, each an object with a `t` field.
- The first client frame must be `hello` within 10 s. Server pings every 5 s.
- Match incoming frames by `t`, never by order: `welcome` can race `state`.
- Failures: server sends `{"t":"err","message":...}` and closes.
Messages: `bad hello: ...`, `proto mismatch: got X, want Y`, `wrong secret`,
`rejected: <reason>` (e.g. avatar too large), `bad message: ...` (a frame
with a non-finite number, e.g. NaN position), `bad swap media`,
`swap needs a url or the hosted file` (empty swap).
- `GET /health` returns JSON (`ok`, `name`, `proto`, `viewers`, `bytesServed`,
`uptimeSecs`; unauthenticated). `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). `/r/<sid>/<token>/...` is the relay of a
member's media (see "Relay" below). `GET /a/<sid>/<token>/<hash>` serves
a member's picture (see "Avatars" below), `GET /i/<sid>/<token>/<hash>`
the current media's art (see "Media art" below). `POST` and `GET
/l/<sid>/<token>` send and read the session log (see "Diagnostics"
below).
- A socket without any frame from its client for 15 s
(`RelayConstants.silenceSecs`) is closed by the server (close code
4015) and its member is `gone` (see "Presence"). Clients send a
`report` every second and a `ping` every second anyway.
- Failures: `err` (see "Errors and close codes"); a refused `hello` is
followed by a close.
- `GET /health` returns JSON (unauthenticated, no member names): `ok`,
`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`.
- `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).
`/r/<sid>/<token>/...` is the relay of a member's media (see "Relay"
below). `GET /a/<sid>/<token>/<hash>` serves a member's picture (see
"Avatars" below), `GET /i/<sid>/<token>/<hash>` the current media's art
(see "Media art" below). `POST` and `GET /l/<sid>/<token>` send and read
the session log (see "Diagnostics" below). A token works from the
member's `welcome` until it leaves (through its grace, see "Presence").
## Errors and close codes
```
{"t":"err","code":"version","min":16,"max":16,"server":"1.3.0","for":"hello",
"message":"proto mismatch: got 15, want 16..16"}
{"t":"err","code":"bad","for":"seek","message":"bad message: position must be finite"}
```
- `message` is plain text for people; a proto 15 app shows it as is (the
version refusal starts with `proto mismatch:` for that reason). `code`
is for programs; `for` names the `t` of the frame that failed, when
there is one. With `code` `version`: `min`/`max` the server's range,
`server` its app version. This shape is stable from proto 16 on.
- Codes: `version` (no common version), `secret` (`wrong secret`),
`rejected` (understood but refused: `rejected: avatar too large`,
`server fetch needs a server password`, `The server can't fetch this
link: ...`), `bad` (malformed: `bad hello: ...`, `bad message: ...` for
a frame with a non-finite number or a bad queue item, `bad swap media`,
`swap needs a url or the hosted file`, `relay fetch needs an http(s)
url`), `replaced` (another hello of the same client took the member
over; the client stops, "opened in another window"), `stale` (this hello
is older than the member's current one), `forbidden` (`presence` `in` for
someone else; an `auto` queue item from someone else than the swapper),
`rate` (**reserved**; nudges over the cap are still dropped silently),
`full` (`Up next is full (100 items)`).
- After `err` the socket stays open, except at `hello` and for
`replaced`, which are followed by a close.
- Close codes (the server's; constants: `CloseCodes` in core):
| Code | Socket | Meaning |
|---|---|---|
| 1000 | `/sync` | the member left (`bye`, or its grace ran out) |
| 4001 | relay channel | not (or no longer) the source of this relay; the source stops |
| 4002 | relay channel | the source is back with another media than the relay's first `info`; the relay ends, the source stops |
| 4003 | relay channel | a newer channel of the same source replaced this one; this one stops |
| 4010 | `/sync` | refused: `err` `version` |
| 4011 | `/sync` | refused: `err` `secret` |
| 4012 | `/sync` | refused: `err` `bad` or `rejected` at `hello` |
| 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` |
## Client to server
```
{"t":"hello","proto":15,"secret":"","user":{"name":"","avatar":""},
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
"resume":"<token>"}
{"t":"hello","protoMin":16,"proto":16,"secret":"","user":{"name":"","avatar":""},
"media":{},"hostKey":"...","client":"<install id>","seq":7,"app":"1.3.0",
"platform":"linux","caps":{"source":true,"resolve":true,"jellyfin":true},
"feedAfter":41}
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
{"t":"force","target":"all"|"<memberId>","ready":true}
{"t":"seek","position":123.4,"seq":3}
@@ -47,27 +99,45 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true},"error":"Not allowed (403)"}
{"t":"ping","c":1767225600000}
{"t":"chat","text":"..."}
{"t":"out","target":"<memberId>","out":true}
{"t":"presence","target":"<memberId>","state":"away"|"in","reason":"manual"}
{"t":"bye"}
{"t":"nudge","target":"<memberId>"}
{"t":"profile","name":"...","avatar":"<base64>"}
{"t":"art","mediaId":2,"poster":"<base64>","banner":"<base64>"}
{"t":"queue","op":"add"|"move"|"remove"|"play"|"skip",...} see "Queue"
{"t":"queue","op":"add"|"move"|"remove"|"play"|"skip"|"cancel",...} see "Queue"
```
Field notes:
- `hello.proto` / `hello.protoMin`: the client's version range
(`protoMin` absent: `proto` only). The server reads them before
anything else in the hello (a hello of another version may not parse)
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.media` is carried but not used by the server today.
- `hello.hostKey` (optional, omitted when empty): the hosting app
generates a random key per hosting, gives it to its server and sends it
from its own client. The member whose key equals the server's is the host
(`members[].host`). A server without a key (the dedicated server) has no
host. Where the connection comes from does not matter.
- `hello.resume` (optional, omitted when empty): the token from this
client's previous `welcome`, sent on reconnect. If the server still has
that member, the connection takes it over: `welcome`
returns the same `token` and `memberId`, the member keeps its flags, and
there is no `join`/`leave` feed. Otherwise it is ignored and a new member
joins.
- `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.
It names the member across connections (see "Presence"); without it
every hello is a new member. **Reserved**: deriving it per server (an
HMAC of an install secret and the dialled host:port).
- `hello.seq` (default 0): increases with every connection this install
makes. A hello whose `seq` is lower than the one holding the member
gets `err` `stale` and is closed; equal or higher takes the member over.
- `hello.app` (the app version, cut to 32 characters): shown as
`members[].app`. `hello.platform` (`linux`, `windows`, `macos`,
`android`): logged only. `hello.caps`: what the app can do (`source`:
serve a relay; `resolve`: run yt-dlp; `jellyfin`: play with its own
account), shown as `members[].caps`; unknown entries are ignored.
- `hello.feedAfter` (optional): the last `feed.seq` the client saw from
this server; `welcome.feed` replays the lines after it. A client should
send it only for the same `welcome.instance` (a restarted server counts
from 1 again).
- `seek.seq` (optional, default 0): increasing per client; echoed in
`state.seekSeq` so the sender recognises its own seek.
- `report.duration` / `report.mediaId` (optional, omitted when 0): the open
@@ -75,7 +145,9 @@ Field notes:
the first one whose `mediaId` is current while its media duration is 0.
`report.buffered` is the current media's: a client sends 0 while its
player does not hold it yet, and the server counts a report whose
`mediaId` names an earlier media as `buffered` 0.
`mediaId` names an earlier media as `buffered` 0. A `joining` member's
report ends its joining once it holds the current media at the server
position (within 5 s) with the buffer the gate needs (see "Presence").
- `report.jf` (optional): Jellyfin access per `ServerId`, `true` = this
client plays the item with its own account, `false` = through the relay.
Sticky: a report without `jf` keeps the last one. At most 8 entries, keys
@@ -92,27 +164,27 @@ Field notes:
clears it, and so does a swap. An errored member is out of the ready
gate: out of the set, a `ready` or `force` does not put it back; when
the error clears it is in the set again like a join (unless `playing`,
then from its `ready`). Optional on both sides: older servers ignore it,
older clients ignore `members[].error`.
then only if it is ready). It also ends a `joining`.
- `ping.c`: the client's clock in epoch ms; answered with `pong`.
- `user.avatar`: base64 PNG, at most 256 KiB of characters (else
`rejected: ...`). `name` is trimmed, empty becomes `?`, cut to 32
characters.
characters. A takeover applies the hello's name and picture like a
`profile`.
- `profile`: changes this member's name and/or picture mid-session. A field
that is absent (or null) stays as it is; `avatar: ""` removes the picture.
Same rules as `hello.user`; a too large avatar gets `err`
`rejected: avatar too large` (the socket stays open, nothing changes).
- `art`: pictures of the current media (see "Media art").
- `chat.text` is cut to 500 characters.
- `out`: `out:true` sits the member `target` out (anyone, the host
included); `out:false` brings the sender back and only works with its own
member id as `target`. An out member is never in the set and never
ready: `ready` from it and `force` on it are ignored, a swap leaves it
out, the `buffered` of its reports counts as 0 (it keeps sending
`report`: that keeps it fresh). Coming back puts it in the set like a
join (unless `playing`), unready; it sends `ready` itself. Unknown
targets and no-op changes are ignored. Feed: `out` (`target` = the name
of who was sat out, absent when the actor sat itself out) and `back`.
- `presence`: `target` a member id (absent or empty: oneself). `state`
`away` sits `target` out: anyone may, on anyone (the host included); a
`gone` target comes back `away`. `state` `in` brings oneself back (through
`joining`, see "Presence"); for anyone else it is `err` `forbidden`.
`reason` (`manual` default, `idle`, `minimised`, `bye`) goes into the
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`).
- `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
@@ -120,29 +192,34 @@ Field notes:
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
dropped. `swap.media.relay` makes it relayed media (see "Relay").
A swap keeps every member's `ready` and puts every fresh member in the
set (`inSet`); clients that want to be unready for the new media send
`ready` themselves (gate: ARCHITECTURE.md, "Ready gate").
`swap.media.ref` says what it is (see "Media identity"). A swap keeps
every member's `ready` and puts every member in the set (`inSet`; who
counts is up to presence); clients that want to be unready for the new
media send `ready` themselves (gate: ARCHITECTURE.md, "Ready gate").
- Frames before `welcome` (other than `hello`) are ignored; unparsable or
unknown frames are ignored silently.
unknown frames are ignored silently. Frames
from a socket another hello took over are ignored.
## Server to client
```
{"t":"welcome","token":"...","memberId":"m1","mediaUrl":"...","relayUrl":"...",
"state":{<state>}}
{"t":"welcome","proto":16,"token":"...","memberId":"m1","resumed":false,
"instance":"<random per boot>","grace":600,"mediaUrl":"...","relayUrl":"...",
"state":{<state>},"feed":[<feed with "replayed":true>,...]}
{"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3,
"serverTime":"<ISO-8601 UTC>","mediaId":1,
"media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent,
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?,
"host"?,"out"?,"error"?,"rate"?}],
"media":{name,size,duration,url?,jellyfin?,relay?,ref?,swapBy?,art?}|absent,
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","presence",
"goneSince"?,"jf"?,"host"?,"error"?,"rate"?,"app"?,"caps"?}],
"seekBy":"m1","seekSeq":3,"relay":true,"relayNext":true,"queue":[<item>]?,
"queuePlay":"q3"?}
"queuePlay":"q3"?,"hold":{"reason":"joining","members":["m3"],"until":"<ISO>"}?,
"advance":{"id":"q3","at":"<ISO>","secs":10}?}
{"t":"pong","c":1767225600000,"s":1767225600012}
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
{"t":"chat","text":"...","from":"<name>"}
{"t":"feed","seq":12,"actor":"","actorId":"m2","action":"","target":"","targetId":"m3",
"reason":"","at":"<ISO-8601>"}
{"t":"chat","text":"...","from":"<name>","fromId":"m2"}
{"t":"nudge","from":"m2","name":"Alice"}
{"t":"err","message":""}
{"t":"err","code":"...","message":"...","for":"..."}
```
- `welcome.token` is private to its owner and appears only in that member's
@@ -151,7 +228,15 @@ Field notes:
the shared link.
`welcome.state` has the same shape as `state`. `relayUrl` is present only
when `state.relay` is: this member's `/j/...` URL for the current
Jellyfin media (private like `token`).
Jellyfin media (private like `token`). `welcome.proto`: the version of
this connection. `welcome.resumed`: the hello took over this client's
member (same `memberId` and `token`, everything it had kept); false for
a new member, also after a server restart. `welcome.instance`: random
per server boot (a client that held a member of another instance knows
the server restarted, not that its grace ran out). `welcome.grace`:
seconds a `gone` member is kept. `welcome.feed` (omitted when empty):
the feed lines after `hello.feedAfter`, at most the last 50, oldest
first, each with `replayed: true` (log them, do not toast them).
- `state` is sent every second and on every change. `position` is the value at
`serverTime` (server clock); clients extrapolate while `transport` is
`playing`, on the server's clock (see `pong`). `finished`: the media
@@ -161,7 +246,8 @@ Field notes:
(see "Relay"). `media.duration` is 0 until a client reports
it. `media.swapBy`: member id of who swapped it in (omitted once that
member left, and for the media the server started with). `media.art`:
see "Media art". `seekBy` (member id) and `seekSeq` are present only while the current
see "Media art". `media.ref`: see "Media identity". `seekBy` (member id)
and `seekSeq` are present only while the current
`eventId` came from a seek. `relay` (omitted when false): the server can
relay the current Jellyfin media. `relayNext` (omitted when false, proto
13): it can relay Up next's first item too (a Jellyfin ref on the same
@@ -169,29 +255,98 @@ Field notes:
`report.jf`, omitted when it never sent one. `members[].avatarHash`
(omitted when the member has no picture) names the picture's bytes (see
"Avatars"). `members[].host` (omitted when false): the member sent the
server's `hostKey` (see `hello.hostKey`). `members[].out` (omitted when
false): the member sits out (see `out`). `members[].error` (omitted when
server's `hostKey` (see `hello.hostKey`). `members[].presence` and
`goneSince` (ISO, only while `gone`): see "Presence". `members[].inSet`:
the member counts for the ready gate right now (in the set, no player
error, and its presence counts). `members[].error` (omitted when
empty): the member's player cannot open the current media (see
`report.error`). `members[].rate` (omitted when 0): bytes per second
the server sent this member over the last 5 s (`/m`, `/j` and `/r`
together), two significant digits, under 10 kB/s counts as 0. Members
that fetch the media themselves read nothing from the server.
`members[].app` / `caps` (omitted when empty): its `hello.app` /
`hello.caps`.
- `state.hold` (present while `held` with media up): why. `reason`, most
pressing first: `joining` (a joiner within its bound holds the start;
`until` is when that bound ends, server clock), `ready` (someone in the
set is not ready), `buffer` (someone's buffer is short of `bufferNeed`),
`source` (the same while the relay's source is `gone`), `start` (nobody
counts for the gate: someone has to ready up). `members`: the member
ids the gate waits for (empty for `start`).
- `state.advance` (**reserved**, step 6): the shared countdown to Up next
(see "Queue").
- `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms
(the clock `serverTime` uses). With `t3` the local receive time, the offset
is `s - (c + t3) / 2`, accurate to half the round trip.
- `feed.action` values: `ready`, `unready`, `force-ready`, `force-unready`,
`force-play`, `force-hold`, `out`, `back`, `join`, `leave`, `stale`, `swap`,
`seek`, `pause`, `resume`, `finished`, `chat`, `rename`, `avatar`,
`queue-add`, `queue-remove`, `queue-skip`, `fetch-fail` (proto 12: the
server could not fetch the current media, `target` its name). `target`
and `reason` are optional; `reason` carries `auto`/`manual` for ready and
`silent too long` or `no signal` for leave/stale. `rename`: `actor` is the
new name, `target` the old one. `avatar`: the actor changed only the
picture. `queue-*`: `target` is the item's title, `reason` (skip) why it
could not play (see "Queue").
- Chat is delivered twice: a `chat` frame with the text and a textless
`feed` with `action:"chat"`. Render the `chat` frame only; the reference
client drops the textless feed entry.
- `feed`: `seq` increases by one per line for the server's life (a
restart counts from 1 again); `actor` / `target` are display names as
they were then, `actorId` / `targetId` the member ids (`server` for the
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
`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
manual), `in` (back from away), `swap`, `seek`, `pause`, `resume`,
`finished`, `chat`, `rename`, `avatar`, `queue-add`, `queue-remove`,
`queue-skip`, `advance-cancel` (`target` the dropped continuation's
title, if any), `fetch-fail` (the server could not fetch the current
media, `target` its name). `reason` carries `auto`/`manual` for ready.
`rename`: `actor` is the new name, `target` the old one. `avatar`: the
actor changed only the picture. `queue-*`: `target` is the item's
title, `reason` (skip) why it could not play (see "Queue").
- Chat is delivered twice: a `chat` frame with the text (`fromId` the
sender's member id) and a textless `feed` with `action:"chat"`. Render
the `chat` frame only; the reference client drops the textless feed
entry.
## Presence
The member is lasting: one per client, kept across reconnects. Gate
rules and timings in detail: ARCHITECTURE.md, "Sync model".
- The server maps `hello.client` to one member. A hello with a known
client id (and a `seq` not older than the member's) takes that member
over: `memberId` and `token` stay, so `/m` `/r` `/j` URLs keep working;
the ready flag, presence, queue items, relay, picture all stay; an older
socket still open gets `err` `replaced` and closes (4013).
`welcome.resumed` is true.
- A socket close (or 15 s of silence) is not a leave: the member goes
`gone` (`goneSince`) and is kept for the grace, 10 min
(`welcome.grace`), with everything it had; a relay it serves goes on
(see "Relay"). After the grace it leaves (feed `leave`, reason
`grace`), its token stops working; a hello with its client id after
that is a new member. `bye` leaves at once.
- `members[].presence`: `joining` (new, back from `gone`, or back `in`
from `away`, while media is up: it loads in the background until its
report holds the current media at the server position with
`bufferNeed` buffered, or reports an error, or 60 s pass; then `in`),
`in` (watching), `away` (sat out: still in the server, never counted
for the gate, the ready counter or watched tracking), `gone`
(disconnected, in its grace). A member `away` when it dropped comes
back `away`. With no media up a joiner is `in` at once.
- What each presence counts for is one table, `PresencePolicy` in core
(`presence.dart`), used by the server and the app: the gate counts
`in`, and `joining` only while `held` and for at most 15 s after it
started joining (never while `playing`: a joiner never pauses anyone);
the ready counter and preloads count `joining` and `in`; watched
tracking and full social notifications count `in` (`joining` too for
the notifications).
## Media identity
```
media.ref = {"kind":"episode","title":"Show","anilistId":1,"anilistEp":6,
"tvdbId":2,"season":1,"ep":6,"absolute":30}
```
- In `swap.media`, `state.media` and `queue[].media`; passed through as
is (the server drops non-integer ids and cuts `title` to 200
characters). `kind` `episode`, `movie` or `extra` (else no ref); every
other field optional; `season` absent for absolute numbering. At most
2 KiB of JSON, else `err` `bad`. **Reserved**: nothing produces it yet
(step 4).
## Avatars
@@ -278,6 +433,8 @@ state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true}
media.relay = {"fetch":true,"live":true,"tracks":[{"kind":"video"},{"kind":"audio"}]}
media.relay = {"source":"m2","live":true,"rate":4100000,"need":2300000}
media.relay = {"source":"m2","live":true,"queueId":"q3"} started in Up next (proto 14)
media.relay = {"source":"m2","live":true,"sourceGone":true,"sourceGoneSince":"<ISO>"}
its source is gone, in its grace
queue[0].media.relay = {"source":"m2","live":true,"tracks":[...]} its warm relay
queue[0].media.relay = {"fetch":true,"live":true}
```
@@ -309,6 +466,12 @@ anything else means one file.
channel is connected, or the server could read the link). `tracks`: as
the swap named them, or, with `fetch`, set once the server has read the
link (together with `live`).
- `sourceGone` (omitted when false) and `sourceGoneSince` (ISO): the
source member is `gone` (see "Presence"). The relay and its buffer stay
through the source's grace; viewers play what is buffered. A source's
leave (grace over, `bye`) ends the relay: `live` goes, `sourceGone`
stays, its URLs answer 404. A swap ends it as before. The warm relay's
`queue[0].media.relay` carries the same fields.
- `state.media.relay.rate` (omitted while unknown): bytes per second the
source (with `fetch`, the server's own fetch) delivers while the buffer
is waiting on it, smoothed over about 4 s of that time and present once
@@ -368,11 +531,23 @@ anything else means one file.
relay's source token is accepted; anyone else, or the source once its
relay is dropped, is closed with code 4001 (the source stops instead
of reconnecting). A warm relay's tracks are those of its source's
first `info`.
first `info`. The source's token works through its grace, so a source
back from a drop dials the channel again (before or after its own
`hello`) and sends its `info` again: the same as its first (track
kinds, byte sizes, piece shapes, and `name` / `duration` where both
give them) continues the relay: the buffer stays and the server pulls
what it still lacks with new pull ids, from the first byte or piece not
buffered (the `pull` frames below are that exchange; nothing else is
needed). Another `info` closes the channel with 4002 and ends the
relay. A second channel for the same relay replaces the first, which is
closed with 4003. The source stops on 4001, 4002 and 4003 and
reconnects (with backoff) on anything else.
Frames:
```
source -> server {"t":"info","size":<bytes>} first frame, one file
source -> server {"t":"info","size":<bytes>,"name":"Ep 06.mkv","duration":1440.5}
first frame, one file
(name, duration optional)
{"t":"info","tracks":[{"kind":"video","size":<bytes>},
{"kind":"audio","size":<bytes>}]}
first frame, tracks
@@ -397,26 +572,31 @@ server -> source {"t":"pull","id":1,"track":0,"start":0,"end":1048575}
init and key pieces it uses, `iv` an explicit IV (`0x` + hex), `disc` a
discontinuity before it; `seq` (default 0) the media sequence of the
first segment. A piece pull's bytes end with `end` like a range pull.
An `info` whose tracks differ from the swap's `tracks` (count, bytes or
pieces), or a malformed piece list, closes the channel with 4001. A
track `size` or piece count/roles different from the one before (a
reconnect) drops that track's buffer.
A first `info` whose tracks differ from the swap's `tracks` (count,
bytes or pieces), or a malformed piece list, closes the channel with
4001. A later `info` unlike the first closes it with 4002 (see above).
## Join and reconnect
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the server
position and buffers. A joining member is `ready=false`; it is `inSet`
at once unless the server is `playing`, then from its first `ready`
(members present at a swap are put in the set by it). A socket the server sees close loses its token; reconnect
is a fresh `hello` (with `resume` = the old token), after which the client
re-sends its prior ready flag. If the old member was still there (unclean
drop), `resume` takes it over and the token and media URL stay the same.
position and buffers. A new member is `ready=false` and `joining` (with
media up); it is in the set at once unless the server is `playing`,
then from its first `ready` (members present at a swap are put in the
set by it), and the gate counts it per "Presence". A reconnect is a
`hello` with the same `client` and a higher `seq` (and `feedAfter`):
within the grace it is the same member (`welcome.resumed`), its token and
media URL unchanged, its ready flag and presence kept; after a
non-resumed `welcome` (grace over, server restarted) the client sends
its intent again (`ready`, `presence`).
## Queue (proto 12)
Up next: what plays after the current media, held by the server for
everyone, in memory (a restart loses it). Any member edits it; items stay
when their adder leaves. Behavior (who puts an item up, the countdown,
when their adder leaves. `by` is the adder's lasting member id: it
survives reconnects, and while the adder is `gone` its `through`/`file`
items wait for it (it is still in `members`) instead of being skipped;
after it left for good they cannot play. Behavior (who puts an item up, the countdown,
skipping) is in [ARCHITECTURE.md](ARCHITECTURE.md), "Up next".
```
@@ -425,11 +605,14 @@ client {"t":"queue","op":"add","at":"end"|"next","item":{<item>},"thumb":"<base6
{"t":"queue","op":"remove","id":"q3","why":"Bob left"}
{"t":"queue","op":"play","id":"q3"}
{"t":"queue","op":"skip"}
{"t":"queue","op":"cancel"}
{"t":"queue","op":"add","item":{<item>,"auto":true,"for":2}}
{"t":"swap","media":{...},"queueId":"q3"}
state "queue":[<item>,...], "queuePlay":"q3"
<item> = {"id":"q3","media":{<swap media>},"how":"direct"|"server"|"through"|"file"|"jellyfin",
"title":"...","sub":"...","duration":213,"thumb":"<hash>",
"by":"m2","byName":"Bob","key":"<adder's handle>"}
"by":"m2","byName":"Bob","key":"<adder's handle>","auto":true?,"for":2?}
state "advance":{"id":"q3","at":"<server time>","secs":10} reserved
```
- `add`: `item` without `id`, `by`, `byName`, `thumb` (the server sets
@@ -461,6 +644,19 @@ state "queue":[<item>,...], "queuePlay":"q3"
the member who can put it up to do so now. `skip` is `play` of the
first item. `queuePlay` ends with any swap, when the item is removed,
or after 30 s.
- `auto` (with `for`, the `mediaId` it continues): the continuation (the
next episode). Only for the current media (else `err` `bad`), and only
from the member who put it up (`media.swapBy`) or, when that member is
not `in`, from the first member who is (else `err` `forbidden`). At most
one: a newer one replaces it; it stays after the other items (an `add`
goes before it, `next` puts an item first, `move` never passes it, it
does not move itself); any swap that does not put it up drops it.
- `cancel`: drops the `auto` item and the countdown for everyone (feed
`advance-cancel`); nothing to drop, nothing happens.
- `state.advance` (**reserved**, step 6): the shared countdown when the
media finishes and an item is first; at `at` + `secs` the server would
set `queuePlay` to `id`, holding while its performer or source is
`gone`. The server never starts one yet; `cancel` and any swap clear it.
- `swap.queueId`: the swap puts that item up and takes it off the queue
in the same step. A swap naming an item that is no longer queued is
ignored (someone put it up first). For a `server` item the swap may
+37 -164
View File
@@ -35,15 +35,8 @@ Problems are numbered; delete a line when its fix ships.
- D11 Login-only sites: error texts say which cookies were used.
**Problems:**
- P1 A reconnect makes you a new member: the relay, ready, sitting out,
your queue items and even your avatar are gone (log: Hoff m23 → m24 had
to put ep 06 up again).
- P2 Joining a relay whose source left serves broken leftovers; mpv loops
on failed seeks; nobody is told the source is gone.
- P3 A joiner during playback lands mid-scene (see D2).
- P4 Reconnects time out repeatedly on a bad link; no clear
"reconnecting" state.
- P6 Sat-out people count in the ready counter.
- P2 Viewers of a relay whose source is gone: mpv loops on failed seeks
past what the server buffered (viewer-side recovery, step 5).
- P7 Sat-out people get tracked as watched on Jellyfin.
- P8 Sat-out people get the normal notifications.
- P9 The watched rule (position coverage) misses real watches (D3).
@@ -85,31 +78,14 @@ 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).
1. Connection + Identity + Presence; proto 16 opened (version ranges on
both sides, refusal frame, identity, grace, presence, relay resume,
ids in the feed); later steps add fields, it is frozen at the 1.3.0
release. A drop is not the end of the session: keep the last state
(marked stale), the player, media and relay source; the UI reads the
connection state (no update band over a running video, no emptied
queue, no reopen); reconnect with backoff and jitter; silence 8–10 s
with "connection unstable" from ~3 s; lost/back sounds only after a
few seconds of drop. New feed actions mapped one-to-one onto today's
notices, matched by member id, not name. Presence shown in the strip
and party UI (joining, away, gone = dimmed "reconnecting"). The relay
source re-dials on return (client half of D6). Auto sit-out with a
`ShellPort.watchActivity` (focused/unfocused/minimised/hidden): never
when alone; while playing only minimised-for-a-while counts; the
client re-sends its intent after a non-resumed welcome. A joiner holds
only while not playing, at most ~15 s; while playing never (D2). The
app close sends `bye` and flushes it; update-and-restart does not.
`Profile` moves to identity. Fixes P1 P3 P4 P6, server half of P2;
D1 D2 D5 D6 (source).
2. Status (one computed level, incl. the server's hold reason) + Notifier
(`noticeFor(event, context)`: self presence, alone, window activity,
playing, prefs, replayed; owns its toast timer; system notifications
port; library snack bars moved in). Fixes P8 P11 P12 P21 P33, "source
left"; D4, D1 notifications, D9 alone policy ("alone" defined once in
core next to `PresencePolicy`).
left"; D4, D1 notifications, D9 alone policy (`isAlone` in core). Step 1
left over for it: the presence notices are today's (sat you out, a
toast when auto sit-out sets you away, lost signal and return only in
the activity log); a replayed feed only logs.
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`
@@ -159,144 +135,42 @@ both first); the dedicated server's fallback folder
`~/.cache/watch-together`, the release token file
`~/.config/watch-together-release-token`, Jellyfin's client name "Watch
Together" (devices are listed under it) and `WatchTogetherApp`; dropping
the 1.2.x launchers from the archive once nobody updates from 1.2.x. Auto
sit-out: idle or minimised for a while sets you Away, activity sets you
In; it replaces the 10-minute auto-unready.
the 1.2.x launchers from the archive once nobody updates from 1.2.x.
Left of step 1: Update and restart keeps the member `gone` for the
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).
**Open decisions:**
- Starting together: today someone has to stay unready so others can
join first. Ideas: a short "starting in 30 s, ready up" countdown;
waiting for everyone from the last session.
- Grace length: ~10 min; a source in grace keeps its relay buffer.
- Platform decision: "Desktop + mpv only" becomes "mpv only, desktop
first" if Android goes ahead.
- D10 was dropped on purpose (preload/warm relay/fresh player are folded
into step 6, not a separate decision).
### Protocol 16 (planned)
### Protocol 16 (open parts)
Lands in step 1, written into PROTOCOL.md then. Tags against proto 15:
**new**, **changed**, **removed**. Rooms are out: one server, no room id.
Versions and refusal:
- `hello.proto` **changed**: the client's version; the server accepts
`[protoMin, protoMax]` (both 16 at 1.3.0; later additions raise
`protoMax` only, so they stay additive). `welcome.proto` **new**: the
version used on this connection.
- Refusal **changed**, stable from 16 on: `{"t":"err","code":"version",
"min":16,"max":16,"server":"1.3.0","message":"proto mismatch: got 15,
want 16..16"}`, then close (a proto 15 app shows `message`). Every
`err` gets `code` **new**: `version`, `secret`, `rejected`, `bad`,
`replaced`.
- `/health`: `protoMin`, `protoMax`, `tls` **new** (`proto` = `protoMax`).
Handshake:
- `hello.client` **new**: a random id per install (22 characters
base64url), private: never broadcast.
- `hello.app` (`"1.3.0"`), `hello.platform` (`linux`, `windows`,
`android`), `hello.caps` **new**: what this app can do (`source`: serve
a relay; `resolve`: run yt-dlp; `jellyfin`: play with its own
account); unknown entries ignored.
- `hello.resume` **removed** (the client id replaces it); `hello.media`
**removed** (unused).
- `members[].app` **new**: the member's `hello.app`.
Lasting member:
- The server maps `client` to one member. A new socket with a known
client id takes that member over (`memberId` and `token` unchanged, so
`/m` `/r` `/j` URLs keep working); the older socket gets `err`
`replaced` and closes. `welcome.resumed` **new**: true when it did.
- A socket close no longer removes the member **changed**: it goes
`gone` and is kept for the grace period (~10 min, `welcome.grace`
**new**, seconds) with its ready flag, presence, queue items and
relay. After the grace it leaves (feed `leave`).
- `{"t":"bye"}` **new**: leave now (Leave button, app close).
Presence:
- `members[].presence` **new**: `joining` | `in` | `away` | `gone`;
`members[].goneSince` **new** (ISO, only while `gone`).
`members[].out` and `members[].stale` **removed** (`away`; a silent
socket is closed and its member is `gone`).
- `{"t":"presence","target":"<memberId>","state":"away"|"in"}` **new**,
replaces `out` **removed**: anyone sets someone `away`; only the member
itself goes back `in`.
- Feed **changed**: `away` (was `out`), `in` (was `back`), `gone` (was
`stale`), `return` **new** (back within the grace: no `join`), `leave`
(after the grace or `bye`), `join` (new members only).
- Gate (one `PresencePolicy` in core, used by server and app): only `in`
counts for the gate, the ready counter and watched tracking; `away` and
`gone` never. A joiner is `joining` until its report holds the current
media with `bufferNeed` at the current position, then `in`. While
`playing` a joiner never holds (D2): it loads in the background and
comes in already in sync. While held it is in the set like a fresh
member today, for at most ~15 s, then out of the set until `in`. A
returning member (`gone` → back within grace) is `joining` again with
its ready flag kept, treated like a joiner; an `away` one stays
`away`.
Relay (D6):
- `media.relay.sourceGone` **new**: true while the source member is
`gone`. The relay and its buffer stay; viewers play what is buffered.
- Source resume **changed**: the source's close no longer ends the relay;
only its leave (grace over, `bye`) or a swap does. Back in the grace,
it dials `/r/<sid>/<token>/source` again; an `info` equal to the first
continues (pulls restart from the first byte or piece not buffered, new
pull ids), a different one ends the relay (close 4002 **new**).
Queue and what's next:
- `queue[].by` **changed**: lasting (the member id survives reconnects);
a `through`/`file` item of a `gone` adder waits instead of being
skipped, until its adder leaves.
- `queue[].auto` **new**: the continuation (next episode): `add` with
`auto:true`; at most one, after the others, replaced by a newer one,
dropped by a swap to something else.
- `state.advance` **new**: `{"id":"q3","at":"<server time>","secs":10}`,
the shared countdown when the media finishes and an item is first; at
`at` + `secs` the server sets `queuePlay` to it.
`{"t":"queue","op":"cancel"}` **new** clears it for everyone (feed
`advance-cancel` **new**).
Media identity:
- `media.ref` **new**, in `swap.media`, `state.media` and
`queue[].media`: `{"kind":"episode"|"movie"|"extra","ids":{"anilist":1,
"tvdb":2},"show":"...","season":1,"episode":6}` (MediaRef); passed
through as is, at most 2 KiB.
Transport: paths unchanged. A server with TLS serves the same endpoints
over `wss://`/`https://` (`/health.tls`); apps use `wss://` when offered,
`ws://` stays allowed.
**Protocol 16 additions (second review):**
- Client range too: `hello.protoMin`/`proto`; the server picks the
highest common version; read `proto` before strict hello parsing; 16
apps still send `hello.media:{}` so a 15 server answers "proto
mismatch"; a 16 app shows a 15 refusal as "the host runs an older
Starsync".
- Ordered takeover: `hello.seq` (monotonic per client), older hellos get
`err stale`; `replaced` is terminal on the client ("opened in another
window"); `welcome.instance` per server boot (grace expired vs server
restarted).
- Error codes `stale`, `forbidden`, `rate`, `full`, plus `err.for`; close
codes listed in one place. `/health.version` (no member names).
- Silence: no frame for ~15 s closes the socket (member `gone`); the
`gone` feed line only after ~10 s gone (a quick return emits nothing);
`presence.reason` manual/idle/minimised/bye; away → in while playing
goes through `joining`; `joining` ends on a report error or timeout; a
second `/source` socket for the same token replaces the first.
- Feed: `actorId`/`targetId`; `feed.seq` with `hello.feedAfter` and a
bounded replay in `welcome` (marked `replayed`: logged, not toasted).
- `state.hold {reason ready|buffer|joining|source|start, members, until}`;
`members[].caps`.
- Relay: `sourceGoneSince`; "same info" = size, duration, tracks, name.
- Queue: `auto` items carry `for: <mediaId>`, added only by the current
swapper (or first in); `advance` holds while its performer or source is
gone; `cancel` drops the `auto` item too.
- `MediaRef` gains `absolute`; `season` may be null.
- `client` is redacted from every log; optionally derived per server
(HMAC of an install secret and the dialled host:port).
- wss: only with a CA-valid certificate in 1.3.0; pinned self-signed via
the Invite `fp` later.
Opened in step 1: [PROTOCOL.md](PROTOCOL.md) is proto 16 (version
ranges and the refusal frame, error codes and close codes, lasting members by client id with takeover and `seq`, grace and
`bye`, presence and `PresencePolicy`, `state.hold`, feed ids, `seq` and
replay, relay `sourceGone` and source resume, lasting `queue[].by`,
`auto` items and `cancel`, `media.ref`, `members[].app`/`caps`), server,
core and app. Still open on the wire, frozen at the 1.3.0 release:
- `state.advance` (step 6): the server starts the countdown when the
media finishes and an item is first, sets `queuePlay` at its end and
holds it while the item's performer or source is `gone`. Today it is
parsed and broadcast but never started; `auto` items and `cancel` are
live.
- `media.ref` producers (step 4); the server carries it already.
- `hello.client` optionally derived per server (HMAC of an install
secret and the dialled host:port).
- TLS: `/health.tls` is always false; `wss://` only with a CA-valid
certificate in 1.3.0, pinned self-signed via the Invite `fp` later.
- `err` code `rate`: reserved, nothing sends it.
## Backlog
@@ -331,8 +205,9 @@ over `wss://`/`https://` (`/health.tls`); apps use `wss://` when offered,
filter on the Schedule from the user's AniList lists. This is the
Schedule's personal tracking; AnimeFlow has no per-user follow.
5. **Ops**: Check shaders on Windows; the relay e2e "source drops
uncleanly and resumes" fails about half the time under the full suite.
5. **Ops**: Check shaders on Windows; run the relay e2e "the source drops
uncleanly and comes back" a few times under the full suite (its
predecessor failed about half the time there).
6. **Windows: first real use.** Check the rename at 1.3.0: a 1.2.x
install updates and restarts (`watch_together.exe`, a copy of
@@ -387,11 +262,8 @@ over `wss://`/`https://` (`/health.tls`); apps use `wss://` when offered,
- HTTP: move the app's clients (Jellyfin, AniList, AnimeFlow, updater)
to dio from `fox_utils` when touching them, for retries/interceptors.
- TLS: document a reverse proxy in front of the server.
- Up next follow-ups (see ARCHITECTURE "Up next"): a member
whose reconnect lands as a new member loses its "through me" links
and files in the queue (skipped as gone); the key could follow the
member. A whole season or a pick list at once ("Add the rest of the
season").
- Up next follow-ups (see ARCHITECTURE "Up next"): a whole season or
a pick list at once ("Add the rest of the season").
- Sidecar subtitles.
## Epic: Jellyfin
@@ -400,7 +272,8 @@ What is left (the rest is in [ARCHITECTURE.md](ARCHITECTURE.md), "Jellyfin
in a session"):
- Auto-advance cancel for everyone (the next episode and Up next): today
Cancel is per screen (the designated swapper's Cancel stops the swap; a
seek stops all). A shared cancel needs a wire message.
seek stops all). The wire has it (`queue` `cancel`, `state.advance`);
the app uses it in step 6.
- Sidecar (external) subtitle streams of Jellyfin items: only embedded
tracks are mapped to mpv today.