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();
}
},
);
}
+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();
+197
View File
@@ -0,0 +1,197 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.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';
Duration s(num secs) => Duration(milliseconds: (secs * 1000).round());
Duration m(int min, [int sec = 0]) => Duration(minutes: min, seconds: sec);
/// OP 0:30-2:00, Part A, ED 20:00-21:30, Part B; 24:00 long.
final chapters = chaptersFromStarts([
('Prologue', Duration.zero),
('OP', m(0, 30)),
('Part A', m(2)),
('ED', m(20)),
('Part B', m(21, 30)),
], m(24));
void main() {
group('chapter-list', () {
test('parses mpv JSON, ends from the next start and the duration', () {
final c = parseChapterList(
'[{"title":"OP","time":30.0},{"title":"Part A","time":120.5},{"time":1200}]', m(24));
expect(c, [
MediaChapter(index: 0, title: 'OP', start: s(30), end: s(120.5)),
MediaChapter(index: 1, title: 'Part A', start: s(120.5), end: m(20)),
MediaChapter(index: 2, title: '', start: m(20), end: m(24)),
]);
});
test('unknown duration: the last chapter ends where it starts', () {
final c = parseChapterList('[{"title":"A","time":0},{"title":"B","time":10}]', Duration.zero);
expect(c.last.end, s(10));
});
test('garbage gives none', () {
expect(parseChapterList('', m(1)), isEmpty);
expect(parseChapterList('{"a":1}', m(1)), isEmpty);
expect(parseChapterList('[{"title":"x"}]', m(1)), isEmpty);
});
});
group('track-list', () {
test('audio and subtitles with flags and selection; video left out', () {
final t = parseTrackList('''[
{"id":1,"type":"video","codec":"h264","selected":true},
{"id":1,"type":"audio","lang":"jpn","codec":"eac3","demux-channel-count":2,"default":true,"selected":true},
{"id":2,"type":"audio","lang":"eng","title":"Dub","codec":"aac","demux-channel-count":6},
{"id":1,"type":"sub","lang":"eng","title":"Signs","codec":"ass","forced":true},
{"id":2,"type":"sub","codec":"subrip","external":true,"selected":true}
]''');
expect(t.length, 4);
expect(t[0], const MpvTrack(
id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2,
isDefault: true, selected: true));
expect(t[2].isForced, isTrue);
expect(t[3].isExternal && t[3].selected, isTrue);
expect(t.map((x) => x.label), [
'Japanese · E-AC3 2.0',
'English · AAC 5.1 · Dub',
'English · ASS · Signs · Forced',
'Track 2 · SRT',
]);
});
});
group('opening / ending', () {
test('from Jellyfin segments first', () {
final seg = [
MediaSegment(kind: JellyfinSegmentType.recap, start: Duration.zero, end: s(20)),
MediaSegment(kind: JellyfinSegmentType.intro, start: s(40), end: s(130)),
MediaSegment(kind: JellyfinSegmentType.outro, start: m(21), end: m(22, 30)),
];
expect(openingRange(seg, chapters), SkipRange(s(40), s(130), SkipSource.segment));
expect(endingRange(seg, chapters), SkipRange(m(21), m(22, 30), SkipSource.segment));
});
test('else from chapter titles', () {
expect(openingRange(const [], chapters), SkipRange(s(30), m(2), SkipSource.chapter, title: 'OP'));
expect(endingRange(const [], chapters), SkipRange(m(20), m(21, 30), SkipSource.chapter, title: 'ED'));
});
test('an empty segment range falls back to chapters', () {
final seg = [MediaSegment(kind: JellyfinSegmentType.intro, start: s(10), end: s(10))];
expect(openingRange(seg, chapters)!.source, SkipSource.chapter);
});
test('title variants: whole words, any case, numbered', () {
for (final t in ['OP', 'op', 'OP1', 'OP 2', 'Opening', 'Opening Song', 'Intro', 'INTRO']) {
expect(isOpeningTitle(t), isTrue, reason: t);
}
for (final t in ['Opportunity', 'Operation', 'Introduction', 'Part A', 'Prologue', 'Stop', '']) {
expect(isOpeningTitle(t), isFalse, reason: t);
}
for (final t in ['ED', 'ed', 'ED2', 'Ending', 'Ending Theme', 'Credits', 'End Credits', 'Outro']) {
expect(isEndingTitle(t), isTrue, reason: t);
}
for (final t in ['Edge', 'Edited', 'Endings?', 'Part B', 'Preview', 'Red']) {
expect(isEndingTitle(t), isFalse, reason: t);
}
});
test('none: no segments and no matching chapter titles', () {
final plain = chaptersFromStarts([('Part A', Duration.zero), ('Part B', m(10))], m(24));
expect(openingRange(const [], plain), isNull);
expect(endingRange(const [], plain), isNull);
expect(openingRange(const [], const []), isNull);
});
});
group('skip offers', () {
final op = SkipRange(s(30), m(2), SkipSource.chapter);
final ed = SkipRange(m(20), m(21, 30), SkipSource.chapter);
SkipOffer? at(Duration p, {SkipRange? opening, SkipRange? ending, Duration? duration}) => skipOfferAt(
position: p, duration: duration ?? m(24), opening: opening, ending: ending);
test('Skip OP inside the opening, to its end', () {
expect(at(s(29), opening: op, ending: ed), isNull);
expect(at(s(30), opening: op, ending: ed), SkipOffer(SkipKind.opening, m(2)));
expect(at(s(118), opening: op, ending: ed), SkipOffer(SkipKind.opening, m(2)));
// Last second: nothing left to skip.
expect(at(s(119.5), opening: op, ending: ed), isNull);
expect(at(m(5), opening: op, ending: ed), isNull);
});
test('Skip ED to the ending end only', () {
expect(at(m(20), opening: op, ending: ed), SkipOffer(SkipKind.ending, m(21, 30)));
expect(at(m(21), opening: op, ending: ed), SkipOffer(SkipKind.ending, m(21, 30)));
expect(at(m(21, 30), opening: op, ending: ed), isNull);
// Known ending without an opening: the fallback applies early on.
expect(at(m(20, 10), ending: ed), SkipOffer(SkipKind.ending, m(21, 30)));
});
test('1:30 fallback: no opening, over 20 min, first 6 min only', () {
expect(at(Duration.zero), SkipOffer(SkipKind.fixed, s(90)));
expect(at(m(1)), SkipOffer(SkipKind.fixed, m(2, 30)));
expect(at(m(5, 59)), SkipOffer(SkipKind.fixed, m(7, 29)));
expect(at(m(6)), isNull);
expect(at(m(1), duration: m(20)), isNull, reason: 'exactly 20 min is not over');
expect(at(m(1), duration: m(12)), isNull);
expect(at(m(1), ending: ed), SkipOffer(SkipKind.fixed, m(2, 30)));
// An opening known (but not playing) means no fallback.
expect(at(m(3), opening: op), isNull);
});
});
group('next episode prompt', () {
test('at the ED start when known, else the last two minutes', () {
expect(nextPromptStart(null, m(24), endingStart: m(20)), m(20));
expect(nextPromptStart(null, m(24)), m(22));
expect(nextPromptStart(null, m(24), endingStart: null), m(22));
// Nonsense ending starts fall back.
expect(nextPromptStart(null, m(24), endingStart: m(25)), m(22));
expect(nextPromptStart(null, m(24), endingStart: Duration.zero), m(22));
expect(nextPromptStart(null, s(90)), Duration.zero);
});
});
group('chapter steps (PgUp / PgDn)', () {
test('current chapter and forward steps', () {
expect(chapterIndexAt(chapters, s(45)), 1);
expect(chapterIndexAt(chapters, m(23)), 4);
expect(chapterStepTarget(chapters, s(45), 1), m(2));
expect(chapterStepTarget(chapters, m(21), 1), m(21, 30));
expect(chapterStepTarget(chapters, m(22), 1), isNull, reason: 'no chapter after the last');
});
test('back: current start, or the previous one near a start (mpv)', () {
expect(chapterStepTarget(chapters, m(10), -1), m(2));
expect(chapterStepTarget(chapters, m(2, 3), -1), s(30));
expect(chapterStepTarget(chapters, s(2), -1), Duration.zero);
expect(chapterStepTarget(const [], m(1), 1), isNull);
});
test('before the first chapter forward goes to it', () {
final late = chaptersFromStarts([('A', m(1)), ('B', m(5))], m(10));
expect(chapterIndexAt(late, s(10)), isNull);
expect(chapterStepTarget(late, s(10), 1), m(1));
expect(chapterStepTarget(late, s(10), -1), isNull);
});
});
test('clock format', () {
expect(formatClock(s(65)), '1:05');
expect(formatClock(const Duration(hours: 1, minutes: 2, seconds: 3)), '1:02:03');
});
test('menu labels: language names, codecs, layouts', () {
expect(languageName('jpn'), 'Japanese');
expect(languageName('xyz'), 'XYZ');
expect(languageName(null), isNull);
expect(codecName('hdmv_pgs_subtitle'), 'PGS');
expect(codecName('pcm_s16le'), 'PCM');
expect(channelLayoutName(8), '7.1');
expect(channelLayoutName(4), '4ch');
});
}
+198
View File
@@ -0,0 +1,198 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/media_marks.dart';
import 'package:watch_together/src/screens/session/player_controls.dart';
// The control-bar pieces alone (never the session screen or a `Video`).
Duration m(int min, [int sec = 0]) => Duration(minutes: min, seconds: sec);
final chapters = chaptersFromStarts([
('Prologue', Duration.zero),
('OP', m(0, 30)),
('Part A', m(2)),
('ED', m(20)),
('Part B', m(21, 30)),
], m(24));
Finder item<T>(String text) =>
find.ancestor(of: find.text(text), matching: find.byType(CheckedPopupMenuItem<T>));
Widget host(Widget child, {double width = 960}) => MaterialApp(
home: Scaffold(
backgroundColor: Colors.black,
body: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 40),
child: SizedBox(width: width, child: child),
),
),
),
);
void main() {
group('seek bar', () {
testWidgets('a mark at each chapter start but the first, placed by time', (tester) async {
await tester.pumpWidget(host(ChapterSeekBar(
position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
expect(find.byKey(const ValueKey('chapter-mark-0')), findsNothing, reason: 'starts at 0');
final bar = tester.getRect(find.byKey(const ValueKey('seek-bar')));
for (final c in chapters.skip(1)) {
final mark = tester.getCenter(find.byKey(ValueKey('chapter-mark-${c.index}')));
final x = bar.left + bar.width * c.start.inSeconds / m(24).inSeconds;
expect(mark.dx, moreOrLessEquals(x, epsilon: 0.5), reason: c.title);
expect(mark.dy, moreOrLessEquals(bar.center.dy, epsilon: 0.5));
}
});
testWidgets('no marks without a duration or chapters', (tester) async {
await tester.pumpWidget(host(ChapterSeekBar(
position: Duration.zero, duration: Duration.zero, chapters: chapters, onSeek: (_) {})));
expect(find.byKey(const ValueKey('chapter-mark-1')), findsNothing);
await tester.pumpWidget(host(ChapterSeekBar(position: Duration.zero, duration: m(24), onSeek: (_) {})));
expect(find.byKey(const ValueKey('chapter-mark-1')), findsNothing);
});
testWidgets('hover shows the chapter title above the time', (tester) async {
await tester.pumpWidget(host(ChapterSeekBar(
position: m(1), duration: m(24), chapters: chapters, onSeek: (_) {})));
final bar = tester.getRect(find.byKey(const ValueKey('seek-bar')));
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: Offset.zero);
addTearDown(mouse.removePointer);
// 20:45 of 24:00: inside "ED".
await mouse.moveTo(Offset(bar.left + bar.width * (20.75 / 24), bar.center.dy));
await tester.pump();
final title = find.byKey(const ValueKey('seek-hover-chapter'));
final time = find.byKey(const ValueKey('seek-hover-time'));
expect(tester.widget<Text>(title).data, 'ED');
expect(tester.widget<Text>(time).data, '20:45');
expect(tester.getCenter(title).dy, lessThan(tester.getCenter(time).dy));
expect(tester.getRect(time).bottom, lessThanOrEqualTo(bar.top + 2), reason: 'above the bar');
// 10:00: Part A.
await mouse.moveTo(Offset(bar.left + bar.width * (10 / 24), bar.center.dy));
await tester.pump();
expect(tester.widget<Text>(title).data, 'Part A');
await mouse.moveTo(Offset(bar.left, bar.top - 200));
await tester.pump();
expect(time, findsNothing);
});
testWidgets('no chapters: the hover shows only the time', (tester) async {
await tester.pumpWidget(host(ChapterSeekBar(position: m(1), duration: m(24), onSeek: (_) {})));
final bar = tester.getRect(find.byKey(const ValueKey('seek-bar')));
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: Offset.zero);
addTearDown(mouse.removePointer);
await mouse.moveTo(Offset(bar.left + bar.width / 2, bar.center.dy));
await tester.pump();
expect(find.byKey(const ValueKey('seek-hover-chapter')), findsNothing);
expect(tester.widget<Text>(find.byKey(const ValueKey('seek-hover-time'))).data, '12:00');
});
testWidgets('drag seeks on every move and on release', (tester) async {
final seeks = <Duration>[];
await tester.pumpWidget(host(ChapterSeekBar(
position: m(1), duration: m(24), chapters: chapters, onSeek: seeks.add)));
final bar = tester.getRect(find.byKey(const ValueKey('seek-bar')));
final g = await tester.startGesture(Offset(bar.left + bar.width / 4, bar.center.dy),
kind: PointerDeviceKind.mouse);
expect(seeks, isEmpty, reason: 'press alone does not seek');
await g.moveTo(Offset(bar.left + bar.width / 2, bar.center.dy));
await g.moveTo(Offset(bar.left + bar.width * 3 / 4, bar.center.dy));
await g.up();
expect(seeks.length, 3);
expect(seeks[0], m(12));
expect(seeks.last, m(18));
});
});
group('skip pill', () {
testWidgets('labels per kind; faint, lit on hover; click skips', (tester) async {
var pressed = 0;
for (final (kind, label) in [
(SkipKind.opening, 'Skip OP'),
(SkipKind.ending, 'Skip ED'),
(SkipKind.fixed, 'Skip 1:30'),
]) {
await tester.pumpWidget(host(Align(child: SkipPill(offer: SkipOffer(kind, m(2)), onPressed: () => pressed++))));
expect(find.text(label), findsOneWidget);
}
double opacity() => tester.widget<AnimatedOpacity>(
find.descendant(of: find.byType(SkipPill), matching: find.byType(AnimatedOpacity))).opacity;
expect(opacity(), lessThan(0.5));
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: Offset.zero);
addTearDown(mouse.removePointer);
await mouse.moveTo(tester.getCenter(find.byKey(const ValueKey('skip-pill'))));
await tester.pumpAndSettle();
expect(opacity(), 1);
await tester.tap(find.byKey(const ValueKey('skip-pill')));
expect(pressed, 1);
});
});
group('menus', () {
const tracks = [
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
MpvTrack(id: 2, type: JellyfinStreamType.audio, language: 'eng', codec: 'aac', channels: 6, title: 'Dub'),
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
];
testWidgets('audio menu: count badge, labels, current checked, pick', (tester) async {
TrackPick? got;
await tester.pumpWidget(host(Row(children: [
TrackMenuButton(type: JellyfinStreamType.audio, tracks: tracks, onPick: (p) => got = p),
TrackMenuButton(type: JellyfinStreamType.subtitle, tracks: tracks, onPick: (p) => got = p),
])));
expect(find.descendant(of: find.byKey(const ValueKey('audio-button')), matching: find.text('2')),
findsOneWidget);
expect(find.descendant(of: find.byKey(const ValueKey('subtitles-button')), matching: find.text('1')),
findsOneWidget);
await tester.tap(find.byKey(const ValueKey('audio-button')));
await tester.pumpAndSettle();
final items = tester.widgetList<CheckedPopupMenuItem<TrackPick>>(find.byType(CheckedPopupMenuItem<TrackPick>));
expect(items.map((i) => i.checked), [true, false]);
expect(find.text('Japanese · E-AC3 2.0'), findsOneWidget);
await tester.tap(item<TrackPick>('English · AAC 5.1 · Dub'));
await tester.pumpAndSettle();
expect(got, (aid: 2, sid: null, subtitlesOff: false));
});
testWidgets('subtitle menu has Off, checked when none is selected', (tester) async {
TrackPick? got;
await tester.pumpWidget(host(TrackMenuButton(
type: JellyfinStreamType.subtitle, tracks: tracks, onPick: (p) => got = p)));
await tester.tap(find.byKey(const ValueKey('subtitles-button')));
await tester.pumpAndSettle();
final items = tester.widgetList<CheckedPopupMenuItem<TrackPick>>(find.byType(CheckedPopupMenuItem<TrackPick>));
expect(items.map((i) => i.checked), [true, false]);
await tester.tap(item<TrackPick>('Off'));
await tester.pumpAndSettle();
expect(got, (aid: null, sid: null, subtitlesOff: true));
});
testWidgets('chapter menu: current checked, a pick seeks to its start', (tester) async {
MediaChapter? got;
await tester.pumpWidget(host(ChaptersControlButton(chapters: chapters, current: 2, onPick: (c) => got = c)));
await tester.tap(find.byKey(const ValueKey('chapters-button')));
await tester.pumpAndSettle();
final items = tester.widgetList<CheckedPopupMenuItem<MediaChapter>>(
find.byType(CheckedPopupMenuItem<MediaChapter>));
expect(items.map((i) => i.checked), [false, false, true, false, false]);
expect(find.text('20:00'), findsOneWidget);
await tester.tap(item<MediaChapter>('ED'));
await tester.pumpAndSettle();
expect(got!.start, m(20));
});
testWidgets('no chapters, no chapter button', (tester) async {
await tester.pumpWidget(host(ChaptersControlButton(chapters: const [], current: null, onPick: (_) {})));
expect(find.byKey(const ValueKey('chapters-button')), findsNothing);
});
});
}