forked from Foxyz/sync_replay_project
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:
1 parent
6b10cae4e6
commit
771b961b32
27 files changed
+1396
-558
No files matched your search
@@ -149,6 +149,9 @@ class _FakeJellyfin {
|
||||
res.statusCode = 204;
|
||||
return res.close();
|
||||
}
|
||||
if (path.startsWith('/UserPlayedItems/') || path.startsWith('/UserItems/')) {
|
||||
return json({'Played': path.startsWith('/UserPlayedItems/'), 'PlaybackPositionTicks': 0});
|
||||
}
|
||||
if (RegExp(r'^/Videos/\w+/stream$').hasMatch(path)) return _stream(req);
|
||||
res.statusCode = 404;
|
||||
await res.close();
|
||||
@@ -378,8 +381,13 @@ void main() {
|
||||
expect(ep1.last, 'Stopped');
|
||||
expect(ep1.where((k) => k == 'Stopped'), hasLength(1));
|
||||
final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1);
|
||||
expect(stopped1.body!['PositionTicks'], greaterThan(19 * jellyfinTicksPerSecond),
|
||||
reason: 'played to the end');
|
||||
// Part of it watched, then a seek to the end: not watched (see
|
||||
// WatchSession). A resume point capped at 85 % of the 20 s, so
|
||||
// Jellyfin cannot mark it played on its own.
|
||||
expect(stopped1.body!['PositionTicks'], lessThan(18 * 10000000),
|
||||
reason: 'part way: resume point under 90 %, so not marked played');
|
||||
expect(jelly.calls.any((c) => c.method == 'POST' && c.path == '/UserPlayedItems/$_ep1'), isFalse,
|
||||
reason: 'seeking to the end is not watching');
|
||||
expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1');
|
||||
expect(ep2.first, 'Playing');
|
||||
expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped');
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -54,16 +54,6 @@ void main() {
|
||||
expect(queueJanitor([member('m9'), member('m1', stale: true), member('m2')]), 'm2');
|
||||
});
|
||||
|
||||
test('warming starts in the last 90 s, once buffered to the end, or at the end', () {
|
||||
bool due(double pos, {double buffered = 0, String t = 'playing'}) =>
|
||||
queueWarmDue(transport: t, position: pos, duration: 1000, bufferedTo: pos + buffered);
|
||||
expect(due(100), isFalse);
|
||||
expect(due(911), isTrue);
|
||||
expect(due(100, buffered: 900), isTrue);
|
||||
expect(due(0, t: 'finished'), isTrue);
|
||||
expect(queueWarmDue(transport: 'playing', position: 5, duration: 0, bufferedTo: 5), isFalse);
|
||||
});
|
||||
|
||||
test('labels', () {
|
||||
expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you');
|
||||
expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob');
|
||||
|
||||
Reference in new issue
Block a user