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,144 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/playback/media_marks.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/sync/state_diff.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
/// 23 min mkv with chapters OP 0:30-2:00, Part A, ED 20:00-21:30, Part B,
/// two audio tracks (jpn, eng "Dub") and an English srt subtitle.
Future<String> _media() async {
final path = '$_tmp/chapters_23m.mkv';
if (await File(path).exists()) return path;
final meta = File('$_tmp/chapters_meta.txt');
String chapter(int start, int end, String title) =>
'[CHAPTER]\nTIMEBASE=1/1000\nSTART=${start * 1000}\nEND=${end * 1000}\ntitle=$title\n';
await meta.writeAsString(';FFMETADATA1\n'
'${chapter(30, 120, 'OP')}${chapter(120, 1200, 'Part A')}'
'${chapter(1200, 1290, 'ED')}${chapter(1290, 1380, 'Part B')}');
final srt = File('$_tmp/chapters_subs.srt');
await srt.writeAsString('1\n00:00:01,000 --> 00:00:05,000\nHello\n');
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=160x120:rate=10',
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
'-f', 'lavfi', '-i', 'sine=frequency=660:sample_rate=48000',
'-i', srt.path, '-i', meta.path,
'-map', '0:v', '-map', '1:a', '-map', '2:a', '-map', '3:s',
'-map_metadata', '4', '-map_chapters', '4',
'-metadata:s:a:0', 'language=jpn',
'-metadata:s:a:1', 'language=eng', '-metadata:s:a:1', 'title=Dub',
'-metadata:s:s:0', 'language=eng',
'-t', '1380', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '10',
'-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '32k', '-c:s', 'srt', path,
]);
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
return path;
}
Future<void> _poll(
FutureOr<bool> Function() cond,
String what, {
Duration timeout = const Duration(seconds: 30),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(const Duration(milliseconds: 100));
}
fail('timed out waiting for $what');
}
void main() {
test(
'chapters and tracks from mpv; Skip OP and PgDn go through the server',
timeout: const Timeout(Duration(minutes: 5)),
() async {
MediaKit.ensureInitialized();
final media = await _media();
final server = SyncServer(AppConfig(port: 0, secret: 'ch', name: 'ch', mediaPath: media));
await server.start();
final a = SessionCoordinator();
final b = SessionCoordinator();
try {
for (final (c, name) in [(a, 'a'), (b, 'b')]) {
await c.connect(ConnectParams(
host: '127.0.0.1', port: server.boundPort, secret: 'ch', name: name));
await c.playback.player.setVolume(0);
}
await _poll(() => a.serverState.peek()?.members.length == 2, 'both in');
// chapters signal: titles, starts, ends (last = duration).
await _poll(() => a.playback.chapters.peek().length == 4 && a.playback.duration.peek() > Duration.zero,
'chapters');
final ch = a.playback.chapters.peek();
// ignore: avoid_print
print('chapters: $ch');
expect(ch.map((c) => c.title), ['OP', 'Part A', 'ED', 'Part B']);
expect(ch.map((c) => c.start.inSeconds), [30, 120, 1200, 1290]);
expect(ch[2].end, ch[3].start);
expect(ch[3].end.inSeconds, closeTo(1380, 1));
expect(a.playback.opening.peek(),
SkipRange(const Duration(seconds: 30), ch[1].start, SkipSource.chapter, title: 'OP'));
expect(a.playback.ending.peek()!.start, const Duration(minutes: 20));
expect(a.playback.segments.peek(), isEmpty, reason: 'not a Jellyfin item');
// tracks signal: two audio, one subtitle; mpv's choice marked.
await _poll(() => a.playback.tracks.peek().length == 3, 'tracks');
final tracks = a.playback.tracks.peek();
// ignore: avoid_print
print('tracks: $tracks');
final audio = tracks.where((t) => t.type == JellyfinStreamType.audio).toList();
expect(audio.map((t) => t.label), ['Japanese · AAC 1.0', 'English · AAC 1.0 · Dub']);
expect(audio.first.selected, isTrue);
await a.selectTrack(aid: 2);
expect(a.playback.tracks.peek().where((t) => t.selected && t.type == JellyfinStreamType.audio).single.id, 2);
a.toggleReady();
b.toggleReady();
await _poll(() => a.playback.player.state.playing && b.playback.player.state.playing, 'both playing');
// Into the OP: the pill offers Skip OP to its end.
a.seekTo(40);
await _poll(() {
final r = a.serverState.peek();
return r != null && r.seekBy == a.sync.memberId && (r.position - 40).abs() < 2;
}, 'server at 0:40');
await _poll(() => a.skipOffer.peek()?.kind == SkipKind.opening, 'Skip OP offered');
expect(a.skipOffer.peek()!.target, const Duration(minutes: 2));
int seeks(SessionCoordinator c) => c.feedLog.peek().where((f) => f.action == 'seek').length;
await _poll(() => seeks(b) >= 1 && seeks(b) == seeks(a), 'b saw the seek to 0:40');
final seeksBefore = seeks(b);
a.skip(a.skipOffer.peek()!);
await _poll(() {
final r = a.serverState.peek();
return r != null &&
r.seekBy == a.sync.memberId &&
serverPositionAt(r, a.sync.serverNow()) >= 120 &&
serverPositionAt(r, a.sync.serverNow()) < 125;
}, 'server at the OP end');
await _poll(() => b.playback.positionSecs >= 119.5 && b.playback.positionSecs < 126, 'b follows');
await _poll(() => seeks(b) == seeksBefore + 1, 'b saw the Skip OP seek');
await Future.delayed(const Duration(seconds: 1));
expect(seeks(b), seeksBefore + 1, reason: 'Skip OP is one seek');
await _poll(() => a.skipOffer.peek() == null, 'pill gone after the OP');
// PgDn: next chapter (ED at 20:00), for everyone.
expect(a.stepChapter(1), isTrue);
await _poll(() => b.playback.positionSecs >= 1199.5 && b.playback.positionSecs < 1206, 'b at the ED');
await _poll(() => a.skipOffer.peek()?.kind == SkipKind.ending, 'Skip ED offered');
expect(a.skipOffer.peek()!.target, const Duration(minutes: 21, seconds: 30));
} finally {
await a.dispose();
await b.dispose();
await server.stop();
}
},
);
}