Player overlay as one piece, Up next always warm and on every source, Jellyfin never unmarks a watched episode

- Overlay: one layout for both designs (top bar with Up next as one of
  its buttons; one action band above the controls for the status pill,
  skips, next episode and end cards), one glass recipe (AppGlass) and
  one pill (FloatingPill); hovering any of them, or the open queue,
  holds the controls up.
- Up next: the first item (or the first continuation episode) is kept
  resolved on the app that puts it up, refreshed before it expires;
  Schedule and drop/paste can queue too.
- Jellyfin: WatchSession (core) counts real watched coverage; an
  episode is watched at 80 % or at its ending after half; a played one
  is re-marked after Playing (10.11 unmarks on open) and never gets a
  position; a peek leaves the old resume point; part-way resume points
  stay under 85 %.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-11 07:53:42 +03:00
1 parent 6b10cae4e6
commit 771b961b32
27 files changed
+1396 -558

No files matched your search

+121 -41
View File
@@ -7,7 +7,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
| Path | What | Rule |
|---|---|---|
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater | pure Dart, no Flutter |
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`) | pure Dart, no Flutter |
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
| `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, update UI (`UpdateController`) | ships with `watch-together-server` |
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
@@ -688,7 +688,8 @@ all three link modes. Nothing is bundled or installed by hand.
reason and the GET's HTTP status. `LinkProbe.problem`: why it will not
play, in plain words (`playErrorText` of yt-dlp's reason, else of a 4xx
status), null for a file or a page yt-dlp read. The app probes on paste
(`AppYtDlp.probe`, remembered 10 minutes; media file names too, not a
(`AppYtDlp.probe`, remembered 10 minutes, longer for Up next's first
item (see "Up next"); media file names too, not a
Jellyfin item link) for the
Link preview and the swap: a page is named by its title, never its URL
tail, and its thumbnail goes up as its art (see "Media art"). A link with a
@@ -754,13 +755,43 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
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, sitting out, 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.
(bounded to 1.5 s inside the graceful leave) and at `finished`; seeking
back from the end sends Playing again. Reports carry `PositionTicks`
(see "Watched rule"), `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.
- **Watched rule** (`WatchSession` in `packages/core/lib/src/watch_session.dart`,
pure, meant for any tracker): each opening samples the position every
tick; only normal playback counts (forward, at most 2x speed + 1.5 s,
samples at most 10 s apart; seeks, skips and pauses add nothing). The
union of the watched stretches is the coverage (rewatching counts once),
alongside the plain watched time, first / last / furthest position and
start / end time. At a stop: played before this opening -> keep played;
coverage >= 80 % of the runtime, or the ending reached (furthest position
past the Outro / ED start, else the real end) with coverage >= 50 % ->
played; coverage under max(2 min, 5 %) (at most a quarter of a short
item) -> a peek; else part way, resuming at the last position. An
unknown runtime never makes it played. Jellyfin decides played from
positions alone (10.11): Playing sets a resumable item unplayed, a
Progress or Stopped position past "max resume" (90 %) marks it played,
one in between keeps a resume point. So `JellyfinProgressReporter`
(played before = `UserData.Played` read at open with
`/UserItems/{id}/UserData`):
- played (before, or by the rule at an earlier stop of this opening):
`UserPlayedItems` right after Playing, Progress without
`PositionTicks` (leaves the saved position and played state), Stopped
without `PositionTicks` (Jellyfin marks it played, position 0), then
`UserPlayedItems` again;
- peek: Stopped with the resume point from before the opening (0 when
none), so the item is as it was;
- part way: Stopped at the last position, held under 85 % of the
runtime so Jellyfin cannot mark it played;
- not played yet, any time: Progress leaves `PositionTicks` out from
85 % of the runtime on (and without a runtime), so only the rule
marks it played.
Members playing through the server report nothing.
- **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
@@ -832,7 +863,7 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
puts the first item up does, with a swap naming it (`swap.queueId`), so
the server takes it off in the same step and a second swap of it is
ignored. Cancel is per screen like the next-episode countdown. "Play
now" anywhere (a row, the card, the chip's ▶, `>` / Shift+N) swaps
now" anywhere (a row, the card, the Up next button's ⏭, `>` / Shift+N) swaps
directly from the app that can, else sends `play` and that app reacts
to `state.queuePlay`. With nothing up at all, the first item goes up as
soon as it is added. The swap goes through the usual paths
@@ -842,37 +873,52 @@ 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** (`queueWarmDue`): in the last 90 s of the current
media, once it is buffered to its end, or at `finished`, the app that
will put the first item up resolves its link (`AppYtDlp.probe`, kept 10
minutes, which the swap then reads), so the swap does not wait for
yt-dlp. A Jellyfin item, a file or a server-fetched link has nothing to
get ready there. Only the first item, only that app, nothing on the
server. Its row shows a dot: hollow waiting, pulsing getting ready,
filled ready, red failed (tried again when it comes up).
- **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
(`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).
- **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,
"+" adds one. "Clear" hides them for the media playing and keeps its
next-episode countdown from starting.
- **UI**: `UpNextChip` top right under the top bar (under the party in
fullscreen), shown with the controls like the pills (`PillGate`) and
while its popover is open: "Up next · title", a +n count, ▶ plays it
now; without Up next the continuation ("Next · S1E8 …", ▶ = next
- **UI**: `UpNextButton` is one of the player's top-bar controls, before
chat (see "Player overlay" under Desktop UI), in the look of the
design's buttons (`UpNextLook`); it shows and hides with the controls,
which an open popover holds up: "Up next · title", a +n count, ⏭ plays
it now; without Up next the continuation ("Next · S1E8 …", ⏭ = next
episode); nothing when both are empty. Its title opens the queue as a
`LeafPopover` (package `leaf_widgets`, an overlay rather than a route):
frosted glass like the app menus; rows with a 16:9 picture, title, second
line, "length · added by (avatar) name · how", a drag handle
`LeafPopover` (package `leaf_widgets`, an overlay rather than a route)
hanging under it, right edges aligned: the app's glass (`AppGlass`),
the menus' paddings and row hover, 64 px rows with an 80 px 16:9
picture, title, second line, "length · added by (avatar) name · how", a drag handle
(`ReorderableListView`, reordered here at once and sent as `move`), ▶
and × floating in on hover; "Continues with"; a "Paste a link to add"
field (focused on open, so the player's keys and Space rest while it
is up; Esc closes) and a drop zone for links and video files. Being no
route, it holds the shell's own drop and paste handling off while open
(`handoffHolds`). "Add to queue" elsewhere: the Link page's button
next to Put it up (in the chosen mode), a right-click menu on recent
links, Jellyfin cards, episodes and details-panel episodes, and Files
videos (Put it up / Add to queue / Play next), and a button in the
Jellyfin details panel (right-click: play next). None of it offline.
(`handoffHolds`). "Add to queue" elsewhere, on every source: the Link
page's button next to Put it up (in the chosen mode), a right-click
`showAppMenu` (Put it up / Add to queue / Play next) on recent links,
Jellyfin cards (home rows, libraries, search), series episodes and
details-panel episodes, Files videos and Schedule cards that can be
put up, a button in the Jellyfin details panel (right-click: play
next), and Add to queue / Play next on the drop and paste card (videos
in order, a link in the Link page's mode). All go through
`QueueController.add`. None of it offline.
## Desktop UI
@@ -988,7 +1034,10 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
by the linked account (`jellyfinEpisodeNumbers`, one request per series
memoised 3 min; `confirmOnJellyfin` is the pure step); absent reads
"aired · not on Jellyfin yet", unknown (no linked account, lookup
failed) "aired", neither with Put it up. Requests and the owner's inbox are in
failed) "aired", neither with Put it up. Put it up resolves the Jellyfin
episode (`resolveJellyfinEpisode`: AnimeFlow's numbering, else the
season and offset, else the air day) and goes through the shell's
put-up; right-click queues it the same way. Requests and the owner's inbox are in
`requests_sheet.dart`. It refreshes every minute; mouse drag and
Shift+wheel scroll the board sideways, the plain wheel scrolls a column.
Settings (`settings_tab.dart`) has a left nav with icons
@@ -1008,7 +1057,8 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
`HandoffCard` opens as a popover at the pointer: a video goes up through
the shell's put-up (Play when alone; offline that starts solo) or adds
its folder to Files; a folder is added to Files and opened; a link fills
Library → Link (with others the relay mode is picked there). Images are ignored.
Library → Link (with others the relay mode is picked there). In a
server, videos and links also get Add to queue / Play next. Images are ignored.
Nothing happens while a dialog or popover is over the shell.
- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready,
unready, force-ready, join, leave, swap and others' chat (feed
@@ -1059,8 +1109,8 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
the banner (else a blur of the cover, else dark), darkened, with cover,
big title and episode; it fades out on play and stays gone for that
media; under 320 px tall (the mini) it drops the cover and shrinks the
title. One status pill (`player_status.dart`) sits bottom centre above
the controls: `playerStatusFor` (pure) picks the most pressing of
title. One status pill (`player_status.dart`) sits centred in the
action area (see "Player overlay"): `playerStatusFor` (pure) picks the most pressing of
removed, reconnecting (Cancel), a warn/error health message (Retry),
connecting (only after 2 s), finished, someone loading (below
`StateBroadcast.bufferNeed`, the gate's own need at the current
@@ -1073,10 +1123,10 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
line) and are never in "waiting for". A member's new error also toasts
once (the pill is hidden while playing); avatars carry a red error mark
and the members card says "can't play this: ...". 34 px avatars, a main line
and a muted detail line. Frosted glass, colour only for problems; it fades through
and a muted detail line. The pill glass, colour only for problems; it fades through
between kinds. The mini shows its short line in its hover bar. ⚙ in both
control designs opens the player settings card (`controls/
player_settings.dart`, `showPopover`): player design, progress line
player_settings.dart`, `showPopover`, on `AppGlass`): player design, progress line
when hidden, upscaling shader, hardware decoding (set live on mpv's
`hwdec`), subtitle and audio track. The rows (`playerSettingRows`) are
the same widgets Settings → Player shows; all apply live. The progress
@@ -1086,6 +1136,35 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus.
exit), as media_kit does not expose it. Fullscreen adds the
`WindowButtons` to the top bar while the controls are up (restore
leaves fullscreen).
- **Player overlay** (full player, both designs; `session_player.dart`):
everything over the picture has a fixed place on one grid (16 px from
the sides, the bars' inset), so nothing floats on its own. The top
bar (part of the controls): now-playing title left; right the Up next
button (`UpNextButton`), chat, back-to-library or exit fullscreen,
then the window buttons in fullscreen, all in that design's button
look. Under it at the right, in fullscreen while paused, the party's
avatars; with the controls down and unread chat, the unread chip top
right. The action area: one band just above the bottom controls
(`_actionArea`; their height per design, 16 px gap), sliding down to
16 px from the bottom while they are hidden (`AnimatedPositioned`,
the shell's `ease` / `easeTime`); in it the status pill centred
(moved left only as far as it must to clear the right end, never
overlapping it) and at the right end the end card (`UpNextCard`, else
`EndOfEpisodeCard`, which stay up) or the action pills (Skip OP / ED /
1:30 over Next episode, `PillGate`). The Jellyfin blocked card sits in
the middle. Two surfaces: cards and popovers use `AppGlass`
(`widgets/app_menu.dart`, the menus' recipe: blur, dark tint, hairline
border, 12 px radius, shadow); pills use `PlayerGlass`
(`AppGlass.pill`: a stadium, lighter tint, no shadow): the status
pill, the unread chip and `FloatingPill` (the action pills, 44 px
tall like the status pill without avatars). The mouse on any
floating element counts as the mouse on the controls: a
`ControlsHold` (`controls/controls_probe.dart`) held by
`ControlsHoldRegion`s (and by the open Up next popover) keeps either
design's controls up (`UoscControls.holdBy`; `ControlsHoverProbe.hold`,
which re-wakes media_kit's controls through their private hover
handler, the same guarded dynamic call as `keepVideoControlsAwake`),
and lets them hide on their usual countdown once released.
- **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right
(strip button or Tab): chat with avatars; the "Activity" chip (off by
default, `showActivity`, kept for the app's run only) adds the other
@@ -1164,7 +1243,7 @@ Session controls (the Standard player design): media_kit's
`screens/session/player_controls.dart`); its own double-click and keys are
off (its double-click pushes a fullscreen route with a second `Video`), the
picture's double-click is a raw pointer observer (`DoubleClickListener`).
The top bar holds the now-playing title, Change video, chat and back-to-library
The top bar holds the now-playing title, Up next, Change video, chat and back-to-library
(fullscreen: exit, then the window buttons); the bottom bar ends with the
shader button, ⚙ and fullscreen. Its seek bar is off; ours
(`ChapterSeekBar`: chapter diamonds, hover time with the chapter title)
@@ -1172,10 +1251,11 @@ sits in the bottom bar, which is taller by the bar's height (the top bar
keeps its row at the top). A drag sends no hover events, so the bar keeps
the controls up through a guarded dynamic `onHover()` on media_kit's
private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED /
1:30, Next episode) and cards live outside the auto-hiding controls. The
1:30, Next episode) and cards live outside the auto-hiding controls, in
the action area ("Player overlay"). The
cards stay up; a pill (`PillGate`) shows only while the controls are up
(`ControlsHoverProbe` for this design, `UoscControls.shown` for uosc) or
the mouse is on it, and Skip OP / Skip 1:30 also for 5 s when an offer of
the mouse is on it (which holds the controls up too), and Skip OP / Skip 1:30 also for 5 s when an offer of
a new kind appears (Skip ED and Next episode never without the controls).
Volume (`controls/volume.dart`, both
designs): mpv's `volume-max` is pinned to `PlaybackController.maxVolume`
@@ -1200,8 +1280,8 @@ leave),
subtitles and audio with track counts, a shader badge ("A HQ", opening the
shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30,
chapters, previous / next episode, change video, volume, ⚙, fullscreen; the
now-playing title top left (`UoscControls.topLeft`, with a top gradient), chat
and back-to-library (fullscreen: exit) top right. Menus (`showUoscMenu`: a popup route above the button, the
now-playing title top left (`UoscControls.topLeft`, with a top gradient), Up next, chat
and back-to-library (fullscreen: exit) top right, centred on the title. Menus (`showUoscMenu`: a popup route above the button, the
current row highlighted) list tracks and chapters with start times. All
actions are the coordinator's (seeks through `player.seek`, the user path;
`selectTrack`, `skipForward`, `seekTo`); keys and the floating pills are