Chapters on the scrubber, Skip OP/ED, audio/subtitle menus

- PlaybackController exposes chapters, segments (Jellyfin
  MediaSegments for account holders), tracks, position/duration.
- Own seek bar with chapter diamonds and chapter title on hover,
  seeking through the sync user-seek path.
- Skip OP / Skip ED pills from Intro/Outro segments or chapter names
  (ED skips to its end only); Skip 1:30 fallback for >20 min videos
  without OP info; next-episode pill now starts at the ED.
- PgUp/PgDn chapters, Shift+Right +1:30; Audio and Subtitles menus
  with count badges via the existing track path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 02:56:41 +03:00
1 parent ae4edc29e8
commit bc98c5c665
21 files changed
+2084 -257

No files matched your search

@@ -0,0 +1,27 @@
{
"Items": [
{
"Id": "5d1f0a7c2b3e4f60a1b2c3d4e5f60718",
"ItemId": "b18826be994070a79f9b52bc0f66f052",
"Type": "Outro",
"StartTicks": 12900000000,
"EndTicks": 13800000000
},
{
"Id": "0c9a8b7d6e5f40312a3b4c5d6e7f8091",
"ItemId": "b18826be994070a79f9b52bc0f66f052",
"Type": "Intro",
"StartTicks": 300000000,
"EndTicks": 1200000000
},
{
"Id": "1a2b3c4d5e6f47089a0b1c2d3e4f5061",
"ItemId": "b18826be994070a79f9b52bc0f66f052",
"Type": "Preview",
"StartTicks": 13800000000,
"EndTicks": 14100000000
}
],
"TotalRecordCount": 3,
"StartIndex": 0
}
@@ -272,6 +272,38 @@ void main() {
});
});
group('media segments', () {
// Shape from Jellyfin's MediaSegmentDto (the live 10.11 server answers
// 200 with an empty list: no segment provider installed).
test('parses and sorts the ranges by start', () async {
http.on('GET', '/MediaSegments/b18826be994070a79f9b52bc0f66f052', fixture: 'media_segments');
final s = await client.mediaSegments('b18826be994070a79f9b52bc0f66f052');
expect(http.last.method, 'GET');
expect(s.map((e) => e.type), [
JellyfinSegmentType.intro,
JellyfinSegmentType.outro,
JellyfinSegmentType.preview,
]);
expect((s.first.start, s.first.end), (const Duration(seconds: 30), const Duration(minutes: 2)));
expect(s[1].start, const Duration(minutes: 21, seconds: 30));
expect(s.first.itemId, 'b18826be994070a79f9b52bc0f66f052');
});
test('empty list, and none on servers without the endpoint', () async {
http.on('GET', '/MediaSegments/a', body: '{"Items":[],"TotalRecordCount":0,"StartIndex":0}');
expect(await client.mediaSegments('a'), isEmpty);
expect(await client.mediaSegments('missing'), isEmpty); // 404
http.on('GET', '/MediaSegments/x', status: 401);
await expectLater(client.mediaSegments('x'),
throwsA(isA<JellyfinException>().having((e) => e.isUnauthorized, 'unauthorized', true)));
});
test('unknown types stay unknown', () {
expect(JellyfinSegmentType.parse('Something'), JellyfinSegmentType.unknown);
expect(JellyfinSegmentType.parse(null), JellyfinSegmentType.unknown);
});
});
group('playback', () {
test('playbackInfo asks for direct play and parses the session', () async {
http.on('POST', '/Items/b18826be994070a79f9b52bc0f66f052/PlaybackInfo', fixture: 'playback_info');
@@ -113,6 +113,12 @@ void main() {
final src = pi.primarySource!;
expect(src.supportsDirectPlay, isTrue);
// 10.10+: the endpoint answers (often an empty list: segments need a
// provider plugin); ranges are well formed when present.
for (final seg in await client.mediaSegments(eps.first.id)) {
expect(seg.end, greaterThan(seg.start));
}
final http = HttpClient();
addTearDown(() => http.close(force: true));
final req = await http.getUrl(client.streamUrl(eps.first.id, mediaSourceId: src.id));