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