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

+17
View File
@@ -9,6 +9,7 @@ import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/media_marks.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
// Two account holders and a guest watch Jellyfin episodes together over a
@@ -134,6 +135,15 @@ class _FakeJellyfin {
if (path == '/Shows/$_series/Episodes') {
return json({'Items': [_item(_ep1), _item(_ep2)], 'TotalRecordCount': 2});
}
if (path == '/MediaSegments/$_ep1') {
return json({
'Items': [
{'Id': 'seg1', 'ItemId': _ep1, 'Type': 'Intro', 'StartTicks': 20000000, 'EndTicks': 60000000},
],
'TotalRecordCount': 1,
'StartIndex': 0,
});
}
if (path.startsWith('/Sessions/Playing')) {
res.statusCode = 204;
return res.close();
@@ -260,6 +270,13 @@ void main() {
}, 'jf access in the member list');
expect(a.nextEpisode.peek()?.item.id, _ep2);
expect(b.nextEpisode.peek(), isNull, reason: 'guests cannot resolve the next one');
// MediaSegments with an account; the guest (relay) has none.
await _poll(() => a.playback.segments.peek().isNotEmpty, 'segments');
expect(a.playback.opening.peek()?.source, SkipSource.segment);
expect(a.playback.opening.peek()?.end, const Duration(seconds: 6));
expect(b.playback.segments.peek(), isEmpty);
expect(jelly.calls.where((c) => c.path.startsWith('/MediaSegments') && _FakeJellyfin.authFields(c.auth)['Token'] == 'CTOKEN'),
isNotEmpty);
for (final x in all) {
x.toggleReady();