Protocol 13: Up next preloads, so switching to it is instant

Every app gets the first Up next item ready itself (own Jellyfin plan,
direct links, guests via /j when state.relayNext says the server's
login can pass it on) and opens it in a hidden, muted, paused second
player; the swap promotes that player instead of opening cold, so its
buffer counts at once. Reports go out on open and when the buffer
reaches the gate's need. SwitchTrace times each phase. Measured in the
e2e harness: 1.5-3 s before, 35-103 ms with a preload. Relayed items
still open cold for everyone but their source (ROADMAP).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-11 16:15:16 +03:00
1 parent 4275abe470
commit ddd7a21195
19 files changed
+1041 -142

No files matched your search

+70 -21
View File
@@ -103,6 +103,9 @@ Loosely modeled on Syncplay, with a different readiness design.
media still resolving, being fetched by the server or opening), and the
server counts a report naming an earlier `mediaId` as 0, so the old
media's buffer never passes the gate for the new one.
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
(`minBufferSecs`), or near the end what is left minus 2 s
@@ -277,8 +280,9 @@ or a 5xx is retried, at most 3 times per read and 4 s apart at most; a
fatal "No video or audio streams selected", or an EDL track that cannot
be opened) before a duration is known is reported once per open
(`onLoadError`), with ffmpeg's HTTP status when this player gets
ffmpeg's log (ffmpeg logs through one mpv instance per process, the
first one alive). That, a yt-dlp hook error and a failed `openUrl` become
ffmpeg's log (ffmpeg logs through one mpv instance per process: the
first one, and once that is disposed the next one created; the preload
player passes the HTTP errors it gets on to the session player). That, a yt-dlp hook error and a failed `openUrl` become
the coordinator's `playerError` in plain words (`playErrorText`, core
`link_probe.dart`: 412/precondition/login/cookies/sign in → "This site
needs cookies — Settings → Player → yt-dlp → Cookies", 403 → "Not allowed
@@ -336,14 +340,16 @@ can silently fall back to software.
Player lifecycle: the `Player` and its `VideoController` live for one
session: created on join, disposed on leave, replaced by `recreate()` for
"Reload video" (button or Ctrl+R; volume kept). `openUrl` opens with `play: false`; play/pause/seek/rate come only from
"Reload video" (button or Ctrl+R; volume kept), or by a promoted preload
when Up next's first item goes up (see "Getting ready" under "Up next"). `openUrl` opens with `play: false`; play/pause/seek/rate come only from
`StateDiff` actions, the play gate (`localPlayAllowed`: self in-set and
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
and `StateDiff` seeks to 0, holds and resets rate.
(or promotes the preload holding it) and `StateDiff` seeks to 0, holds
and resets rate.
Chapters, segments, tracks (`PlaybackController` signals, values from
`playback/media_marks.dart`, no widgets; any controls UI reads them):
@@ -436,8 +442,10 @@ relay stays off. The token is never logged (`toString` and config parse
errors leave it out).
- Available (`state.relay`) only while the current media is a ref whose
`serverId` equals the credential's. Only that item is relayed, only to a
valid viewer token: no open proxy.
`serverId` equals the credential's; `state.relayNext` likewise for Up
next's first item, which `/j` also serves so guests can preload it
(see "Getting ready" under "Up next"). Only those two items are
relayed, only to a valid viewer token: no open proxy.
- Each request opens `/Videos/{id}/stream?static=true&mediaSourceId=` with
`Authorization: MediaBrowser` built from exactly the configured fields
(Jellyfin rewrites a token's device fields from the header), forwards
@@ -873,23 +881,64 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
with the reason. A swap without `queueId` (a manual one) leaves the
queue alone. A member who puts its own link or file up while sitting
out comes back first; any other queued swap leaves the swapper out.
- **Getting ready** (`QueueController._warm`): the one item this app
puts up next is kept ready all the time, so a skip, Play now or the
end of the media starts it at once. That is the first Up next item on
the app that puts it up (`queuePerformer`), else, with Up next empty,
the first "Continues with" episode on an app that has it. It is
resolved as soon as it is first there (or this app becomes the one),
again every 30 minutes, and dropped when it stops being first: a link
is probed (`AppYtDlp.probe` with `keep`: the result stays 35 minutes
instead of 10 and a refresh replaces it only once it is in; swaps read
it from there); a Jellyfin item is resolved with this app's account
- **Getting ready** (`QueueController._warm`): every app keeps the one
item that plays next ready all the time, its own copy, so a skip, Play
now or the end of the media starts it at once. That is the first Up
next item, else, with Up next empty, the first "Continues with"
episode (on the apps that have it); while sitting out only the probe
of a link this app puts up (its swap reads it). Per
kind: a Jellyfin item is resolved with this app's account
(`resolveJellyfinRef`: PlaybackInfo and the item) into
`SessionCoordinator.warmJellyfin`, which `_playJellyfin` takes instead
of resolving when the same ref goes up within those 35 minutes and the
account is unchanged. A file or a server-fetched link has nothing to
get ready. One item, one app, nothing on the server. Its row shows a
dot on its picture's corner (a tooltip says which): hollow waiting, pulsing getting ready, filled ready, red failed
(tried again every 2 minutes).
of resolving when the same ref goes up within 35 minutes and the
account is unchanged; without an account it is the server's `/j` URL
when `state.relayNext` says the server's login passes it on (see
"Jellyfin relay"). A link everyone opens is that link; the app that
puts it up also probes it (`AppYtDlp.probe` with `keep`: kept 35
minutes instead of 10, a refresh replaces it only once it is in; swaps
read it from there). A link passed on "through me" and a file only on
their adder's app (the link, the path; not the host's own file, which
plays from `/m`). A server-fetched link and others' relayed items have
nothing to get ready (the relay starts with the swap; see ROADMAP). It
is resolved as soon as it is first there, again every 30 minutes, and
dropped when it stops being first.
**Preload**: once resolved, the stream is opened in a second, hidden
mpv player (`PlaybackController.preload`, through
`SessionCoordinator.preloadMedia`): paused at the start, muted, with
its own video output when the session view is up (media_kit starts a
player with `vid=no`; the output decodes the first frame), the same
pins, hwdec and yt-dlp hook as the session player but a 48 MiB forward
and 8 MiB back cache (`preloadBytes`), mpv log at `error`. One at a
time; it waits until the media playing has started here
(`SessionCoordinator.settledMediaId`: opened, failed or nothing to
open), so it never competes with that open and a preload a swap is
about to take is never replaced first; a removed, reordered or replaced
first item swaps or drops it then, a manual swap to something else
keeps it (still next). A link is opened again at the 30-minute refresh
(stream URLs expire); a failed preload is not retried until then and
changes nothing (the usual open follows). When the coordinator opens
the same URL and headers (`openUrl`), the preload is promoted instead:
it becomes the session player (log, marks and track observers move
over, volume, unmute, the full cache pins, shaders), the view swaps to
its video output (`generation`), and the old player is disposed once
the view let go of its texture (as on leave). Its buffer comes along,
so the report sent right after the open already passes the gate: the
start no longer waits for anyone's open. A promoted player is no
longer the first mpv of the process, so ffmpeg's HTTP status reaches
it only when it happens to be the instance ffmpeg logs through (load
errors themselves are still detected; see "Media"). Off with env
`SYNC_RELAY_PRELOAD=0`. Measured (`queue_switch_e2e_test`, a fake
Jellyfin and link host answering after 120 ms at 24 Mbit/s, two
account holders and a `/j` guest): ⏭ to the server playing 59 to
86 ms for the next episode and 35 ms for a direct link, against about
1.5 s cold; at 300 ms and 12 Mbit/s 52 and 103 ms against about 2.9 s.
`SwitchTrace` (env `SYNC_RELAY_TRACE_SWITCH=1` prints it) times each
phase of a media change: state, resolved, open, opened or preloaded,
buffered, playing.
The row of the item shows a dot on its picture's corner (a tooltip
says which): hollow waiting, pulsing getting ready (resolving, then
preloading its first 4 s), filled ready, red failed (tried again every
2 minutes).
- **Continues with**: not queued, computed here like the next episode:
with an own-account Jellyfin episode playing, the next three episodes
of the series (`/Shows/{id}/Episodes`, cached), greyed under Up next,
+7 -2
View File
@@ -39,7 +39,9 @@ the dedicated binary. Prints `READY port=... name=...` and stops on SIGINT
or SIGTERM. Then Join from one or more apps at `127.0.0.1:8080`. Or just
Host from the app. mpv env override:
`SYNC_RELAY_HWDEC` (wins over the Settings choice). `SYNC_RELAY_VO` has no
effect with embedded video: media_kit forces `vo=libmpv`.
effect with embedded video: media_kit forces `vo=libmpv`. `SYNC_RELAY_PRELOAD=0`
turns Up next's preload player off; `SYNC_RELAY_TRACE_SWITCH=1` prints
the timings of each media change (`SwitchTrace`).
Harnesses in `tool/` (own `pubspec.yaml`, `dart run tool/<name>.dart`):
`range_logger` (serve a file and log every Range request), `shaper`
@@ -52,7 +54,10 @@ and `e2e_check` (simulated viewers against a live server).
- `cd apps/desktop && flutter test test/<file>`.
- Real-mpv tests: `sync_playback_e2e`, `coord_presence`, `video_reload_e2e`,
`seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `jellyfin_e2e` (a
local fake Jellyfin over HTTP; tests never talk to a real Jellyfin). The sync and
local fake Jellyfin over HTTP; tests never talk to a real Jellyfin),
`queue_switch_e2e` (times Up next switches against a delayed, rate-capped
fake Jellyfin and link host; `WT_SWITCH_PRELOAD=0` for the cold path,
`WT_SWITCH_DELAY_MS` / `WT_SWITCH_RATE_KBS` for the link). The sync and
end-of-file ones expect `~/Videos/chiyo.mp4`; others generate media with
ffmpeg into `WT_TEST_TMP`. Headless mpv (no video output attached) won't
open video-only files, so test media needs an audio track. Tests always log
+12 -7
View File
@@ -1,4 +1,4 @@
# Protocol (proto = 12)
# Protocol (proto = 13)
Wire format only. Behavior (gate, clock, timeouts) is in
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
@@ -21,7 +21,8 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
- `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 (see
`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>`
@@ -30,7 +31,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
## Client to server
```
{"t":"hello","proto":12,"secret":"","user":{"name":"","avatar":""},
{"t":"hello","proto":13,"secret":"","user":{"name":"","avatar":""},
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
"resume":"<token>"}
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
@@ -133,7 +134,8 @@ Field notes:
"media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent,
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?,
"host"?,"out"?,"error"?,"rate"?}],
"seekBy":"m1","seekSeq":3,"relay":true,"queue":[<item>]?,"queuePlay":"q3"?}
"seekBy":"m1","seekSeq":3,"relay":true,"relayNext":true,"queue":[<item>]?,
"queuePlay":"q3"?}
{"t":"pong","c":1767225600000,"s":1767225600012}
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
{"t":"chat","text":"...","from":"<name>"}
@@ -159,7 +161,9 @@ Field notes:
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
`eventId` came from a seek. `relay` (omitted when false): the server can
relay the current Jellyfin media. `members[].jf` is the member's last
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
login), so viewers without access preload it from `/j`. `members[].jf` is the member's last
`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
@@ -243,12 +247,13 @@ state media.art = {"poster":"<hash>","banner":"<hash>"}
without one plays the relay URL when `state.relay` is true: `welcome.relayUrl`,
or after a swap built like the `/m` one from the host it dialled:
`http://<host>/j/default/<token>/<itemId>`.
- `GET /j/<sid>/<token>/<itemId>`: the server fetches
- `GET /j/<sid>/<token>/<itemId>`: for the current item or, when
`state.relayNext`, Up next's first one, the server fetches
`/Videos/<itemId>/stream?static=true&mediaSourceId=<id>` (the original
file) with its own credential and streams it through. `Range` is forwarded
as sent; status, `Content-Range`, `Content-Length`, `Content-Type` and
`Accept-Ranges` come back as Jellyfin sent them. Errors: 401 bad token,
403 not the current item, 404 no relay for the current media,
403 neither of those items, 404 no relay for either,
410 unknown session, 502 Jellyfin unreachable or rejecting the server's
credential. Bytes count toward `bytesServed`.
- Pasted Jellyfin links sent as plain `url` stay plain links.
+19
View File
@@ -56,6 +56,25 @@ off by deleting it in the same commit as the code. Wire changes also edit
over instead of the buffer running dry.
- "Bob can't open this link — relay it?": offer the relay to whoever put
a direct link up when a viewer's player fails to open it.
- Preload relayed Up next items (see ARCHITECTURE "Getting ready"):
today a link passed on "through me", another member's file, a
server-fetched link and the host's own file are opened cold by
everyone but their source when they go up (their relay or `/m`
starts with the swap), so the gate waits for the slowest open. Plan
(wire change): the server holds a second, small relay for Up next's
first item, keyed by its queue id. Its adder's app dials a second
source for it as soon as it is first (`/r/.../source` naming the
queue id); a server-fetched item starts its fetch then;
`state.queue[0].relay` (`live`, `tracks`) tells viewers when and
how to open `/r/<sid>/<token>/q/<queueId>/<name>` in their preload
player. The buffer keeps only the head and tail pins plus about
32 MiB ahead of the viewers until the swap, pulls nothing while
nobody preloads, and is dropped when the item stops being first. A
swap with `queueId` turns it into the media's relay (same buffer;
the `q/` URL keeps working for that media, so the preloads match
and are promoted). The host's file: the hosting app hands the
server the first item's path early and `/m` serves it under a
`next/` name until the swap.
8. **Jellyfin** (epic, below).