Jellyfin end to end: per-viewer playback, progress, episodes, swap browser
- Host/swap send refs only; pasted Jellyfin links become refs; the host hands the relay its own app-obtained credential. - Each viewer resolves with their own account (token in a header) or via the relay, else a clear blocked card with Link account. - Per-account progress reporting (Playing/Progress/Stopped), 401 marks the account expired. - Previous/next episode in the controls (< > / Shift+P/N), floating Next episode pill near the end, auto-advance countdown (one designated swapper), track carry-over per series. - Swap panel uses the shared MediaPicker; browser location remembered per server and opens on the current series. - Fix: states are applied one at a time (a stale pause could land after a newer play). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
773a04369e
commit
ae4edc29e8
33 files changed
+3541
-175
No files matched your search
+84
-9
@@ -26,7 +26,10 @@ an `AppConfig.toMap()` (primitives only, so it crosses isolates). It starts a
|
||||
`SyncServer` (bound to all IPv4 interfaces) and reports on the `events` port:
|
||||
`ready` (with the port and a `stop` SendPort), `error` (start failed), and
|
||||
every log record as `log`. Sending `{'t':'stop'}` to the `stop` port closes
|
||||
all sockets and returns, and the isolate exits.
|
||||
all sockets and returns, and the isolate exits. `{'t':'file', 'path', 'reply'}`
|
||||
on the same port makes a local file the hosted one and answers its name and
|
||||
size (`ServerIsolate.setFile`): the host's in-session File swap then sends a
|
||||
`swap` with that name and size, which the server matches to the file.
|
||||
|
||||
- **Hosting from the app**: `SessionState.host()` spawns an isolate running
|
||||
`serve` (`ServerIsolate`), waits for `ready`, then joins it over
|
||||
@@ -96,7 +99,10 @@ Loosely modeled on Syncplay, with a different readiness design.
|
||||
position, bumps `eventId` and records who sent it (`seekBy`, `seekSeq` in
|
||||
`state` for that event); flags are untouched. Clients diff each `state`
|
||||
broadcast by `eventId` (`StateDiff`): new event means seek to the computed
|
||||
server position, transport change means play/pause. Between events: drift
|
||||
server position, transport change means play/pause. The coordinator
|
||||
applies states one at a time, newest first (a state arriving meanwhile
|
||||
replaces any still waiting): interleaved applies could land an older
|
||||
pause after a newer play. Between events: drift
|
||||
over 5 s for more than 2 s seeks, ahead by more than 1.5 s sets rate 0.95
|
||||
until level, otherwise nothing.
|
||||
- **User seeks**: the player is a `SyncPlayer` whose public `seek` is the
|
||||
@@ -141,7 +147,10 @@ Three sources, same sync.
|
||||
|
||||
- **Link**: the media field holds an http(s) URL. It travels as `media.url`
|
||||
in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it
|
||||
directly; the server is only the clock.
|
||||
directly; the server is only the clock. A pasted link that
|
||||
`parseJellyfinLink` recognises goes up as a Jellyfin ref instead when the
|
||||
ServerId is known (in the link, else `/System/Info/Public`), so its
|
||||
`api_key` is never shared (`mediaForLink`, host and swap alike).
|
||||
- **Host local file**: `SyncServer.setLocalFile` / `mediaPath` makes the file
|
||||
the session media. `GET /m/<sid>/<token>/<name>` serves it with a Range
|
||||
table, gated by the viewer's token. The server builds the URL per viewer
|
||||
@@ -319,6 +328,66 @@ errors leave it out).
|
||||
subscription does).
|
||||
- No progress reporting in the server: watched state is per client.
|
||||
|
||||
## Jellyfin in a session
|
||||
|
||||
Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
||||
`track_prefs.dart`, wired in `SessionCoordinator`):
|
||||
- **Resolve**: a `state` whose media is a ref is resolved per viewer
|
||||
(`resolveJellyfinRef`): an account for that ServerId that is not expired
|
||||
-> PlaybackInfo with its own user (PlaySessionId, media source; `/Items/{id}`
|
||||
alongside for series and episode numbers), then mpv plays the static
|
||||
stream with the token in `http-header-fields` (`Authorization:
|
||||
MediaBrowser` with this install's fields), not in the URL: `OwnPlay`,
|
||||
`report.jf {serverId: true}`. Else, when `state.relay`, `welcome.relayUrl`
|
||||
or `/j/default/<token>/<itemId>` on the dialled host: `RelayPlay`,
|
||||
`jf: false`. Else `BlockedPlay`: the session shows "This needs a
|
||||
Jellyfin account on <server> (or the host can relay)" with a button that
|
||||
opens Integrations over the session; linking (or reconnecting) the
|
||||
account retries by itself. A 401 marks the account expired. The member
|
||||
list shows "via host" for members whose `jf` is false for the current
|
||||
item. A reconnect to the same item with `OwnPlay` keeps playing as is.
|
||||
- **Progress** (`JellyfinProgressReporter`, `OwnPlay` only): Playing once the
|
||||
file is open and mpv has picked its tracks; Progress every 10 s and at
|
||||
once on pause, resume, a seek (position more than 3 s from where playback
|
||||
should be) or a track change; Stopped on swap, leave, kick, window close
|
||||
(bounded to 1.5 s inside the graceful leave) and at `finished` (position =
|
||||
duration, so Jellyfin marks it played); seeking back from the end sends
|
||||
Playing again. Reports carry `PositionTicks`, `IsPaused` and mpv's
|
||||
`aid`/`sid` as Jellyfin stream indices (`jellyfinIndexForMpvTrack`,
|
||||
`SubtitleStreamIndex -1` = off). They go out in order on one chain;
|
||||
failures are logged, one quiet toast per session, playback is never
|
||||
touched; a 401 marks the account expired and ends reporting.
|
||||
- **Episodes**: with `OwnPlay` on an episode, `adjacentEpisodes` (one
|
||||
`/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the
|
||||
previous and next episode. The controls show previous / next buttons
|
||||
(also `<` `>`, Shift+P / Shift+N); from `nextPromptStart` (last 2 min of
|
||||
the runtime; the single place to switch to an outro segment later) a
|
||||
floating "Next episode" pill shows outside the auto-hiding controls. Any
|
||||
of them swaps to that episode's ref with the clicker's account. Guests
|
||||
(relay, blocked) see none of it.
|
||||
- **Auto-advance** (Settings, on by default): at `finished` with a known
|
||||
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
|
||||
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
|
||||
the swapper's Cancel stops the swap; a seek by anyone ends `finished`
|
||||
and with it every countdown.
|
||||
- **Tracks**: the controls' subtitles button lists mpv's audio and subtitle
|
||||
tracks. A pick is applied, reported, and saved as a `TrackChoice`
|
||||
(language, title, codec, channels, forced/HI: from the Jellyfin streams
|
||||
with `OwnPlay`, else from mpv's track list) per series and as the global
|
||||
fallback in the `track_prefs` Hive box. On the next Jellyfin item it is
|
||||
matched with `matchTrack` against the source's streams (mapped to mpv ids
|
||||
with `mpvTrackId`) or mpv's track list, and selected locally. Nothing goes
|
||||
on the wire.
|
||||
- **Swap panel**: "Change video" in the side panel opens the shared
|
||||
`MediaPicker` over the video (`screens/session/swap_panel.dart`); Link
|
||||
goes through `mediaForLink`, a Jellyfin pick as a ref, File (host only)
|
||||
through `ServerIsolate.setFile` and a matching `swap`.
|
||||
|
||||
## Desktop UI
|
||||
|
||||
One `HomeScreen` (`screens/home/`) with a single top bar (`top_bar.dart`):
|
||||
@@ -339,7 +408,7 @@ Jellyfin source the browser fills the height and can be enlarged, which
|
||||
turns the column into a one-row bar under it. Text fields have fixed or capped widths (720 px max) so
|
||||
they stay compact on ultrawide screens. Submit state (busy, cancel,
|
||||
per-tab errors, server crash) is `ConnectFlow`. Settings (profile, hardware
|
||||
decoding, updates, video shaders, shortcuts), Integrations
|
||||
decoding, Jellyfin auto-advance, updates, video shaders, shortcuts), Integrations
|
||||
(linked Jellyfin servers) and About open in the same body. Settings persist
|
||||
in two Hive boxes: `connect_form` (last form and recents; saved even when a
|
||||
join fails) and `settings`. Link media names are derived from the URL path
|
||||
@@ -369,11 +438,17 @@ Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
|
||||
hover/focus from 1400 px browser width.
|
||||
- Picking an item gives a `JellyfinMedia` (serverId, baseUrl, itemId,
|
||||
mediaSourceId?, title, subtitle, runtime; no credentials). The Host tab
|
||||
remembers the last pick in the `jellyfin` box. Interim until the protocol
|
||||
carries Jellyfin items: `interimJellyfinLink` in `host_tab.dart`
|
||||
(`INTERIM(jellyfin-protocol)`) hosts it as a link to the host's
|
||||
authorised static stream URL; `ConnectFlow.submit(linkOverride:)` keeps
|
||||
that token-bearing URL out of the saved form.
|
||||
remembers the last pick in the `jellyfin` box and hosts it as a ref
|
||||
(`hostJellyfinMedia`, `ConnectFlow.submit(media:)`); the hosted server
|
||||
gets the relay credential (`relayCredentialFor`: the account of the
|
||||
item's server, else the default one, not expired; its token with
|
||||
exactly `JellyfinAccounts.clientInfo`'s device fields).
|
||||
- Browser location: every navigation saves the route stack per server
|
||||
(`browse:<serverId>` in the `jellyfin` box, `encodeBrowseStack`: ids,
|
||||
names and the fields pages start from), so the Host tab, the swap panel
|
||||
and the next start open where the browser was. A `BrowseStart` overrides
|
||||
it: the swap panel opens on the series of the episode playing, its
|
||||
season selected and the episode highlighted.
|
||||
|
||||
Window chrome is frameless (`window_manager`): every screen brings its own
|
||||
drag area and min/max/close buttons (Home's top bar; the session uses
|
||||
|
||||
+2
-1
@@ -46,7 +46,8 @@ and `e2e_check` (simulated viewers against a live server).
|
||||
- `cd packages/core && dart test`, `cd packages/server && dart test`.
|
||||
- `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`. The sync and
|
||||
`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
|
||||
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
|
||||
|
||||
+10
-4
@@ -17,7 +17,9 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
|
||||
+----------------------------------------+
|
||||
|
||||
video bytes come from somewhere else:
|
||||
- a link (Jellyfin, any http/https URL): each app fetches it directly
|
||||
- a link (any http/https URL): each app fetches it directly
|
||||
- a Jellyfin item: each app plays it with its own Jellyfin account,
|
||||
friends without one through the server (/j/... endpoint)
|
||||
- a file on the host's disk: the server streams it (/m/... endpoint)
|
||||
```
|
||||
|
||||
@@ -26,8 +28,10 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
|
||||
- **Server**: keeps one shared clock and the list of people. It never sees
|
||||
video frames, only "position 123 s, playing". There is exactly one server
|
||||
and one watch session on it.
|
||||
- **Jellyfin** (or any web host): where a link's video lives. The server does
|
||||
not touch it today; each app downloads it itself.
|
||||
- **Jellyfin** (or any web host): where the video lives. For a Jellyfin
|
||||
item the server only knows which item it is; each app signs in with its
|
||||
own account and reports what it watched. For friends without an account
|
||||
the server fetches the item with the host's sign-in and passes it on.
|
||||
|
||||
## Ways to run
|
||||
|
||||
@@ -71,7 +75,9 @@ anyone except the host.
|
||||
## Where video comes from
|
||||
|
||||
- **Link**: you paste an http(s) URL. Every app plays it directly with mpv.
|
||||
Works for a Jellyfin direct-download link or any file on the web.
|
||||
A pasted Jellyfin link becomes the Jellyfin item below when possible.
|
||||
- **Jellyfin item**: picked in the app's library browser. Shared as "this
|
||||
item on this Jellyfin server", never as a link with someone's sign-in.
|
||||
- **Local file (host only)**: you pick a file on your disk. The server
|
||||
streams it to everyone, host included, over HTTP with range requests, each
|
||||
with a per-person token. Nothing is stored or converted.
|
||||
|
||||
+14
-22
@@ -24,8 +24,7 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
||||
next suspect is media_kit's context switching on the raster thread
|
||||
with NVIDIA.
|
||||
- Link conveniences: a link swap in session updates the remembered media;
|
||||
show the open link with an open-in-browser button; the session's swap
|
||||
panel reuses Home's `MediaPicker`.
|
||||
show the open link with an open-in-browser button.
|
||||
- Time UI: remaining time, scrubber preview.
|
||||
- mpv / mpv.net shortcut parity.
|
||||
- Sounds: replace `NoopSfxService` (cues already fire). Candidates:
|
||||
@@ -38,32 +37,25 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
||||
- Avatar caching: serve avatar bytes once by hash instead of base64 in
|
||||
every `state` broadcast.
|
||||
- The `state` broadcast carries the full media URL, which can include an
|
||||
`api_key`.
|
||||
`api_key` (recognised Jellyfin links become refs; other links do not).
|
||||
- TLS: document a reverse proxy in front of the server.
|
||||
- Playlist: `queue-add`, `queue-skip`, auto-advance like a swap.
|
||||
- Sidecar subtitles.
|
||||
|
||||
## Epic: Jellyfin
|
||||
|
||||
- Each viewer logs in to Jellyfin themselves (Quick Connect or password).
|
||||
- Several Jellyfin servers can be linked, keyed by `ServerId`.
|
||||
- The sync server shares only `{ServerId, ItemId}`. Pasted Jellyfin links are
|
||||
recognised and reduced to that pair.
|
||||
- Viewers with an account direct-play `/Videos/{id}/stream?static=true`
|
||||
with their own token and report Playing / Progress / Stopped, so watched
|
||||
state and Continue Watching update per account.
|
||||
- Guests without an account play through the sync server, which fetches with a
|
||||
credential that stays server-side (the host's own or a configured one).
|
||||
- Next episode: the successor in `/Shows/{seriesId}/Episodes` (NextUp means
|
||||
first unwatched, not next-after-X); audio and subtitle track matching by language, then title,
|
||||
then codec/channels.
|
||||
- Library browser; anyone can put up items.
|
||||
|
||||
Spike facts (Jellyfin 10.11.11): `/Videos/{id}/stream?static=true&mediaSourceId=`
|
||||
serves the original with byte ranges (206). PlaybackInfo gives `PlaySessionId`
|
||||
and DirectPlay. All audio and subtitle tracks are embedded in the mkv. Quick
|
||||
Connect is enabled but `/QuickConnect/Initiate` returned 400: investigate
|
||||
(likely missing client/device fields in the auth header).
|
||||
What is left (the rest is in [ARCHITECTURE.md](ARCHITECTURE.md), "Jellyfin
|
||||
in a session"):
|
||||
- Queue: put several library items (a season, a pick list) up as a
|
||||
playlist that advances like a swap (shares `queue-add` / `queue-skip`
|
||||
with the backlog's Playlist item).
|
||||
- The "Next episode" prompt from the item's Outro / credits segment instead
|
||||
of the last two minutes (`nextPromptStart`).
|
||||
- Auto-advance cancel for everyone: today Cancel is per screen (the
|
||||
designated swapper's Cancel stops the swap; a seek stops all). A shared
|
||||
cancel needs a wire message.
|
||||
- Sidecar (external) subtitle streams of Jellyfin items: only embedded
|
||||
tracks are mapped to mpv today.
|
||||
|
||||
## Deferred
|
||||
|
||||
|
||||
Reference in new issue
Block a user