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

+40 -2
View File
@@ -41,6 +41,10 @@ FakeJellyfin playbackFake() {
return const JellyfinHttpResponse(204, '');
});
}
fake.on('POST', '/UserPlayedItems/$episodeId', (_, _) {
reports.add((path: '/UserPlayedItems', body: const {}));
return const JellyfinHttpResponse(200, '{"Played":true}');
});
return fake;
}
@@ -261,11 +265,45 @@ void main() {
'ItemId': episodeId,
'MediaSourceId': episodeId,
'PlaySessionId': playSessionId,
'PositionTicks': 3005000000,
});
'PositionTicks': 0,
}, reason: '11 s watched is a peek: the resume point from before (none) stays');
expect(errors, isEmpty);
});
test('watched to the end: Stopped without a position, then marked played', () async {
reporter.start(const PlayerSnapshot(position: 0, paused: false));
for (var i = 1; i <= 1420; i++) {
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: i));
reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false));
}
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 2),
['Stopped', 'UserPlayedItems']);
expect(reports[reports.length - 2].body.containsKey('PositionTicks'), isFalse);
final nearEnd = [for (final r in reports) if (r.path.endsWith('Progress')) r.body]
.where((b) => b.containsKey('PositionTicks') && b['PositionTicks'] >= 1207 * jellyfinTicksPerSecond);
expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide');
});
test('played before: a peek never unmarks it', () async {
final r = JellyfinProgressReporter(
client: accounts.clientFor(fixtureServerId)!,
itemId: episodeId,
source: fixtureSource(),
playSessionId: playSessionId,
playedBefore: true,
clock: () => now,
);
r.start(const PlayerSnapshot(position: 700, paused: false));
now = now.add(const Duration(seconds: 1));
r.update(const PlayerSnapshot(position: 701, paused: true));
await r.stop(const PlayerSnapshot(position: 701, paused: true));
expect(reports.map((r) => r.path.split('/').last),
['Playing', 'UserPlayedItems', 'Progress', 'Stopped', 'UserPlayedItems']);
expect(reports[2].body.containsKey('PositionTicks'), isFalse);
expect(reports[3].body.containsKey('PositionTicks'), isFalse);
});
test('played to the end, then seeked back: a fresh Playing', () async {
reporter.start(const PlayerSnapshot(position: 0, paused: false));
await reporter.stop(const PlayerSnapshot(position: 1440, paused: true));