Restructure step 6: what's next and watch tracking
ci / core (push) Successful in 29s
ci / server (push) Successful in 53s
ci / updater-windows (push) Failing after 7m45s

- What's next is the server's: state.advance counts down (10 s) when the
  media finishes with an Up next item first, holds while its performer or
  warm source is gone, and sets queuePlay at its end; cancel is shared, a
  seek or swap ends it; the server skips items nobody can put up. Shared
  rules in core whats_next.dart; the app's janitor, per-screen countdown
  and end-of-episode card are gone.
- The continuation (next Jellyfin episode, next file of a local show
  with its gaps) is an auto item under "Continues with", prepared like
  Up next's first item.
- Watch tracking by time In (D3): only time played while this viewer is
  in counts; away, paused, held, jumps and stalls never do (P7, P9).
  WatchTracker + device WatchHistory (files, links); Jellyfin progress
  reads the same watch; records are the AniList seam.
- A server restart no longer loses what played: apps hold their media
  and one offers it back (swap.restore, first offer wins), Up next is
  re-added, the source re-feeds, and the watch carries on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-16 02:06:04 +03:00
1 parent a87ee98faa
commit 741b258ac1
55 files changed
+2724 -964

No files matched your search

@@ -148,7 +148,7 @@ class MinimalControls extends StatelessWidget {
const Expanded(
child: Align(alignment: AlignmentDirectional.centerStart, child: NowPlayingTitle()),
),
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.minimal, hold: hold),
UpNextButton(queue: m.queue, look: UpNextLook.minimal, hold: hold),
const SizedBox(width: 4),
SignalBuilder(
builder: (context) {
@@ -81,13 +81,16 @@ class PlayerControlsModel {
/// Everything the layout depends on (not the position: that changes
/// four times a second and only the timeline needs it).
late final Computed<ControlsSnapshot> snapshot = computed(() {
final items = queue.items.value;
final queued = queue.items.value.firstOrNull;
final continues = queue.continues.value;
final next = coord.nextEpisode.value;
return ControlsSnapshot(
play: play.value,
previousLabel: coord.previousEpisode.value?.label,
nextLabel: items.isNotEmpty ? items.first.title : next?.label,
nextIsUpNext: items.isNotEmpty,
// Up next's first item, else the next episode, else what continues
// this (a file's next episode).
nextLabel: queued?.title ?? next?.label ?? continues?.title,
nextIsUpNext: queued != null,
hasChapters: chapters.value.isNotEmpty,
hasDuration: duration.value > Duration.zero,
alone: coord.alone.value,
@@ -137,9 +140,10 @@ class PlayerControlsModel {
void pickChapter(MediaChapter c) => coord.seekTo(c.start.inMicroseconds / 1e6);
/// Up next's first item, else the next episode. False when neither.
/// What plays next (Up next's first item, else the continuation), else
/// the next episode. False when neither.
bool next() {
if (queue.items.peek().isNotEmpty) {
if (queue.head.peek() != null) {
queue.skip();
return true;
}
@@ -165,7 +165,7 @@ class StandardControls extends StatelessWidget {
return Row(
children: [
const Expanded(child: NowPlayingTitle()),
UpNextButton(queue: m.queue, onPlayNextEpisode: m.playNextEpisode, look: UpNextLook.standard, hold: hold),
UpNextButton(queue: m.queue, look: UpNextLook.standard, hold: hold),
SignalBuilder(
builder: (context) {
final full = m.fullscreen.value;