Protocol 13: Up next preloads, so switching to it is instant

Every app gets the first Up next item ready itself (own Jellyfin plan,
direct links, guests via /j when state.relayNext says the server's
login can pass it on) and opens it in a hidden, muted, paused second
player; the swap promotes that player instead of opening cold, so its
buffer counts at once. Reports go out on open and when the buffer
reaches the gate's need. SwitchTrace times each phase. Measured in the
e2e harness: 1.5-3 s before, 35-103 ms with a preload. Relayed items
still open cold for everyone but their source (ROADMAP).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-11 16:15:16 +03:00
1 parent 4275abe470
commit ddd7a21195
19 files changed
+1041 -142

No files matched your search

@@ -3,7 +3,7 @@ import 'dart:ffi' show Int8;
import 'dart:io';
import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc;
import 'package:flutter/foundation.dart' show listEquals;
import 'package:flutter/foundation.dart' show listEquals, mapEquals;
import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
@@ -82,6 +82,7 @@ class PlaybackController {
final p = _player;
if (p == null) return;
try {
_ytdlPath = path;
final dynamic platform = p.platform;
if (!identical(p, _ytdlSetOn)) {
await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path');
@@ -101,6 +102,7 @@ class PlaybackController {
Player? _ytdlSetOn;
String _ytdlRaw = '';
String? _ytdlPath;
Player get player => _player!;
@@ -188,16 +190,18 @@ class PlaybackController {
return AppSettings.instance.hwdec.peek();
}
SyncPlayer _newPlayer({required int bufferSize, required MPVLogLevel logLevel}) => SyncPlayer(
configuration: PlayerConfiguration(
bufferSize: bufferSize,
libass: true,
vo: Platform.environment['SYNC_RELAY_VO'],
logLevel: logLevel,
))
..onUserSeek = (d) => onUserSeek?.call(d);
Future<void> init() async {
if (_player != null) return;
_player = SyncPlayer(
configuration: PlayerConfiguration(
bufferSize: maxBytes,
libass: true,
vo: Platform.environment['SYNC_RELAY_VO'],
logLevel: MPVLogLevel.warn, // see [logLevel]
))
..onUserSeek = (d) => onUserSeek?.call(d);
_player = _newPlayer(bufferSize: maxBytes, logLevel: MPVLogLevel.warn); // see [logLevel]
_shaders = '';
renderMode.value = RenderMode.unknown;
shaderNotice.value = null;
@@ -223,9 +227,9 @@ class PlaybackController {
}
}
Future<void> _pin(String property, String value) async {
Future<void> _pin(String property, String value, [Player? on]) async {
try {
final dynamic platform = player.platform;
final dynamic platform = (on ?? player).platform;
await platform?.setProperty(property, value);
final got = '${await platform?.getProperty(property)}';
if (!pinMatches(value, got)) {
@@ -238,17 +242,17 @@ class PlaybackController {
}
}
Future<void> _pinBufferProps() async {
pinMismatches.clear();
await _pin('demuxer-readahead-secs', '$readAheadSecs');
await _pin('cache-secs', '$readAheadSecs');
await _pin('demuxer-max-bytes', '$maxBytes');
await _pin('demuxer-max-back-bytes', '$maxBackBytes');
Future<void> _pinBufferProps([Player? on, int ahead = maxBytes, int back = maxBackBytes]) async {
if (on == null) pinMismatches.clear();
await _pin('demuxer-readahead-secs', '$readAheadSecs', on);
await _pin('cache-secs', '$readAheadSecs', on);
await _pin('demuxer-max-bytes', '$ahead', on);
await _pin('demuxer-max-back-bytes', '$back', on);
final hwdec = _hwdecOverride;
if (hwdec != null) {
await _pin('hwdec', hwdec);
await _pin('hwdec', hwdec, on);
}
await _pin('stream-lavf-o', streamLavfOptions);
await _pin('stream-lavf-o', streamLavfOptions, on);
}
/// ffmpeg's HTTP options (mpv `stream-lavf-o`, comma form): reconnect
@@ -371,10 +375,7 @@ class PlaybackController {
return;
}
// Nothing playable opened (a track of mpv's yt-dlp EDL refused, ...).
if (!_loadErrorSent &&
duration.peek() == Duration.zero &&
((log.prefix == 'cplayer' && log.level == 'fatal') ||
(log.prefix == 'demux' && log.level == 'error'))) {
if (!_loadErrorSent && _loadFailed(log, duration.peek())) {
_loadErrorSent = true;
final http = _httpError;
onLoadError?.call(http == null ? 'the video stream could not be opened' : 'the video server answered $http');
@@ -399,6 +400,15 @@ class PlaybackController {
}
}
/// [log] says the media could not be opened: before a [duration] is
/// known, mpv's fatal "nothing to play" or a demuxer error, or a yt-dlp
/// hook error.
static bool _loadFailed(PlayerLog log, Duration duration) {
if (log.prefix == 'ytdl_hook') return log.level == 'error' || log.level == 'fatal';
return duration == Duration.zero &&
((log.prefix == 'cplayer' && log.level == 'fatal') || (log.prefix == 'demux' && log.level == 'error'));
}
void _setRenderMode(RenderMode mode) {
renderMode.value = mode;
lastRenderMode.value = mode;
@@ -428,8 +438,14 @@ class PlaybackController {
/// Opens [url] paused. [headers] go with every request for it (mpv
/// `http-header-fields`): a Jellyfin token travels there, not in the URL.
Future<void> openUrl(String url, {Map<String, String>? headers}) async {
if (!_ready) return;
/// When [preload] holds the same [url] and [headers], that player
/// becomes this one instead (true: promoted, its buffer comes along).
Future<bool> openUrl(String url, {Map<String, String>? headers}) async {
if (!_ready) return false;
final pre = _pre;
if (pre != null && pre.matches(url, headers)) {
if (await _promote(pre)) return true;
}
try {
_chapterJson = '';
_httpError = null;
@@ -439,6 +455,7 @@ class PlaybackController {
buffered.value = Duration.zero;
// An empty map, not null: media_kit caches headers per URI.
await player.open(Media(url, httpHeaders: headers ?? const {}), play: false);
return false;
} catch (e, st) {
debugPrint('open ${Uri.tryParse(url)?.replace(query: '') ?? 'media'} failed: $e\n$st');
rethrow;
@@ -607,6 +624,7 @@ class PlaybackController {
/// Release the player. The view drops its texture first ([alive] goes
/// false, one frame passes), then mpv is torn down.
Future<void> dispose() async {
await dropPreload();
final p = _player;
if (p == null) return;
await _logSub?.cancel();
@@ -639,6 +657,173 @@ class PlaybackController {
} catch (_) {}
}
// ------------------------------------------------------------ preload
//
// A second, hidden player holding what plays next (Up next's first
// item), open paused at its start and muted, with a small cache: its
// first seconds are in before anyone asks. [openUrl] for the same URL
// promotes it to the session player (see ARCHITECTURE.md "Getting
// ready"). One at a time.
/// Preloading on (env `SYNC_RELAY_PRELOAD=0` turns it off; tests set it).
bool preloadEnabled = Platform.environment['SYNC_RELAY_PRELOAD'] != '0';
/// The preload's forward cache: its first seconds and the container's
/// index, at little memory. Raised to [maxBytes] when promoted.
static const preloadBytes = 48 * 1024 * 1024;
static const _preloadBackBytes = 8 * 1024 * 1024;
_Preload? _pre;
/// The URL preloaded now (null: none).
String? get preloadUrl => _pre?.url;
/// Opens [url] in the hidden preload player (replacing any other), and
/// completes once [need] seconds are buffered or the whole media is:
/// true, false when it failed (or was dropped or replaced meanwhile).
/// The same [url] and [headers] again is the same preload unless
/// [fresh]. [ytdlPath]: mpv's yt-dlp hook for web pages.
Future<bool> preload(String url,
{Map<String, String>? headers, String? ytdlPath, double need = 4, bool fresh = false}) async {
if (!preloadEnabled || !_ready) return false;
final had = _pre;
if (had != null && had.matches(url, headers) && !had.failed && !fresh) return had.ready.future;
// Set before any await: a second call for the same URL finds it.
_pre = null;
if (had != null) unawaited(_disposePreload(had));
final p = _newPlayer(bufferSize: preloadBytes, logLevel: MPVLogLevel.error);
final pre = _pre = _Preload(p, url, headers ?? const {});
pre.logSub = p.stream.log.listen((log) {
// ffmpeg logs through one mpv per process: the first one, and once
// that is gone (a promotion disposes it) the next one created, which
// can be this preload. Its HTTP errors still name the session's.
if (log.prefix == 'ffmpeg') return _onLog(log);
if (_loadFailed(log, p.state.duration)) pre.fail();
});
try {
await _pinBufferProps(p, preloadBytes, _preloadBackBytes);
await _pin('volume-max', '$maxVolume', p);
await _pin('mute', 'yes', p);
final path = ytdlPath ?? _ytdlPath;
if (path != null) {
final dynamic platform = p.platform;
await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path');
await platform?.setProperty('ytdl-format', ytdlFormat);
pre.ytdlRaw = await AppYtDlp.instance.mpvRawOptions();
await platform?.setProperty('ytdl-raw-options', pre.ytdlRaw);
pre.ytdl = true;
}
// With a view up, its own video output: mpv decodes (media_kit
// starts players with vid=no) and the first frame is there.
if (_video != null) {
pre.video = VideoController(p, configuration: VideoControllerConfiguration(hwdec: _hwdecOverride));
await pre.video!.platform.future;
}
if (!identical(_pre, pre)) {
pre.fail();
return false;
}
await p.open(Media(url, httpHeaders: headers ?? const {}), play: false);
if (!pre.opened.isCompleted) pre.opened.complete();
} catch (e) {
debugPrint('preload failed: $e');
pre.fail();
return false;
}
unawaited(_watchPreload(pre, need));
return pre.ready.future;
}
Future<void> _watchPreload(_Preload pre, double need) async {
final end = DateTime.now().add(const Duration(seconds: 60));
while (identical(_pre, pre) && !pre.failed && DateTime.now().isBefore(end)) {
final st = pre.player.state;
final secs = st.duration.inMilliseconds / 1000;
final buffered = (st.buffer - st.position).inMilliseconds / 1000;
if (secs > 0 && (buffered >= need || st.buffer.inMilliseconds / 1000 >= secs - 0.5)) {
if (!pre.ready.isCompleted) pre.ready.complete(true);
return;
}
await Future.delayed(const Duration(milliseconds: 200));
}
if (!pre.ready.isCompleted) pre.ready.complete(false);
}
/// Drops the preload (nothing to get ready, or something else is).
Future<void> dropPreload() async {
final pre = _pre;
if (pre == null) return;
_pre = null;
await _disposePreload(pre);
}
Future<void> _disposePreload(_Preload pre) async {
pre.fail();
await pre.logSub?.cancel();
try {
await pre.player.dispose();
} catch (_) {}
}
/// [pre] becomes the session player: the view swaps to its video
/// output ([generation]), volume, shaders, log level and the full cache
/// carry over, and the old player goes once the view let go of it.
Future<bool> _promote(_Preload pre) async {
await pre.opened.future;
final old = _player;
if (!identical(_pre, pre) || old == null) return false;
_pre = null;
if (pre.failed) {
unawaited(_disposePreload(pre));
return false;
}
await pre.logSub?.cancel();
if (!pre.ready.isCompleted) pre.ready.complete(true);
final p = pre.player;
final volume = old.state.volume;
await _logSub?.cancel();
for (final sub in _timeSubs) {
await sub.cancel();
}
_timeSubs.clear();
_player = p;
_video = pre.video;
_ytdlSetOn = pre.ytdl ? p : null;
_ytdlRaw = pre.ytdlRaw;
_chapterJson = '';
_httpError = null;
_loadErrorSent = false;
_shaders = '';
final at = p.state.position.inMilliseconds;
position.value = Duration(milliseconds: at - at % 250);
duration.value = p.state.duration;
final ms = p.state.buffer.inMilliseconds;
buffered.value = Duration(milliseconds: ms - ms % 250);
chapters.value = const [];
tracks.value = const [];
_logSub = p.stream.log.listen(_onLog);
_requestLog(p, logLevel);
_watchMarks(p);
generation.value++;
try {
await p.setVolume(volume);
await _pin('mute', 'no', p);
await _pinBufferProps(p);
} catch (e) {
debugPrint('promoted player settings: $e');
}
unawaited(applyShaders());
unawaited(_refreshTracks());
// The view lets go of the old texture first (as in [dispose]).
unawaited(() async {
await _settleUi();
try {
await old.dispose();
} catch (_) {}
}());
return true;
}
Future<void> stop() async {
if (!_ready) return;
try {
@@ -671,3 +856,27 @@ bool pinMatches(String wanted, String got) {
final a = double.tryParse(wanted), b = double.tryParse(got);
return a != null && b != null && a == b;
}
class _Preload {
final SyncPlayer player;
final String url;
final Map<String, String> headers;
VideoController? video;
StreamSubscription<PlayerLog>? logSub;
/// Completes once the media is handed to mpv (or the preload failed).
final opened = Completer<void>();
bool ytdl = false;
String ytdlRaw = '';
bool failed = false;
final ready = Completer<bool>();
_Preload(this.player, this.url, this.headers);
bool matches(String url, Map<String, String>? headers) =>
url == this.url && mapEquals(headers ?? const {}, this.headers);
void fail() {
failed = true;
if (!ready.isCompleted) ready.complete(false);
if (!opened.isCompleted) opened.complete();
}
}
+161 -46
View File
@@ -34,7 +34,7 @@ class UpNextCountdown {
const UpNextCountdown({required this.mediaId, required this.item, this.secondsLeft});
}
/// The first item's readiness, on the app that puts it up.
/// The first item's readiness on this app.
enum WarmState { idle, warming, ready, failed }
class QueueController {
@@ -51,8 +51,8 @@ class QueueController {
final countdown = signal<UpNextCountdown?>(null);
/// The first item's id and how ready it is here (only on the app that
/// puts it up; null elsewhere).
/// The first item's id and how ready it is here (null: nothing to get
/// ready on this app).
final warm = signal<({String id, WarmState state})?>(null);
/// The natural continuation of what plays: the next episodes of the
@@ -88,9 +88,12 @@ class QueueController {
final plan = coord.jellyfinPlan.value;
unawaited(_loadContinuation(next, plan));
});
// The continuation and the state decide what to get ready.
// The continuation and the state decide what to get ready; the media
// playing having started (or sitting out) lets the preload go.
effect(() {
shownContinuation.value;
coord.settledMediaId.value;
coord.selfOut.value;
final r = coord.serverState.value;
untracked(() => _warm(r));
});
@@ -326,6 +329,7 @@ class QueueController {
// --------------------------------------------------------- per state
void _onState(StateBroadcast r) {
_lastState = r;
_prune(r);
final queue = r.queue;
final play = r.queuePlay;
@@ -490,58 +494,98 @@ class QueueController {
String? _warmUrl;
Timer? _warmTimer;
int _warmGen = 0;
// What the preload player should hold once the media playing has
// started (null: nothing); [_preloadFresh]: open it again (a link's
// refresh).
({String url, Map<String, String>? headers})? _preloadWant;
bool _preloadFresh = false;
String _preloadKey = '';
bool _preloadBusy = false;
bool _preloadOk = true;
int _preloadGen = 0;
StateBroadcast? _lastState;
/// What this app gets ready: Up next's first item when it is the one
/// that puts it up; with Up next empty, the first continuation episode
/// (only here when this app plays the series with its own account, and
/// its ▶ puts it up from here). Null: nothing.
({String id, String? url, JellyfinRef? ref})? _warmTarget(StateBroadcast? r) {
bool _ownAccount(String serverId) =>
coord.jellyfin.account(serverId) != null && coord.jellyfin.statusOf(serverId) != JellyfinAccountStatus.expired;
/// What this app gets ready: Up next's first item, else (Up next empty)
/// the first continuation episode (only here when this app plays the
/// series with its own account). Every app does, each its own copy (see
/// [_WarmTarget]); while sitting out only the probe of a link this app
/// puts up (its swap reads it). Null: nothing.
_WarmTarget? _warmTarget(StateBroadcast? r) {
final t = _warmTargetHere(r);
if (t == null || !coord.selfOut.peek()) return t;
return t.probe == null ? null : _WarmTarget(t.id, probe: t.probe);
}
_WarmTarget? _warmTargetHere(StateBroadcast? r) {
if (r == null) return null;
final head = r.queue.firstOrNull;
if (head != null) {
if (queuePerformer(head, r.members).id != _me) return null;
final performer = queuePerformer(head, r.members).id == _me;
final local = _local[head.key]?.pick;
return switch (head.how) {
QueueHow.direct => (id: head.id, url: head.media.url, ref: null),
QueueHow.through => (id: head.id, url: local is LinkMedia ? local.url : null, ref: null),
QueueHow.jellyfin => (id: head.id, url: null, ref: head.media.jellyfin),
QueueHow.file || QueueHow.server => (id: head.id, url: null, ref: null),
};
switch (head.how) {
case QueueHow.jellyfin:
final ref = head.media.jellyfin;
if (ref == null) return null;
if (_ownAccount(ref.serverId)) return _WarmTarget(head.id, ref: ref);
// No account here: the server's login passes it on (`/j`).
final relay = r.relayNext ? coord.jellyfinRelayUrl(ref) : null;
return relay == null ? null : _WarmTarget(head.id, open: relay);
case QueueHow.direct:
final url = head.media.url;
if (url == null) return null;
return _WarmTarget(head.id, probe: performer ? url : null, open: url);
case QueueHow.through:
// Only its adder has the link; the others' relay starts with the swap.
if (!performer) return null;
final url = local is LinkMedia ? local.url : null;
return _WarmTarget(head.id, probe: url, open: url);
case QueueHow.file:
if (!performer) return null;
// The host's own file plays from its `/m` URL once it is up.
return _WarmTarget(head.id, open: local is FileMedia && coord.hostFile == null ? local.path : null);
case QueueHow.server:
return performer ? _WarmTarget(head.id) : null;
}
}
final next = shownContinuation.peek().firstOrNull;
if (next == null) return null;
return (id: 'next:${next.item.id}', url: null, ref: mediaForPick(next.pick).jellyfin);
return _WarmTarget('next:${next.item.id}', ref: mediaForPick(next.pick).jellyfin);
}
/// Keeps the one item this app puts up next ready, so a skip or the end
/// of what plays starts it at once: a link probed (`AppYtDlp.probe`,
/// kept while it stays first), a Jellyfin item resolved with this app's
/// account ([SessionCoordinator.warmJellyfin]). Done as soon as it is
/// first here, again before that runs out, dropped when it is no longer
/// first. A file or a link the server fetches has nothing to get ready.
/// Keeps the one item that plays next ready on this app, so a skip or
/// the end of what plays starts it at once: a link probed when this app
/// puts it up (`AppYtDlp.probe`, kept while it stays first), a Jellyfin
/// item resolved with this app's account
/// ([SessionCoordinator.warmJellyfin]), and then opened in the hidden
/// preload player ([SessionCoordinator.preloadMedia]) once the media
/// playing has started. Done as soon as it is first, again before that
/// runs out, dropped when it is no longer first.
void _warm(StateBroadcast? r) {
final t = _warmTarget(r);
final ref = t?.ref;
final key = t == null ? '' : '${t.id}|${t.url}|${ref?.serverId}/${ref?.itemId}/${ref?.mediaSourceId}';
if (key == _warmKey) return;
_dropWarm(r);
_warmKey = key;
if (t == null) {
if (warm.peek() != null) warm.value = null;
return;
final key = t?.key ?? '';
if (key != _warmKey) {
_dropWarm(r);
_warmKey = key;
if (t == null) {
if (warm.peek() != null) warm.value = null;
} else if (t.empty) {
warm.value = (id: t.id, state: WarmState.ready);
} else {
warm.value = (id: t.id, state: WarmState.warming);
_resolveWarm(t, fresh: false);
}
}
if (t.url == null && ref == null) {
warm.value = (id: t.id, state: WarmState.ready);
return;
}
warm.value = (id: t.id, state: WarmState.warming);
_resolveWarm(t, fresh: false);
_syncPreload(r);
}
void _dropWarm(StateBroadcast? r) {
_warmTimer?.cancel();
_warmTimer = null;
_warmGen++;
_preloadWant = null;
final url = _warmUrl;
_warmUrl = null;
if (url != null) coord.ytDlp.release(url);
@@ -550,33 +594,89 @@ class QueueController {
if (w != null && !sameJellyfinItem(w.ref, refOfStateMedia(r?.media))) coord.warmJellyfin = null;
}
void _resolveWarm(({String id, String? url, JellyfinRef? ref}) t, {required bool fresh}) {
void _resolveWarm(_WarmTarget t, {required bool fresh}) {
final gen = ++_warmGen;
unawaited(() async {
var ok = false;
var ok = true;
({String url, Map<String, String>? headers})? open;
try {
final url = t.url, ref = t.ref;
if (url != null) {
_warmUrl = url;
final p = await coord.ytDlp.probe(url, keep: _warmKeep, fresh: fresh).timeout(const Duration(seconds: 60));
final probe = t.probe, ref = t.ref, url = t.open;
if (probe != null) {
_warmUrl = probe;
final p = await coord.ytDlp.probe(probe, keep: _warmKeep, fresh: fresh).timeout(const Duration(seconds: 60));
ok = p.problem == null;
} else if (ref != null) {
}
if (ref != null) {
final plan = await resolveJellyfinRef(coord.jellyfin, ref, relay: false, relayUrl: null);
if (gen != _warmGen) return;
ok = plan is OwnPlay;
if (plan is OwnPlay) {
coord.warmJellyfin = (ref: ref, plan: plan, until: DateTime.now().add(_warmKeep));
ok = true;
open = (url: plan.url, headers: plan.headers);
}
} else if (url != null && ok) {
open = (url: url, headers: null);
}
} catch (e) {
ok = false;
debugPrint('queue: getting ${t.id} ready: $e');
}
if (gen != _warmGen) return;
warm.value = (id: t.id, state: ok ? WarmState.ready : WarmState.failed);
_preloadWant = ok ? open : null;
// A refresh opens a link again (a page's stream URLs expire); the
// same Jellyfin stream stays as it is.
_preloadFresh = fresh && ok && t.ref == null && open != null;
final waits = ok && open != null && coord.playback.preloadEnabled;
warm.value = (id: t.id, state: !ok ? WarmState.failed : (waits ? WarmState.warming : WarmState.ready));
_syncPreload(coord.serverState.peek());
_warmTimer = Timer(ok ? _warmRefresh : _warmRetry, () => _resolveWarm(t, fresh: true));
}());
}
/// Puts [_preloadWant] in the preload player, or drops what it holds;
/// only once the media playing has started (its own open goes first,
/// and a preload a swap is about to take is never replaced before), at
/// once while sitting out (nothing preloads then).
void _syncPreload(StateBroadcast? r) {
// The newest state seen here: the coordinator's lags behind while the
// same `state` is still being handed to it.
final seen = _lastState;
if (r == null || (seen != null && seen.mediaId > r.mediaId)) r = seen;
if (r == null) return;
final out = coord.selfOut.peek();
if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return;
final want = out ? null : _preloadWant;
final playback = coord.playback;
if (want == null) {
_preloadKey = '';
_preloadGen++;
if (playback.preloadUrl != null) unawaited(playback.dropPreload());
return;
}
if (!playback.preloadEnabled) return;
final key = '${want.url}|${want.headers}';
final fresh = _preloadFresh;
// Asked for already: in flight, there, or failed (tried again with the
// next refresh). One that went away (Reload video) is asked again.
if (!fresh && key == _preloadKey && (_preloadBusy || !_preloadOk || playback.preloadUrl == want.url)) return;
_preloadFresh = false;
_preloadKey = key;
_preloadBusy = true;
final gen = ++_preloadGen;
final id = warm.peek()?.id;
unawaited(coord.preloadMedia(want.url, headers: want.headers, fresh: fresh).then((ok) {
if (gen != _preloadGen) return;
_preloadBusy = false;
_preloadOk = ok;
// A preload that fails leaves the usual open: nothing to say.
if (!ok) debugPrint('queue: preload of $id failed');
final w = warm.peek();
if (w != null && w.id == id && w.state == WarmState.warming) {
warm.value = (id: w.id, state: WarmState.ready);
}
}));
}
// -------------------------------------------------------- continuation
/// The next few episodes after the one playing (own account only).
@@ -610,3 +710,18 @@ class QueueController {
}
}
}
/// What [QueueController] gets ready for one item on this app: [probe] a
/// link (the app that puts it up), resolve [ref] with this app's own
/// account, and preload [open] (or the resolved stream). None of it:
/// nothing to do here, and it is ready ([empty]).
class _WarmTarget {
final String id;
final String? probe;
final JellyfinRef? ref;
final String? open;
const _WarmTarget(this.id, {this.probe, this.ref, this.open});
bool get empty => probe == null && ref == null && open == null;
String get key => '$id|$probe|$open|${ref?.serverId}/${ref?.itemId}/${ref?.mediaSourceId}';
}
@@ -31,6 +31,7 @@ import 'idle.dart';
import 'media_art.dart';
import 'now_playing.dart';
import 'now_playing_info.dart';
import 'switch_trace.dart';
/// Thrown when a connect is superseded by cancel/leave before it finished.
class ConnectCancelled implements Exception {
@@ -180,6 +181,8 @@ class SessionCoordinator {
/// The current media's poster and banner by `state.media.art` hash.
late final ArtCache art = ArtCache(sync);
final PlaybackController playback = PlaybackController();
/// Timings of each media change here (see [SwitchTrace]).
final SwitchTrace switchTrace = SwitchTrace();
final StateDiff diff = StateDiff();
final health =
@@ -367,6 +370,7 @@ class SessionCoordinator {
playback.onYtdlError = _setPlayerError;
playback.onLoadError = _setPlayerError;
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
_bufferSub ??= playback.buffered.subscribe((_) => _onBuffered());
await playback.init();
_playerReady = true;
} catch (e, st) {
@@ -418,6 +422,7 @@ class SessionCoordinator {
}
bool _reloadingVideo = false;
void Function()? _bufferSub;
/// "Reload video" (button, Ctrl+R, status menu): fresh
/// player + video output, current media reopened. While it runs the
@@ -460,6 +465,7 @@ class SessionCoordinator {
diff.reset();
_mediaId = -1;
_playerMediaId = -1;
settledMediaId.value = -1;
_openUrl = null;
playerError.value = '';
_memberErrors = const {};
@@ -553,40 +559,85 @@ class SessionCoordinator {
_openHeaders = headers;
diff.reset();
_opening = true;
final mediaId = _mediaId;
var ok = false;
try {
await _prepareYtDlp(url);
await playback.openUrl(url, headers: headers);
switchTrace.mark(mediaId, SwitchTrace.open);
final promoted = await playback.openUrl(url, headers: headers);
switchTrace.mark(mediaId, promoted ? SwitchTrace.preloaded : SwitchTrace.opened);
health.value =
const HealthStatus(HealthSeverity.ok, 'connected');
return true;
ok = true;
} catch (e) {
debugPrint('open media failed: $e');
_setPlayerError('$e');
return false;
} finally {
_opening = false;
}
// Tell the server at once (a preloaded media already has its buffer).
final r = serverState.peek();
if (ok && r != null && r.mediaId == mediaId && url == _openUrl && sync.token.isNotEmpty && _playerReady) {
_sendReport(r);
}
return ok;
}
// The media whose buffer reached the gate's need was reported at once.
int _bufferReportedFor = -1;
/// While held, the report that the buffer reached the gate's need goes
/// out as it happens, not on the next tick.
void _onBuffered() {
final r = serverState.peek();
if (r == null || r.transport != 'held' || _bufferReportedFor == r.mediaId) return;
if (_lastOut || _opening || _openUrl == null || _playerMediaId != r.mediaId || !_playerReady) return;
if (sync.token.isEmpty || playback.bufferedSecsAhead < r.bufferNeed) return;
_bufferReportedFor = r.mediaId;
_sendReport(r);
}
/// A web link (not this server's): mpv's yt-dlp hook gets the app's
/// managed yt-dlp, downloaded first if needed; a media file name does
/// not wait for that.
Future<void> _prepareYtDlp(String url) async {
final path = await _ytDlpPathFor(url);
if (path != null) await playback.useYtDlp(path);
}
/// The yt-dlp mpv's hook needs for [url] (null: none, not a web link).
Future<String?> _ytDlpPathFor(String url) async {
final uri = Uri.tryParse(url);
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return;
if (uri.host == _wsHost && uri.port == _wsPort) return;
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return null;
if (uri.host == _wsHost && uri.port == _wsPort) return null;
try {
final y = await ytDlp.ytDlp;
final ready = y.ensure();
final path = hasMediaExtension(uri)
return hasMediaExtension(uri)
? y.path
: await ready.timeout(const Duration(seconds: 90), onTimeout: () => null);
if (path != null) await playback.useYtDlp(path);
} catch (e) {
debugPrint('yt-dlp not ready: $e');
return null;
}
}
/// The media of the latest `state` is open here (or has nothing to
/// open, could not, or waits for the server): its mediaId. Up next
/// preloads what comes next only then, so the two never compete, and a
/// preload a swap is about to take is never replaced first.
final settledMediaId = signal(-1);
/// Opens [url] in the hidden preload player ([PlaybackController.preload]);
/// [openUrl] for the same URL and headers then starts from it. True
/// once its first seconds are in.
Future<bool> preloadMedia(String url, {Map<String, String>? headers, bool fresh = false}) async {
if (!_playerReady) return false;
final path = await _ytDlpPathFor(url);
if (!_playerReady) return false;
return playback.preload(url, headers: headers, ytdlPath: path, fresh: fresh);
}
bool _relayWaitShown = false;
/// The server-fetched media this viewer opens once it is live.
@@ -680,6 +731,7 @@ class SessionCoordinator {
void _onState(StateBroadcast r) {
_lastStateAt = DateTime.now().toUtc();
serverState.value = r;
if (r.transport == 'playing') switchTrace.mark(r.mediaId, SwitchTrace.playing);
_toastMemberErrors(r);
_warnSlowSource(r);
_checkPlayerError(r.mediaId);
@@ -717,6 +769,7 @@ class SessionCoordinator {
_lastEventId = r.eventId;
if (r.mediaId != _mediaId) {
_mediaId = r.mediaId;
switchTrace.start(r.mediaId);
final ref = refOfStateMedia(r.media);
// Back after a dropped connection on the same item played with our
// own account: nothing to resolve or reopen.
@@ -779,6 +832,7 @@ class SessionCoordinator {
}
_seekGate.reset();
_clearPendingSeek();
if (_mediaId == r.mediaId) settledMediaId.value = r.mediaId;
} else if (_awaitLiveFor == r.mediaId && (stateMediaRelay(r)?.live ?? false)) {
_awaitLiveFor = -1;
await _openMedia(
@@ -998,6 +1052,31 @@ class SessionCoordinator {
_ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
}
/// The 1 s `report` (also sent at once when a new media is open, so the
/// server's gate does not wait for the next tick).
void _sendReport(StateBroadcast r) {
// Duration only once the open media is the server's current one.
// Sitting out: still a report (it keeps this member fresh), empty.
final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId;
// The buffer counts only while the player holds the server's current
// media: before a swap's media is open it is the previous one's (or
// none), and the start must wait for this member.
final holds = !_lastOut && !_opening && _openUrl != null && _playerMediaId == r.mediaId;
_checkPlayerError(r.mediaId);
final error = playerError.peek();
final buffered = holds ? playback.bufferedSecsAhead : 0.0;
if (holds && buffered >= r.bufferNeed) switchTrace.mark(r.mediaId, SwitchTrace.buffered);
sync.sendReport(
position: _lastOut ? 0 : playback.positionSecs,
buffered: buffered,
eventId: r.eventId,
duration: known ? playback.durationSecs : 0,
mediaId: known ? r.mediaId : 0,
jf: _jfAccessFor(r),
error: known && _playerErrorFor == r.mediaId ? error : '',
);
}
Future<void> _tick() async {
final now = DateTime.now().toUtc();
toasts.value =
@@ -1005,24 +1084,7 @@ class SessionCoordinator {
final r = serverState.peek();
if (sync.token.isNotEmpty) sync.sendPing();
if (sync.token.isNotEmpty && r != null && _playerReady) {
// Duration only once the open media is the server's current one.
// Sitting out: still a report (it keeps this member fresh), empty.
final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId;
// The buffer counts only while the player holds the server's current
// media: before a swap's media is open it is the previous one's (or
// none), and the start must wait for this member.
final holds = !_lastOut && !_opening && _openUrl != null && _playerMediaId == r.mediaId;
_checkPlayerError(r.mediaId);
final error = playerError.peek();
sync.sendReport(
position: _lastOut ? 0 : playback.positionSecs,
buffered: holds ? playback.bufferedSecsAhead : 0,
eventId: r.eventId,
duration: known ? playback.durationSecs : 0,
mediaId: known ? r.mediaId : 0,
jf: _jfAccessFor(r),
error: known && _playerErrorFor == r.mediaId ? error : '',
);
_sendReport(r);
if (!_lastOut) {
_checkSeekLanded(r);
unawaited(_tickJellyfin(r));
@@ -1580,6 +1642,9 @@ class SessionCoordinator {
);
}
/// This member's `/j/` URL for [ref] (the server's Jellyfin login).
String? jellyfinRelayUrl(JellyfinRef ref) => _relayUrlFor(ref);
String? _relayUrlFor(JellyfinRef ref) {
if (sync.token.isEmpty) return null;
final welcome = sync.relayUrl;
@@ -1632,6 +1697,7 @@ class SessionCoordinator {
if (gen == _jfGen) _opening = false;
}
if (gen != _jfGen) return;
switchTrace.mark(r.mediaId, SwitchTrace.resolved);
// This member put it up: serve it to viewers without an account, with
// its own sign-in, else the pasted link (played directly here too).
if (origin != null) {
@@ -2046,6 +2112,7 @@ class SessionCoordinator {
await _nudgeSub?.cancel();
await _errSub?.cancel();
await _statusSub?.cancel();
_bufferSub?.call();
_aniList?.close();
await sync.dispose();
await playback.dispose();
@@ -0,0 +1,53 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
/// Timings of one media change on this client, from the `state` naming
/// the new media to the server playing it (see ARCHITECTURE.md "Getting
/// ready"). Printed when [enabled] (env `SYNC_RELAY_TRACE_SWITCH=1`);
/// marks are always kept for the last few media, tests read them.
class SwitchTrace {
/// Phases, in the order they usually happen.
static const state = 'state';
static const resolved = 'resolved';
static const open = 'open';
static const opened = 'opened';
static const preloaded = 'preloaded';
static const buffered = 'buffered';
static const playing = 'playing';
static bool enabled = (Platform.environment['SYNC_RELAY_TRACE_SWITCH'] ?? '').isNotEmpty;
final String who;
SwitchTrace([this.who = '']);
final Map<int, Map<String, int>> _marks = {};
final Map<int, Stopwatch> _clocks = {};
/// Milliseconds from [state] to each phase of media [mediaId].
Map<String, int> of(int mediaId) => Map.unmodifiable(_marks[mediaId] ?? const {});
/// Starts the clock for [mediaId] (its first `state`).
void start(int mediaId) {
if (_clocks.containsKey(mediaId)) return;
_clocks[mediaId] = Stopwatch()..start();
_marks[mediaId] = {state: 0};
while (_clocks.length > 4) {
final oldest = _clocks.keys.first;
_clocks.remove(oldest);
_marks.remove(oldest);
}
}
/// Records [phase] once for [mediaId]; [playing] ends the trace.
void mark(int mediaId, String phase) {
final clock = _clocks[mediaId];
final marks = _marks[mediaId];
if (clock == null || marks == null || marks.containsKey(phase)) return;
marks[phase] = clock.elapsedMilliseconds;
if (phase == playing && enabled) {
debugPrint('switch${who.isEmpty ? '' : ' $who'} m$mediaId: '
'${[for (final e in marks.entries) '${e.key} +${e.value} ms'].join(', ')}');
}
}
}
@@ -0,0 +1,327 @@
import 'dart:async';
import 'dart:convert';
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/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/yt_dlp_app.dart';
import 'package:watch_together/src/queue/queue_controller.dart' show WarmState;
import 'package:watch_together/src/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/session/switch_trace.dart';
// How long an Up next switch takes, end to end, over real mpv and a real
// sync server: two Jellyfin account holders and a guest (through the
// server's /j login) watch an episode, then ⏭ puts up the next episode,
// then a direct link. The fake Jellyfin and the link's host answer with a
// delay and a capped rate, like a server elsewhere. Prints each client's
// phases (SwitchTrace) and the wall time from ⏭ to the server playing.
//
// WT_SWITCH_PRELOAD=0 turns the preload off (the cold path, for comparison);
// WT_SWITCH_DELAY_MS and WT_SWITCH_RATE_KBS set the remote's delay and rate.
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
const _sid = 'ab12ab12ab12ab12ab12ab12ab12ab12';
const _series = '5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e';
const _ep1 = 'e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1';
const _ep2 = 'e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2';
/// Per API request, and before a stream's first byte (WT_SWITCH_DELAY_MS).
final _delay = Duration(milliseconds: int.tryParse(Platform.environment['WT_SWITCH_DELAY_MS'] ?? '') ?? 120);
/// Per stream connection, default 24 Mbit/s (WT_SWITCH_RATE_KBS).
final _bytesPerSec = (int.tryParse(Platform.environment['WT_SWITCH_RATE_KBS'] ?? '') ?? 3072) * 1024;
/// 60 s of 720p at about 4 Mbit/s with sound (headless mpv needs audio).
Future<String> _media() async {
final path = '$_tmp/queue_switch_60s.mkv';
if (await File(path).exists()) return path;
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=1280x720:rate=24',
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
'-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-b:v', '4M', '-g', '48', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', path,
]);
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
return path;
}
/// Serves [file] with ranges, after [_delay], at most [_bytesPerSec].
Future<void> _serveFile(HttpRequest req, String file) async {
final f = File(file);
final size = await f.length();
final res = req.response;
res.headers.set('Accept-Ranges', 'bytes');
res.headers.contentType = ContentType('video', 'x-matroska');
var start = 0, end = size - 1;
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
if (range != null) {
start = int.tryParse(range.group(1)!) ?? 0;
end = int.tryParse(range.group(2)!) ?? size - 1;
if (end >= size) end = size - 1;
res.statusCode = 206;
res.headers.set('Content-Range', 'bytes $start-$end/$size');
}
res.contentLength = end - start + 1;
await Future.delayed(_delay);
try {
final raf = await f.open();
try {
await raf.setPosition(start);
var at = start;
const chunk = 64 * 1024;
final clock = Stopwatch()..start();
var sent = 0;
while (at <= end) {
final n = (end - at + 1).clamp(0, chunk);
res.add(await raf.read(n));
await res.flush();
at += n;
sent += n;
final due = Duration(microseconds: sent * 1000000 ~/ _bytesPerSec);
if (due > clock.elapsed) await Future.delayed(due - clock.elapsed);
}
} finally {
await raf.close();
}
await res.close();
} catch (_) {}
}
class _FakeJellyfin {
final String file;
final tokens = {'ATOKEN': 'ua', 'CTOKEN': 'uc', 'HOSTTOKEN': 'uh'};
late HttpServer _server;
_FakeJellyfin(this.file);
String get base => 'http://127.0.0.1:${_server.port}';
Future<void> start() async {
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
_server.listen(_handle);
}
Future<void> stop() => _server.close(force: true);
Map<String, Object?> _item(String id) => {
'Id': id,
'Name': id == _ep1 ? 'Pilot' : 'Second',
'Type': 'Episode',
'ServerId': _sid,
'SeriesId': _series,
'SeriesName': 'Fake Show',
'ParentIndexNumber': 1,
'IndexNumber': id == _ep1 ? 1 : 2,
'RunTimeTicks': 60 * jellyfinTicksPerSecond,
};
Future<void> _handle(HttpRequest req) async {
final auth = req.headers.value('authorization') ?? '';
await req.drain<void>();
final res = req.response;
final path = req.uri.path;
if (RegExp(r'^/Videos/\w+/stream$').hasMatch(path)) return _serveFile(req, file);
await Future.delayed(_delay);
Future<void> json(Object v) async {
res.headers.contentType = ContentType.json;
res.write(jsonEncode(v));
await res.close();
}
if (path == '/System/Info/Public') return json({'Id': _sid, 'ServerName': 'Fake', 'Version': '10.11.11'});
final token = RegExp(r'Token="([^"]*)"').firstMatch(auth)?.group(1);
final user = tokens[token];
if (user == null) {
res.statusCode = 401;
return res.close();
}
final m = RegExp(r'^/Items/(\w+)(/PlaybackInfo)?$').firstMatch(path);
if (m != null && m.group(2) != null) {
return json({
'PlaySessionId': 'ps-$user-${m.group(1)}',
'MediaSources': [
{
'Id': m.group(1),
'SupportsDirectPlay': true,
'MediaStreams': [
{'Index': 0, 'Type': 'Video', 'Codec': 'h264'},
{'Index': 1, 'Type': 'Audio', 'Language': 'jpn', 'Codec': 'aac'},
],
}
],
});
}
if (m != null) return json(_item(m.group(1)!));
if (path == '/Shows/$_series/Episodes') {
return json({'Items': [_item(_ep1), _item(_ep2)], 'TotalRecordCount': 2});
}
if (path.startsWith('/Sessions/Playing')) {
res.statusCode = 204;
return res.close();
}
if (path.startsWith('/UserPlayedItems/') || path.startsWith('/UserItems/')) {
return json({'Played': false, 'PlaybackPositionTicks': 0});
}
res.statusCode = 404;
await res.close();
}
}
Future<void> _poll(FutureOr<bool> Function() cond, String what,
{Duration timeout = const Duration(seconds: 40), Duration every = const Duration(milliseconds: 200)}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(every);
}
fail('timed out waiting for $what');
}
Future<JellyfinAccounts> _account(String base, String token, String userId, String device) async {
final a = JellyfinAccounts(store: MemoryJellyfinStore(), deviceName: device);
await a.ensureLoaded(validate: false);
await a.link(
baseUrl: base,
server: const JellyfinServerInfo(id: _sid, name: 'Fake', version: '10.11.11'),
auth: JellyfinAuthResult(accessToken: token, user: JellyfinUser(id: userId, name: userId), serverId: _sid),
);
return a;
}
void main() {
test(
'Up next: ⏭ to a Jellyfin episode, then to a direct link, timed per client',
timeout: const Timeout(Duration(minutes: 4)),
() async {
MediaKit.ensureInitialized();
final preload = Platform.environment['WT_SWITCH_PRELOAD'] != '0';
final file = await _media();
final jelly = _FakeJellyfin(file);
await jelly.start();
final link = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
link.listen((req) => _serveFile(req, file));
final linkUrl = 'http://127.0.0.1:${link.port}/clip.mkv';
final server = SyncServer(AppConfig(
port: 0,
secret: 'q',
name: 'q',
jellyfin: JellyfinCredential(
baseUrl: jelly.base,
serverId: _sid,
token: 'HOSTTOKEN',
client: 'Watch Together',
device: 'Host',
deviceId: 'host-1',
version: '9.9.9'),
));
await server.start();
final ytDlp = AppYtDlp(YtDlp('$_tmp/queue_switch_ytdlp', enabled: false));
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC');
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
await accB.ensureLoaded(validate: false);
SessionCoordinator coord(JellyfinAccounts acc) => SessionCoordinator(
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp)
..playback.preloadEnabled = preload;
final a = coord(accA), b = coord(accB), c = coord(accC);
final all = {'alice': a, 'guest': b, 'carol': c};
ConnectParams params(String name, [HelloMedia? media]) =>
ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'q', name: name, media: media);
final report = <String>[];
Future<void> switchOnce(String what) async {
final before = a.serverState.peek()!.mediaId;
// Time to get ready (Up next's first item, on every client).
await Future.delayed(const Duration(seconds: 8));
if (preload) {
for (final x in all.values) {
expect(x.queue.warm.peek()?.state, WarmState.ready, reason: 'ready on every client before ⏭');
expect(x.playback.preloadUrl, isNotNull, reason: 'preloaded on every client');
}
}
final clock = Stopwatch()..start();
a.queue.skip();
await _poll(() {
final r = a.serverState.peek();
return r != null && r.mediaId > before && r.transport == 'playing';
}, '$what playing', every: const Duration(milliseconds: 10));
final total = clock.elapsedMilliseconds;
final id = a.serverState.peek()!.mediaId;
report.add('$what (${preload ? 'preload' : 'cold'}): ⏭ to server playing $total ms');
for (final e in all.entries) {
report.add(' ${e.key}: ${e.value.switchTrace.of(id)}');
}
await _poll(() => all.values.every((x) => x.playback.player.state.playing), 'all play $what');
// The promoted player is the session player: marks, tracks and
// the volume carried over, the clock moves.
for (final x in all.values) {
expect(x.playback.durationSecs, greaterThan(59));
expect(x.playback.player.state.volume, 0, reason: 'volume kept');
await _poll(() => x.playback.tracks.peek().isNotEmpty, 'tracks listed');
}
final at = a.playback.positionSecs;
await Future.delayed(const Duration(seconds: 1));
expect(a.playback.positionSecs, greaterThan(at + 0.5));
}
try {
await a.connect(params('alice', const HelloMedia(
name: 'Pilot', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: _sid, itemId: _ep1, title: 'Pilot'))));
await b.connect(params('guest'));
await c.connect(params('carol'));
for (final x in all.values) {
await x.playback.player.setVolume(0);
}
await _poll(() => all.values.every((x) => x.playback.durationSecs > 59), 'all opened episode 1');
expect(await a.queue.add(pickForRef(accA, const JellyfinRef(serverId: _sid, itemId: _ep2, title: 'Second'))!),
isNull);
expect(await a.queue.add(LinkMedia(linkUrl, relay: LinkRelay.direct)), isNull);
await _poll(() => (a.serverState.peek()?.queue.length ?? 0) == 2, 'two queued');
for (final x in all.values) {
x.toggleReady();
}
await _poll(() => all.values.every((x) => x.playback.player.state.playing), 'all playing episode 1');
await switchOnce('episode 2');
expect(refOfStateMedia(a.serverState.peek()!.media)?.itemId, _ep2);
expect(b.jellyfinPlan.peek(), isA<RelayPlay>());
if (preload) {
for (final x in all.values) {
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded));
}
}
await switchOnce('direct link');
expect(stateMediaUrl(a.serverState.peek()!), linkUrl);
// ignore: avoid_print
print(report.join('\n'));
// Nothing queued: no preload. Queued and taken off again: dropped.
if (preload) {
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'nothing preloaded');
expect(await a.queue.add(LinkMedia(linkUrl, relay: LinkRelay.direct)), isNull);
await _poll(() => all.values.every((x) => x.playback.preloadUrl == linkUrl), 'preloaded again');
a.queue.remove(a.serverState.peek()!.queue.first.id);
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'dropped when taken off');
}
// Preloaded everywhere: every client starts from its preload.
if (preload) {
for (final x in all.values) {
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded));
}
}
} finally {
for (final x in all.values) {
await x.dispose();
}
await server.stop();
await jelly.stop();
await link.close(force: true);
}
},
);
}