Restructure step 6: what's next and watch tracking
- What's next is the server's: state.advance counts down (10 s) when the media finishes with an Up next item first, holds while its performer or warm source is gone, and sets queuePlay at its end; cancel is shared, a seek or swap ends it; the server skips items nobody can put up. Shared rules in core whats_next.dart; the app's janitor, per-screen countdown and end-of-episode card are gone. - The continuation (next Jellyfin episode, next file of a local show with its gaps) is an auto item under "Continues with", prepared like Up next's first item. - Watch tracking by time In (D3): only time played while this viewer is in counts; away, paused, held, jumps and stalls never do (P7, P9). WatchTracker + device WatchHistory (files, links); Jellyfin progress reads the same watch; records are the AniList seam. - A server restart no longer loses what played: apps hold their media and one offers it back (swap.restore, first offer wins), Up next is re-added, the source re-feeds, and the watch carries on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a87ee98faa
commit
741b258ac1
55 files changed
+2724
-964
No files matched your search
@@ -362,6 +362,87 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
test('a restarted server comes back empty: what played is offered back where it was, nothing reopens, '
|
||||
'the watch goes on, Up next comes back', timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final first = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'restart'));
|
||||
await first.start();
|
||||
final port = first.boundPort;
|
||||
final origin = await _origin(await _media());
|
||||
SyncServer? second;
|
||||
try {
|
||||
final alice = await member(port, 'alice');
|
||||
final bob = await member(port, 'bob');
|
||||
await playTogether(alice, [alice, bob], 'http://127.0.0.1:${origin.port}/ep.mkv');
|
||||
expect(await bob.queue.add(LinkMedia('http://127.0.0.1:${origin.port}/next.mkv', anyway: true)), isNull);
|
||||
await _poll(() => alice.queue.items.peek().length == 1, 'Up next has an item');
|
||||
await Future.delayed(const Duration(seconds: 4));
|
||||
final opens = [alice.opens, bob.opens];
|
||||
final watch = alice.watch.session;
|
||||
final watched = watch!.timeIn;
|
||||
expect(watched, greaterThan(2));
|
||||
|
||||
await first.stop();
|
||||
await _poll(() => alice.reconnecting.peek() && bob.reconnecting.peek(), 'both reconnecting');
|
||||
second = SyncServer(AppConfig(port: port, secret: 'pw', name: 'restart'));
|
||||
await second.start();
|
||||
final engine = second.hub.engine;
|
||||
await _poll(() => engine.media?.name == 'ep.mkv', 'the media offered back', timeout: const Duration(seconds: 30));
|
||||
expect(engine.mediaId, 2, reason: 'one offer taken: the server started at 1');
|
||||
expect(engine.positionAt(), greaterThan(4), reason: 'where it was, not the start');
|
||||
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing && x.serverState.peek()?.transport == 'playing'),
|
||||
'playing again', timeout: const Duration(seconds: 20));
|
||||
expect([alice.opens, bob.opens], opens, reason: 'the players kept it: nothing reopened');
|
||||
await _poll(() => engine.queue.items.length == 1, 'Up next back');
|
||||
expect(engine.queue.items.single.media.url, endsWith('/next.mkv'));
|
||||
// The watch went on through the restart (one watch, still counting).
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
expect(identical(alice.watch.session, watch), isTrue);
|
||||
expect(alice.watch.mediaId, engine.mediaId);
|
||||
expect(watch.timeIn, greaterThan(watched + 1));
|
||||
} finally {
|
||||
await second?.stop();
|
||||
await origin.close(force: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('a restarted server comes back empty: the member passing its file on puts it back and feeds it again',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
final first = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'restart'));
|
||||
await first.start();
|
||||
final port = first.boundPort;
|
||||
final file = await _media();
|
||||
SyncServer? second;
|
||||
try {
|
||||
final alice = await member(port, 'alice');
|
||||
final bob = await member(port, 'bob');
|
||||
expect(await alice.swapPicked(FileMedia(file)), isNull);
|
||||
await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 100), 'both opened it',
|
||||
timeout: const Duration(seconds: 30));
|
||||
for (final x in [alice, bob]) {
|
||||
x.toggleReady();
|
||||
}
|
||||
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing), 'both playing');
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
final aliceOpens = alice.opens;
|
||||
|
||||
await first.stop();
|
||||
await _poll(() => alice.reconnecting.peek() && bob.reconnecting.peek(), 'both reconnecting');
|
||||
second = SyncServer(AppConfig(port: port, secret: 'pw', name: 'restart'));
|
||||
await second.start();
|
||||
final hub = second.hub;
|
||||
await _poll(() => hub.engine.media?.name == 'coord_presence_120s.mkv', 'the file offered back',
|
||||
timeout: const Duration(seconds: 30));
|
||||
expect(hub.engine.positionAt(), greaterThan(3));
|
||||
await _poll(() => hub.relay?.sourceToken == alice.connection.token && alice.relaying.peek(), 'alice feeds it again');
|
||||
await _poll(() => [alice, bob].every((x) => x.playback.player.state.playing && x.serverState.peek()?.transport == 'playing'),
|
||||
'playing again', timeout: const Duration(seconds: 30));
|
||||
expect(alice.opens, aliceOpens, reason: 'her own file stayed open');
|
||||
expect(hub.relay!.buffers.single.bytesToViewers, greaterThan(0), reason: 'bob reads the new relay');
|
||||
} finally {
|
||||
await second?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancel during connect setup aborts without joining', timeout: const Timeout(Duration(minutes: 1)), () async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel'));
|
||||
await server.start();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// restructure: rewrite in step 6 (Watch tracking): the progress-reporting
|
||||
// checks; the guest-through-the-relay flow stays.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
@@ -14,6 +12,7 @@ import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||
import 'package:starsync/src/playback/media_marks.dart';
|
||||
import 'package:starsync/src/screens/home/picked_media.dart';
|
||||
import 'package:starsync/src/session/session_coordinator.dart';
|
||||
import 'package:starsync/src/state/settings.dart';
|
||||
|
||||
// Two account holders and a guest watch Jellyfin episodes together over a
|
||||
// real sync server, real mpv and a local fake Jellyfin (never a real one):
|
||||
@@ -220,7 +219,8 @@ String _kind(_Call c) => c.path.split('/').last;
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'account holders direct-play and report, the guest plays through the relay, all in sync',
|
||||
'account holders direct-play and report what they watched, the guest plays through the relay, '
|
||||
'the next episode continues it for everyone',
|
||||
timeout: const Timeout(Duration(minutes: 4)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
@@ -296,6 +296,11 @@ void main() {
|
||||
expect((p - pos.first).abs(), lessThan(1.0), reason: 'in sync: $pos');
|
||||
}
|
||||
|
||||
// Carol sits out for the rest of the episode: she is not tracked as
|
||||
// having watched it (P7).
|
||||
c.sitOut();
|
||||
await _poll(() => c.selfAway.peek(), 'carol away');
|
||||
|
||||
// Pause (A unreadies): an immediate Progress with IsPaused.
|
||||
a.toggleReady();
|
||||
await _poll(() => !a.playback.player.state.playing, 'held');
|
||||
@@ -304,7 +309,7 @@ void main() {
|
||||
_kind(s) == 'Progress' && s.body?['IsPaused'] == true),
|
||||
'paused progress');
|
||||
a.toggleReady();
|
||||
await _poll(() => all.every((x) => x.playback.player.state.playing), 'playing again');
|
||||
await _poll(() => [a, b].every((x) => x.playback.player.state.playing), 'playing again');
|
||||
|
||||
// Account holders see the "Next episode" prompt (this item is
|
||||
// shorter than its 2 min lead); the guest cannot put it up.
|
||||
@@ -319,23 +324,28 @@ void main() {
|
||||
_kind(s) == 'Progress' && s.body?['AudioStreamIndex'] == 2),
|
||||
'track change reported');
|
||||
|
||||
// Near the end: finished -> Stopped, then one auto-advance swap.
|
||||
a.seekTo(16);
|
||||
// Alice put the episode up and has the next one: it continues it,
|
||||
// in Up next for everyone.
|
||||
await _poll(() => b.queue.continues.peek()?.media.jellyfin?.itemId == _ep2, 'the continuation');
|
||||
final auto = b.queue.continues.peek()!;
|
||||
expect(auto.by, a.connection.memberId);
|
||||
|
||||
// Played to the end: finished -> Stopped, the server's countdown
|
||||
// on every screen, then one swap.
|
||||
await _poll(() => a.serverState.peek()?.transport == 'finished', 'finished',
|
||||
timeout: const Duration(seconds: 20));
|
||||
timeout: const Duration(seconds: 40));
|
||||
final finishedId = a.serverState.peek()!.mediaId;
|
||||
await _poll(() => a.autoAdvance.peek() != null && c.autoAdvance.peek() != null,
|
||||
'both account holders count down');
|
||||
expect(b.autoAdvance.peek(), isNull);
|
||||
final swappers = [a.autoAdvance.peek()!.swapper, c.autoAdvance.peek()!.swapper];
|
||||
expect(swappers.where((s) => s), hasLength(1),
|
||||
reason: 'exactly one designated swapper: the lowest member id (alice, m1)');
|
||||
expect(a.autoAdvance.peek()!.swapper, isTrue);
|
||||
await _poll(() => [a, b].every((x) => (x.queue.countdown.peek()?.secondsLeft ?? 0) > 0),
|
||||
'everyone sees the countdown');
|
||||
expect(a.serverState.peek()!.advance!.id, auto.id);
|
||||
expect(a.queue.countdown.peek()!.item.id, auto.id);
|
||||
await _poll(() => (a.serverState.peek()?.mediaId ?? 0) > finishedId, 'advanced',
|
||||
timeout: const Duration(seconds: 20));
|
||||
timeout: const Duration(seconds: 25));
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
expect(a.serverState.peek()!.mediaId, finishedId + 1, reason: 'no double swap');
|
||||
expect(refOfStateMedia(a.serverState.peek()!.media)!.itemId, _ep2);
|
||||
expect(a.serverState.peek()!.queue, isEmpty, reason: 'the swap took the continuation');
|
||||
c.joinBack();
|
||||
await _poll(() => all.every((x) => x.playback.durationSecs > 19 && x.jellyfinPlan.peek() != null),
|
||||
'all opened the next episode');
|
||||
expect(b.jellyfinPlan.peek(), isA<RelayPlay>());
|
||||
@@ -383,13 +393,17 @@ void main() {
|
||||
expect(ep1.last, 'Stopped');
|
||||
expect(ep1.where((k) => k == 'Stopped'), hasLength(1));
|
||||
final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1);
|
||||
// Part of it watched, then a seek to the end: not watched (see
|
||||
// WatchSession). A resume point capped at 85 % of the 20 s, so
|
||||
// Jellyfin cannot mark it played on its own.
|
||||
expect(stopped1.body!['PositionTicks'], lessThan(18 * 10000000),
|
||||
reason: 'part way: resume point under 90 %, so not marked played');
|
||||
expect(jelly.calls.any((c) => c.method == 'POST' && c.path == '/UserPlayedItems/$_ep1'), isFalse,
|
||||
reason: 'seeking to the end is not watching');
|
||||
// Alice watched it through (time In, see WatchSession): Stopped
|
||||
// without a position, then marked played.
|
||||
expect(stopped1.body!.containsKey('PositionTicks'), isFalse, reason: 'watched: Jellyfin marks it played');
|
||||
bool markedPlayed(String token) => jelly.calls.any((c) =>
|
||||
c.method == 'POST' &&
|
||||
c.path == '/UserPlayedItems/$_ep1' &&
|
||||
_FakeJellyfin.authFields(c.auth)['Token'] == token);
|
||||
expect(markedPlayed('ATOKEN'), isTrue);
|
||||
expect(markedPlayed('CTOKEN'), isFalse, reason: 'carol sat out most of it (P7)');
|
||||
final carol1 = jelly.sessions(token: 'CTOKEN').lastWhere((s) => s.body?['ItemId'] == _ep1);
|
||||
expect(_kind(carol1), 'Stopped', reason: 'sitting out stops her reports');
|
||||
expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1');
|
||||
expect(ep2.first, 'Playing');
|
||||
expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped');
|
||||
@@ -442,7 +456,12 @@ void main() {
|
||||
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
|
||||
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
await accB.ensureLoaded(validate: false);
|
||||
final a = SessionCoordinator(jellyfin: accA, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||
// No continuation: its warm relay would be read while the test stops
|
||||
// (a cut relay response fails a test, see DEVELOPMENT "Traps").
|
||||
final a = SessionCoordinator(
|
||||
jellyfin: accA,
|
||||
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
|
||||
settings: AppSettings()..autoAdvance.value = false);
|
||||
final b = SessionCoordinator(jellyfin: accB, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||
try {
|
||||
await a.connect(ConnectParams(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// restructure: rewrite in step 6 (What's next + Watch tracking): the
|
||||
// "progress" and "episodes" groups (reporting, auto-advance swapper).
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
@@ -14,8 +12,9 @@ import 'package:starsync/src/jellyfin/session_media.dart';
|
||||
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||
import 'package:starsync/src/screens/home/media_picker.dart';
|
||||
import 'package:starsync/src/screens/session/jellyfin_widgets.dart';
|
||||
import 'package:starsync/src/queue/queue_controller.dart' show UpNextCountdown;
|
||||
import 'package:starsync/src/queue/queue_ui.dart' show UpNextCard;
|
||||
import 'package:starsync/src/session/delivery.dart';
|
||||
import 'package:starsync/src/session/session_coordinator.dart' show AutoAdvance;
|
||||
import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia;
|
||||
|
||||
import 'jellyfin_accounts_test.dart';
|
||||
@@ -212,9 +211,12 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
// The reporter reads the watch the session's tracker feeds (time In,
|
||||
// D3); here the test feeds it.
|
||||
group('progress', () {
|
||||
late JellyfinAccounts accounts;
|
||||
late DateTime now;
|
||||
late WatchSession watch;
|
||||
late JellyfinProgressReporter reporter;
|
||||
final errors = <Object>[];
|
||||
var unauthorized = 0;
|
||||
@@ -224,27 +226,35 @@ void main() {
|
||||
now = DateTime(2026, 1, 1, 20);
|
||||
errors.clear();
|
||||
unauthorized = 0;
|
||||
watch = WatchSession();
|
||||
reporter = JellyfinProgressReporter(
|
||||
client: accounts.clientFor(fixtureServerId)!,
|
||||
itemId: episodeId,
|
||||
source: fixtureSource(),
|
||||
playSessionId: playSessionId,
|
||||
watch: watch,
|
||||
clock: () => now,
|
||||
onError: errors.add,
|
||||
onUnauthorized: () => unauthorized++,
|
||||
);
|
||||
});
|
||||
|
||||
/// One second of the session at wall second [secs]: the watch gets its
|
||||
/// sample (In unless [away]), the reporter its snapshot.
|
||||
void tick(int secs, double pos, {bool paused = false, bool away = false, int? aid, int? sid}) {
|
||||
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs));
|
||||
watch.sample(pos, playing: !paused, present: !away, at: now);
|
||||
reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid));
|
||||
}
|
||||
|
||||
Future<void> idle() => reporter.stop(const PlayerSnapshot(position: 0, paused: true))
|
||||
.then((_) {});
|
||||
|
||||
test('Playing, Progress every 10 s and on pause / seek / track change, Stopped', () async {
|
||||
// English audio (mpv aid 2 = Jellyfin index 2), English full subs (sid 1 = 3).
|
||||
reporter.start(const PlayerSnapshot(position: 0, paused: true, aid: 2, sid: 1));
|
||||
void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) {
|
||||
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs));
|
||||
reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid));
|
||||
}
|
||||
void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) =>
|
||||
tick(secs, pos, paused: paused, aid: aid, sid: sid);
|
||||
|
||||
at(1, 0); // resume -> Progress
|
||||
at(2, 1);
|
||||
@@ -282,15 +292,14 @@ void main() {
|
||||
'MediaSourceId': episodeId,
|
||||
'PlaySessionId': playSessionId,
|
||||
'PositionTicks': 0,
|
||||
}, reason: '11 s watched is a peek: the resume point from before (none) stays');
|
||||
}, reason: '11 s In is a peek: the resume point from before (none) stays');
|
||||
expect(errors, isEmpty);
|
||||
});
|
||||
|
||||
test('watched to the end: Stopped without a position, then marked played', () async {
|
||||
reporter.start(const PlayerSnapshot(position: 0, paused: false));
|
||||
for (var i = 1; i <= 1420; i++) {
|
||||
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: i));
|
||||
reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false));
|
||||
for (var i = 0; i <= 1420; i++) {
|
||||
tick(i, i.toDouble());
|
||||
}
|
||||
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
|
||||
expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 3),
|
||||
@@ -302,6 +311,19 @@ void main() {
|
||||
expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide');
|
||||
});
|
||||
|
||||
test('sat out for most of it: a resume point, never played (P7)', () async {
|
||||
reporter.start(const PlayerSnapshot(position: 0, paused: false));
|
||||
for (var i = 0; i <= 1420; i++) {
|
||||
// In for five minutes, then away while the others watch on.
|
||||
tick(i, i.toDouble(), away: i > 300);
|
||||
}
|
||||
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
|
||||
expect(reports.map((r) => r.path.split('/').last), isNot(contains('UserPlayedItems')));
|
||||
final stopped = reports.lastWhere((r) => r.path.endsWith('Stopped')).body;
|
||||
expect(stopped['PositionTicks'], 300 * jellyfinTicksPerSecond,
|
||||
reason: 'it resumes where this viewer stopped watching');
|
||||
});
|
||||
|
||||
test('played before: a peek never unmarks it', () async {
|
||||
final r = JellyfinProgressReporter(
|
||||
client: accounts.clientFor(fixtureServerId)!,
|
||||
@@ -376,21 +398,6 @@ void main() {
|
||||
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
|
||||
});
|
||||
|
||||
test('one auto-advance swapper: the lowest id among members with their own account', () {
|
||||
final members = [
|
||||
member('m10', 'Ann', jf: {fixtureServerId: true}),
|
||||
member('m2', 'Bob', jf: {fixtureServerId: true}),
|
||||
member('m1', 'Guest', jf: {fixtureServerId: false}),
|
||||
member('m0', 'Old', jf: {fixtureServerId: true}, gone: true),
|
||||
member('m3', 'Fresh'),
|
||||
];
|
||||
expect(autoAdvanceSwapper(members, fixtureServerId), 'm2');
|
||||
expect(autoAdvanceSwapper(members.reversed.toList(), fixtureServerId), 'm2',
|
||||
reason: 'order does not matter: every client agrees');
|
||||
expect(autoAdvanceSwapper(members, 'other-server'), isNull);
|
||||
expect(compareMemberIds('m9', 'm10'), lessThan(0));
|
||||
});
|
||||
|
||||
test('member route: via server only for relay viewers of the current item', () {
|
||||
DeliveryRoute? route(MemberView m, {bool relay = true}) =>
|
||||
routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay);
|
||||
@@ -508,23 +515,36 @@ void main() {
|
||||
const JellyfinMedia(
|
||||
serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes'));
|
||||
|
||||
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async {
|
||||
var play = 0, cancel = 0;
|
||||
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard(
|
||||
next: next, countdown: null, onPlayNow: () => play++, onCancel: () => cancel++))));
|
||||
expect(find.text('Next: S1E7 Battle with the Heroes'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('auto-advance-cancel')), findsNothing);
|
||||
await tester.tap(find.byKey(const ValueKey('play-next')));
|
||||
expect(play, 1);
|
||||
testWidgets("end card: the server's countdown with Cancel, Play now; waiting for who puts it up",
|
||||
(tester) async {
|
||||
final queue = _graph.coordinator.queue;
|
||||
const item = QueueItem(
|
||||
id: 'q4',
|
||||
media: HelloMedia(name: 'ep7', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: fixtureServerId, itemId: nextEpisodeId)),
|
||||
how: QueueHow.jellyfin,
|
||||
title: 'Battle with the Heroes',
|
||||
sub: 'S1E7',
|
||||
by: 'm1',
|
||||
byName: 'Ann',
|
||||
auto: true,
|
||||
forMedia: 3);
|
||||
await tester.pumpWidget(scoped(_graph, app(UpNextCard(
|
||||
queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item, secondsLeft: 7)))));
|
||||
expect(find.text('Next in 7 s'), findsOneWidget);
|
||||
expect(find.text('Battle with the Heroes'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('up-next-cancel')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('up-next-play-now')), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard(
|
||||
next: next,
|
||||
countdown: AutoAdvance(mediaId: 3, secondsLeft: 7, next: next, swapper: false),
|
||||
onPlayNow: () => play++,
|
||||
onCancel: () => cancel++))));
|
||||
expect(find.text('Next episode in 7 s'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('auto-advance-cancel')));
|
||||
expect(cancel, 1);
|
||||
// Cancelled (for everyone): Play now only.
|
||||
await tester.pumpWidget(scoped(_graph, app(UpNextCard(
|
||||
queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item)))));
|
||||
expect(find.text('Next'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('up-next-cancel')), findsNothing);
|
||||
|
||||
// Ran out while the one who puts it up is gone: the card says so.
|
||||
await tester.pumpWidget(scoped(_graph, app(UpNextCard(
|
||||
queue: queue, countdown: const UpNextCountdown(mediaId: 3, item: item, secondsLeft: 0, waitingFor: 'Ann')))));
|
||||
expect(find.text('Waiting for Ann'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('next-episode pill: faint at rest, lit on hover, click puts it up', (tester) async {
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
// restructure: rewrite in step 6 (What's next): who performs an item and
|
||||
// the janitor become server state.
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:starsync/src/files/scanner.dart' show FolderScan;
|
||||
import 'package:starsync/src/library/library_store.dart';
|
||||
import 'package:starsync/src/library/local_next.dart';
|
||||
import 'package:starsync/src/library/show_matcher.dart';
|
||||
import 'package:starsync/src/queue/queue_logic.dart';
|
||||
|
||||
MemberView member(String id,
|
||||
{bool gone = false, bool away = false, Map<String, bool> jf = const {}, String name = ''}) =>
|
||||
MemberView(
|
||||
id: id,
|
||||
name: name.isEmpty ? id : name,
|
||||
ready: false,
|
||||
inSet: true,
|
||||
buffered: 0,
|
||||
presence: gone ? Presence.gone : away ? Presence.away : Presence.watching,
|
||||
jf: jf);
|
||||
import 'show_matcher_test.dart' show FakeAniList;
|
||||
|
||||
// What's next on the app: an item's words, and what continues a file of
|
||||
// a local show (who puts an item up and the countdown are core's and the
|
||||
// server's: core `whats_next_test`, `queue_test`).
|
||||
|
||||
QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem(
|
||||
id: 'q1',
|
||||
media: how == QueueHow.jellyfin
|
||||
? const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i'))
|
||||
: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
|
||||
media: const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'),
|
||||
how: how,
|
||||
title: 'x',
|
||||
by: by,
|
||||
@@ -27,49 +22,58 @@ QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => Queue
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('queuePerformer', () {
|
||||
test('a link passed on or a file: only its adder; gone waits for it, left means skipped', () {
|
||||
for (final how in [QueueHow.through, QueueHow.file]) {
|
||||
expect(queuePerformer(item(how), [member('m1'), member('m2', away: true)]).id, 'm2');
|
||||
final waits = queuePerformer(item(how), [member('m1'), member('m2', gone: true)]);
|
||||
expect((waits.id, waits.why), (null, null), reason: 'a gone adder may come back within its grace');
|
||||
final left = queuePerformer(item(how), [member('m1'), member('m3')]);
|
||||
expect(left.id, isNull);
|
||||
expect(left.why, 'Bob left');
|
||||
}
|
||||
});
|
||||
|
||||
test('Jellyfin: the adder with access, else the lowest member with access, else skipped', () {
|
||||
final access = {'S': true};
|
||||
expect(queuePerformer(item(QueueHow.jellyfin), [member('m1', jf: access), member('m2', jf: access)]).id, 'm2');
|
||||
expect(
|
||||
queuePerformer(item(QueueHow.jellyfin), [member('m10', jf: access), member('m3', jf: access), member('m2')])
|
||||
.id,
|
||||
'm3');
|
||||
expect(queuePerformer(item(QueueHow.jellyfin), [member('m3', jf: access, away: true), member('m2')]).why,
|
||||
isNotNull);
|
||||
});
|
||||
|
||||
test('a link everyone opens or the server fetches: the adder, else the lowest active member', () {
|
||||
for (final how in [QueueHow.direct, QueueHow.server]) {
|
||||
expect(queuePerformer(item(how), [member('m1'), member('m2')]).id, 'm2');
|
||||
expect(queuePerformer(item(how), [member('m4'), member('m2', gone: true), member('m3')]).id, 'm3');
|
||||
final nobody = queuePerformer(item(how), [member('m2', away: true)]);
|
||||
expect((nobody.id, nobody.why), (null, null), reason: 'nobody around is not a skip');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('the janitor is the lowest member that is not gone', () {
|
||||
expect(queueJanitor([member('m9'), member('m1', gone: true), member('m2')]), 'm2');
|
||||
});
|
||||
|
||||
test('labels', () {
|
||||
expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you');
|
||||
expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob');
|
||||
expect(queueHowLabel(item(QueueHow.file), selfId: 'm1'), "Bob's file");
|
||||
expect(queueHowLabel(item(QueueHow.file, byName: ''), selfId: 'm1'), "who added it's file");
|
||||
expect(queueClock(3725), '1:02:05');
|
||||
expect(queueClock(65), '1:05');
|
||||
expect(queueClock(0), '');
|
||||
});
|
||||
|
||||
group('what continues a file of a local show', () {
|
||||
const dir = '/lib/Bleach';
|
||||
String ep(int n) => '$dir/[Judas] Bleach - ${n.toString().padLeft(3, '0')}.mkv';
|
||||
// 064 and 065 are there, 066..107 are not.
|
||||
final files = [for (final n in [63, 64, 65, 108, 109]) LocalFile(ep(n), 1 << 30, DateTime(2026))];
|
||||
late List<String> scanned;
|
||||
late LocalNextFinder finder;
|
||||
late ShowMatcher matcher;
|
||||
|
||||
setUp(() {
|
||||
scanned = [];
|
||||
matcher = ShowMatcher(store: MemoryLibraryStore(), aniList: FakeAniList(), delay: (_) async {});
|
||||
finder = LocalNextFinder(
|
||||
matcher: matcher,
|
||||
scan: (d) async {
|
||||
scanned.add(d);
|
||||
return FolderScan(videos: files, hiddenCount: 0, hiddenExamples: const [], subtitles: const {}, truncated: false);
|
||||
});
|
||||
});
|
||||
|
||||
test('from its own folder when the library has not seen it this run', () async {
|
||||
final next = await finder.nextAfter(ep(64));
|
||||
expect(next!.episode.path, ep(65));
|
||||
expect(next.skipped, isNull);
|
||||
expect(scanned, [dir]);
|
||||
});
|
||||
|
||||
test('numbers skipped on the way are said', () async {
|
||||
final next = await finder.nextAfter(ep(65));
|
||||
expect(next!.episode.path, ep(108));
|
||||
expect(next.skipped!.label, '66–107');
|
||||
});
|
||||
|
||||
test('the last one, or not an episode: nothing continues it', () async {
|
||||
expect(await finder.nextAfter(ep(109)), isNull);
|
||||
expect(await finder.nextAfter('$dir/notes.mkv'), isNull);
|
||||
});
|
||||
|
||||
test('the library the Files page saw is used without a scan', () async {
|
||||
matcher.remember(groupLocal(files, roots: const ['/lib']));
|
||||
expect((await finder.nextAfter(ep(63)))!.episode.path, ep(64));
|
||||
expect(scanned, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import 'package:starsync/src/queue/queue_controller.dart' show WarmState;
|
||||
import 'package:starsync/src/screens/home/picked_media.dart';
|
||||
import 'package:starsync/src/session/session_coordinator.dart';
|
||||
import 'package:starsync/src/session/switch_trace.dart';
|
||||
import 'package:starsync/src/state/settings.dart';
|
||||
|
||||
// How long an Up next switch takes through the media pipeline, end to
|
||||
// end, over real mpv and a real sync server: two Jellyfin account holders
|
||||
@@ -244,8 +245,14 @@ void main() {
|
||||
final hosts = <SessionCoordinator, MediaKitHost>{};
|
||||
SessionCoordinator coord(JellyfinAccounts acc) {
|
||||
final host = MediaKitHost()..init();
|
||||
// Up next alone: no continuation of the episodes (it would be
|
||||
// queued after the items this test adds and times).
|
||||
final c = SessionCoordinator(
|
||||
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp, players: host)
|
||||
jellyfin: acc,
|
||||
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
|
||||
ytDlp: ytDlp,
|
||||
players: host,
|
||||
settings: AppSettings()..autoAdvance.value = false)
|
||||
..media.preloadEnabled = preload;
|
||||
hosts[c] = host;
|
||||
return c;
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
/// (`AppGraph`, widgets through `AppScope`); this list may only shrink.
|
||||
/// Lower a number when a use goes; never raise one or add a class.
|
||||
const baseline = {
|
||||
'AppSettings': 55,
|
||||
'AppSettings': 49,
|
||||
'JellyfinAccounts': 10,
|
||||
'UpdateController': 8,
|
||||
'Profile': 8,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:starsync/src/watch/watch_history.dart';
|
||||
import 'package:starsync/src/watch/watch_tracker.dart';
|
||||
|
||||
// Watch tracking (D3): one watch per media of the server, time In from the
|
||||
// presence the server reports, the device history for files and links,
|
||||
// every closed watch out on `records` (the seam for AniList later).
|
||||
|
||||
final _t0 = DateTime.utc(2026, 1, 1);
|
||||
|
||||
void main() {
|
||||
late WatchTracker tracker;
|
||||
late List<WatchRecord> records;
|
||||
|
||||
setUp(() {
|
||||
tracker = WatchTracker(history: WatchHistory(persist: false));
|
||||
records = [];
|
||||
tracker.records.listen(records.add);
|
||||
});
|
||||
|
||||
tearDown(() => tracker.dispose());
|
||||
|
||||
const episode = {'name': 'Bleach - 064.mkv', 'size': 100, 'duration': 1440, 'ref': {'kind': 'episode', 'title': 'Bleach', 'absolute': 64}};
|
||||
|
||||
/// [secs] seconds of media [mediaId] from wall second [t], playing,
|
||||
/// with self [present] or not.
|
||||
int run(int mediaId, Map<String, Object?> media, int t, int secs,
|
||||
{bool present = true, bool playing = true, String? ownFile, double from = 0}) {
|
||||
for (var i = 0; i <= secs; i++) {
|
||||
tracker.sample(WatchSample(
|
||||
mediaId: mediaId,
|
||||
media: media,
|
||||
ownFile: ownFile,
|
||||
position: from + i,
|
||||
playing: playing,
|
||||
present: present,
|
||||
duration: 1440,
|
||||
at: _t0.add(Duration(seconds: t + i)),
|
||||
));
|
||||
}
|
||||
return t + secs + 1;
|
||||
}
|
||||
|
||||
test('a file watched through goes into the history as watched; the next media closes the watch', () async {
|
||||
const path = '/lib/Bleach/Bleach - 064.mkv';
|
||||
final t = run(2, episode, 0, 1200, ownFile: path);
|
||||
run(3, const {'name': 'next', 'size': 1}, t, 1);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(records.first.key, WatchKey.file(path));
|
||||
expect(records.first.verdict.outcome, WatchOutcome.played);
|
||||
expect(records.first.ref?.absolute, 64);
|
||||
final e = tracker.history.entry(WatchKey.file(path))!;
|
||||
expect((e.played, e.resumeAt, e.title), (true, 0.0, 'Bleach - 064.mkv'));
|
||||
expect(tracker.mediaId, 3);
|
||||
});
|
||||
|
||||
test('sat out for most of it: not watched (P7), a resume point instead', () async {
|
||||
const url = 'https://example.com/v.mp4';
|
||||
final media = {'name': 'v', 'size': 0, 'duration': 1440, 'url': url};
|
||||
var t = run(2, media, 0, 300);
|
||||
t = run(2, media, t, 1100, present: false, from: 300);
|
||||
tracker.close(at: _t0.add(Duration(seconds: t)));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(records.single.verdict.outcome, WatchOutcome.partial);
|
||||
expect(records.single.timeIn, closeTo(300, 2));
|
||||
final e = tracker.history.entry(WatchKey.link(url))!;
|
||||
expect(e.played, isFalse);
|
||||
expect(e.resumeAt, closeTo(300, 1), reason: 'where this viewer was when it sat out');
|
||||
});
|
||||
|
||||
test('a peek changes nothing; a played item stays played', () async {
|
||||
const path = '/lib/x.mkv';
|
||||
run(2, episode, 0, 1200, ownFile: path);
|
||||
tracker.close();
|
||||
expect(tracker.history.entry(WatchKey.file(path))!.played, isTrue);
|
||||
run(4, episode, 0, 20, ownFile: path);
|
||||
expect(tracker.session!.playedBefore, isTrue, reason: 'the history says it was watched');
|
||||
tracker.close();
|
||||
expect(tracker.history.entry(WatchKey.file(path))!.played, isTrue);
|
||||
final other = WatchKey.of(const {'name': 'peeked', 'size': 5})!;
|
||||
run(5, const {'name': 'peeked', 'size': 5, 'duration': 1440}, 0, 20);
|
||||
tracker.close();
|
||||
expect(tracker.history.entry(other), isNull);
|
||||
});
|
||||
|
||||
test('Jellyfin: the watch is shared with its reports, not kept in the device history', () async {
|
||||
final media = {
|
||||
'name': 'ep',
|
||||
'size': 0,
|
||||
'duration': 1440,
|
||||
'jellyfin': {'serverId': 'S', 'itemId': 'i'},
|
||||
};
|
||||
run(2, media, 0, 10);
|
||||
tracker.before(played: true, resumeAt: 0);
|
||||
expect(tracker.verdict!.outcome, WatchOutcome.keepPlayed);
|
||||
tracker.close();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(records.single.key.isJellyfin, isTrue);
|
||||
expect(tracker.history.entries, isEmpty);
|
||||
});
|
||||
|
||||
test('keys: own file, Jellyfin, own or shared link, else name and size', () {
|
||||
expect(WatchKey.of(const {'name': 'a', 'size': 1}, ownFile: '/x.mkv'), WatchKey.file('/x.mkv'));
|
||||
expect(
|
||||
WatchKey.of(const {
|
||||
'name': 'a',
|
||||
'jellyfin': {'serverId': 'S', 'itemId': 'i'}
|
||||
}),
|
||||
WatchKey.jellyfin(const JellyfinRef(serverId: 'S', itemId: 'i')));
|
||||
expect(WatchKey.of(const {'name': 'a', 'relay': {}}, ownLink: 'https://x/y'), WatchKey.link('https://x/y'));
|
||||
expect(WatchKey.of(const {'name': 'a', 'url': 'https://x/z'}), WatchKey.link('https://x/z'));
|
||||
expect(WatchKey.of(const {'name': 'a', 'size': 3}), WatchKey.media('a', 3));
|
||||
expect(WatchKey.of(null), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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:starsync/src/files/scanner.dart' show scanFolder;
|
||||
import 'package:starsync/src/jellyfin/accounts.dart';
|
||||
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||
import 'package:starsync/src/library/library_store.dart';
|
||||
import 'package:starsync/src/library/local_next.dart';
|
||||
import 'package:starsync/src/library/show_matcher.dart';
|
||||
import 'package:starsync/src/screens/home/picked_media.dart';
|
||||
import 'package:starsync/src/session/session_coordinator.dart';
|
||||
import 'package:starsync/src/watch/watch_history.dart';
|
||||
import 'package:starsync/src/watch/watch_tracker.dart';
|
||||
|
||||
import 'show_matcher_test.dart' show FakeAniList;
|
||||
|
||||
// What's next with real coordinators, real mpv and a dedicated server: a
|
||||
// file of a local show put up by a member is continued by the show's next
|
||||
// episode (numbers skipped on the way said), the server's countdown runs
|
||||
// on every screen, the member who has the file puts it up, a cancel is
|
||||
// shared, and what was watched goes into the device's watch history.
|
||||
|
||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
final _show = '$_tmp/whats_next_e2e/Show';
|
||||
|
||||
/// `Show - NN.mp4`, 6 s each, with audio (headless mpv needs it) and the
|
||||
/// index first.
|
||||
Future<String> _episode(int n) async {
|
||||
final path = '$_show/Show - ${n.toString().padLeft(2, '0')}.mp4';
|
||||
if (await File(path).exists()) return path;
|
||||
await Directory(_show).create(recursive: true);
|
||||
final r = await Process.run('ffmpeg', [
|
||||
'-y', '-v', 'error',
|
||||
'-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24',
|
||||
'-f', 'lavfi', '-i', 'sine=frequency=${300 + 100 * n}:sample_rate=48000',
|
||||
'-t', '6', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24', '-pix_fmt', 'yuv420p',
|
||||
'-c:a', 'aac', '-movflags', '+faststart', 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: 200));
|
||||
}
|
||||
fail('timed out waiting for $what');
|
||||
}
|
||||
|
||||
void main() {
|
||||
late SyncServer server;
|
||||
late List<SessionCoordinator> all;
|
||||
late List<Object> serverErrors;
|
||||
|
||||
Future<SessionCoordinator> member(String name, {Future<LocalNext?> Function(String)? nextFile}) async {
|
||||
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
await acc.ensureLoaded(validate: false);
|
||||
final c = SessionCoordinator(
|
||||
jellyfin: acc,
|
||||
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
|
||||
watch: WatchTracker(history: WatchHistory(persist: false)))
|
||||
..nextFile = nextFile;
|
||||
all.add(c);
|
||||
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name));
|
||||
await c.playback.player.setVolume(0);
|
||||
return c;
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
MediaKit.ensureInitialized();
|
||||
all = [];
|
||||
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'vps'));
|
||||
// A relay read cut short at a swap or the stop is reported to the
|
||||
// zone the server started in (DEVELOPMENT "Traps").
|
||||
serverErrors = [];
|
||||
await runZonedGuarded(server.start, (e, _) {
|
||||
if ('$e'.contains('Content size below specified contentLength') || '$e'.contains('did not arrive')) return;
|
||||
serverErrors.add(e);
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
for (final x in all) {
|
||||
await x.dispose();
|
||||
}
|
||||
await server.stop();
|
||||
expect(serverErrors, isEmpty, reason: 'server errors');
|
||||
});
|
||||
|
||||
test('a file of a show continues with its next episode; the countdown and a cancel are shared',
|
||||
timeout: const Timeout(Duration(minutes: 3)), () async {
|
||||
final ep1 = await _episode(1);
|
||||
for (final n in [3, 4]) {
|
||||
await _episode(n);
|
||||
}
|
||||
final matcher = ShowMatcher(store: MemoryLibraryStore(), aniList: FakeAniList(), delay: (_) async {});
|
||||
final alice = await member('alice', nextFile: LocalNextFinder(matcher: matcher, scan: scanFolder).nextAfter);
|
||||
final bob = await member('bob');
|
||||
|
||||
expect(await alice.swapPicked(FileMedia(ep1)), isNull);
|
||||
await _poll(() => bob.queue.continues.peek() != null, 'the continuation, for everyone');
|
||||
final next = bob.queue.continues.peek()!;
|
||||
expect((next.how, next.by, next.auto), (QueueHow.file, alice.connection.memberId, true));
|
||||
expect(next.media.name, 'Show - 03.mp4');
|
||||
expect(next.sub, contains('skips 2'));
|
||||
expect(bob.queue.items.peek(), isEmpty, reason: 'it is not an Up next item of its own');
|
||||
|
||||
// Both ready: it plays to its end; the countdown is on both screens.
|
||||
await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 5), 'both opened episode 1');
|
||||
final first = alice.serverState.peek()!.mediaId;
|
||||
for (final x in [alice, bob]) {
|
||||
x.toggleReady();
|
||||
}
|
||||
await _poll(() => alice.serverState.peek()?.transport == 'finished', 'episode 1 ended');
|
||||
await _poll(() => [alice, bob].every((x) => (x.queue.countdown.peek()?.secondsLeft ?? 0) > 0), 'the countdown');
|
||||
expect(bob.queue.countdown.peek()!.item.id, next.id);
|
||||
await _poll(() => (bob.serverState.peek()?.mediaId ?? 0) > first, 'episode 3 up', timeout: const Duration(seconds: 20));
|
||||
expect(bob.serverState.peek()!.media!['name'], 'Show - 03.mp4');
|
||||
expect(bob.serverState.peek()!.media!['swapBy'], alice.connection.memberId, reason: 'who has the file put it up');
|
||||
|
||||
// Watched through: in the device history of both (alice's by its path,
|
||||
// bob's by its name and size), recorded as the next one came up.
|
||||
await _poll(() => alice.watch.history.entry(WatchKey.file(ep1))?.played ?? false, "alice's history");
|
||||
await _poll(() => bob.watch.history.entries.any((e) => e.$2.played), "bob's history");
|
||||
|
||||
// Episode 3 continues with 4; at its end bob cancels: for everyone.
|
||||
await _poll(() => alice.queue.continues.peek()?.media.name == 'Show - 04.mp4', 'episode 4 next');
|
||||
await _poll(() => [alice, bob].every((x) => x.playback.durationSecs > 5 && x.playback.player.state.playing),
|
||||
'both play episode 3', timeout: const Duration(seconds: 20));
|
||||
final third = alice.serverState.peek()!.mediaId;
|
||||
await _poll(() => (alice.queue.countdown.peek()?.secondsLeft ?? 0) > 0, 'the countdown again');
|
||||
bob.queue.cancelCountdown();
|
||||
await _poll(() => alice.queue.countdown.peek()?.secondsLeft == null && alice.queue.continues.peek() == null,
|
||||
'cancelled for alice too');
|
||||
await Future.delayed(const Duration(seconds: 12));
|
||||
expect(alice.serverState.peek()!.mediaId, third, reason: 'nothing came up');
|
||||
expect(alice.serverState.peek()!.transport, 'finished');
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user