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:
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)';
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user