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

@@ -499,6 +499,27 @@ class JellyfinClient {
Map<String, String> get authHeaders =>
{'Authorization': clientInfo.authorizationHeader(accessToken)};
/// `GET /MediaSegments/{itemId}` (Jellyfin 10.10+): the item's intro,
/// outro, recap, preview and commercial ranges, in start order. Empty when
/// no segment provider has marked it, and on servers without the
/// endpoint (404).
Future<List<JellyfinMediaSegment>> mediaSegments(String itemId) async {
final Object? j;
try {
j = await _json('GET', '/MediaSegments/$itemId');
} on JellyfinException catch (e) {
if (e.statusCode == 404) return const [];
rethrow;
}
final items = _map(j)['Items'];
final list = [
if (items is List)
for (final e in items)
if (e is Map) JellyfinMediaSegment.fromJson(e.cast<String, dynamic>())
]..sort((a, b) => a.startTicks.compareTo(b.startTicks));
return list;
}
// ------------------------------------------------------------ progress
/// `POST /Sessions/Playing`: playback started (Jellyfin shows the session
@@ -555,3 +555,54 @@ class JellyfinPlaybackInfo {
return mediaSources.isEmpty ? null : mediaSources.first;
}
}
/// Kind of a [JellyfinMediaSegment] (Jellyfin `MediaSegmentType`).
enum JellyfinSegmentType {
unknown,
commercial,
preview,
recap,
outro,
intro;
static JellyfinSegmentType parse(String? v) => switch (v) {
'Commercial' => commercial,
'Preview' => preview,
'Recap' => recap,
'Outro' => outro,
'Intro' => intro,
_ => unknown,
};
}
/// A marked range of an item (`GET /MediaSegments/{itemId}`, Jellyfin
/// 10.10+): intro, outro, recap, preview or commercial. Filled by segment
/// provider plugins; without one the list is empty.
class JellyfinMediaSegment {
final String id;
final String itemId;
final JellyfinSegmentType type;
final int startTicks;
final int endTicks;
const JellyfinMediaSegment({
required this.id,
required this.itemId,
required this.type,
required this.startTicks,
required this.endTicks,
});
Duration get start => ticksToDuration(startTicks);
Duration get end => ticksToDuration(endTicks);
factory JellyfinMediaSegment.fromJson(JellyfinJson j) => JellyfinMediaSegment(
id: _str(j['Id']) ?? '',
itemId: _str(j['ItemId']) ?? '',
type: JellyfinSegmentType.parse(_str(j['Type'])),
startTicks: _int(j['StartTicks']) ?? 0,
endTicks: _int(j['EndTicks']) ?? 0,
);
@override
String toString() => 'JellyfinMediaSegment(${type.name} $start-$end)';
}