Restructure step 1: lasting identity, presence and protocol 16
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:
1 parent
51c1c5ab78
commit
9f2586b7ff
85 files changed
+5198
-1902
No files matched your search
+236
-134
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user