Files
sync_replay_project/apps/desktop/lib/src/queue/queue_controller.dart
T

809 lines
32 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../files/name_parser.dart';
import '../files/thumbnails.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/format.dart' show pickFor;
import '../jellyfin/session_media.dart';
import '../screens/home/picked_media.dart';
import '../session/media_art.dart';
import '../session/session_coordinator.dart';
import '../state/settings.dart';
import '../sync/avatar_cache.dart';
import 'queue_logic.dart';
// Up next on this client (see ARCHITECTURE.md "Up next"): the server's
// queue as signals, adding to it (probed, with a picture), the end-of-media
// countdown for its first item, putting an item up when this app is the
// one that can, and keeping that item ready.
/// The end-of-media card for the first Up next item. [secondsLeft] null:
/// no countdown here (auto-advance off, cancelled, or another app puts
/// it up), the card offers Play now only.
class UpNextCountdown {
final int mediaId;
final QueueItem item;
final int? secondsLeft;
const UpNextCountdown({required this.mediaId, required this.item, this.secondsLeft});
}
/// The first item's readiness on this app.
enum WarmState { idle, warming, ready, failed }
class QueueController {
final SessionCoordinator coord;
QueueController(this.coord);
/// Queued items' pictures by hash, from `/i/`.
late final PictureCache thumbs = PictureCache(coord.sync,
route: 'i', keep: RelayConstants.maxQueueItems + 8, live: (s) => [for (final q in s.queue) q.thumb]);
/// Up next, in order (empty when nothing is queued or not in a server).
late final Computed<List<QueueItem>> items =
computed(() => coord.serverState.value?.queue ?? const <QueueItem>[]);
final countdown = signal<UpNextCountdown?>(null);
/// 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
/// Jellyfin series (not queued; shown greyed under Up next).
final continuation = signal<List<EpisodeLink>>(const []);
/// The mediaId whose continuation was cleared here.
final _hiddenFor = signal(-1);
/// The queue popover is open: the Up next button holds the controls up.
final popoverOpen = signal(false);
// What adder-only items stand for, by their key (a link passed on from
// here, a file here). Never leaves this app.
final Map<String, ({PickedMedia pick, DateTime at})> _local = {};
final Map<String, DateTime> _performing = {};
final Set<String> _skipped = {};
int _cancelledFor = -1;
Timer? _timer;
bool _attached = false;
String _continuationFor = '';
final _random = Random.secure();
String get _me => coord.sync.memberId;
void attach() {
if (_attached) return;
_attached = true;
coord.sync.states.listen(_onState);
coord.sync.feeds.listen(_onFeed);
effect(() {
final next = coord.nextEpisode.value;
final plan = coord.jellyfinPlan.value;
unawaited(_loadContinuation(next, plan));
});
// 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));
});
}
// ------------------------------------------------------------- adding
/// Adds [pick] to Up next (at the end, or with [next] right after what
/// plays). A link is probed first and refused like Put it up when it
/// will not play. Returns an error text, or null when sent.
Future<String?> add(PickedMedia pick, {bool next = false}) async {
coord.markInput();
if (coord.serverState.peek() == null) return 'not in a server';
if (items.peek().length >= RelayConstants.maxQueueItems) {
return 'Up next is full (${RelayConstants.maxQueueItems} items)';
}
final ({QueueItem item, Uint8List? thumb}) built;
try {
final b = await _build(pick);
if (b is String) return b;
built = b as ({QueueItem item, Uint8List? thumb});
} catch (e) {
return "couldn't add it: $e";
}
final item = built.item;
if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now());
final thumb = built.thumb;
coord.sync.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(thumb)));
coord.showToast('Added ${item.title} to Up next');
return null;
}
/// A continuation episode becomes an Up next item.
Future<String?> promote(EpisodeLink e) => add(e.pick);
/// The item, or an error text.
Future<Object> _build(PickedMedia pick) async {
switch (pick) {
case JellyfinMedia():
final accounts = coord.jellyfin;
if (accounts.account(pick.serverId) == null) return 'that Jellyfin server is not linked any more';
if (accounts.statusOf(pick.serverId) == JellyfinAccountStatus.expired) {
return 'Jellyfin sign-in expired — reconnect it in Settings → Jellyfin & integrations';
}
final client = accounts.clientFor(pick.serverId);
final thumb = client == null ? null : await _jellyfinThumb(client, pick.itemId);
return (
item: QueueItem(
media: mediaForPick(pick),
how: QueueHow.jellyfin,
title: pick.title,
sub: pick.subtitle,
duration: (pick.runtime?.inMilliseconds ?? 0) / 1000,
),
thumb: thumb,
);
case FileMedia(:final path):
final file = File(path);
if (!await file.exists()) return 'file not found — check the path';
final name = pick.label;
final parsed = parseReleaseName(name);
final shot = await thumbnailFor(path);
Uint8List? thumb;
if (shot != null) {
try {
final bytes = await File(shot.imagePath).readAsBytes();
if (bytes.length <= RelayConstants.maxPosterBytes) thumb = bytes;
} catch (_) {}
}
final ep = [
if (parsed.season != null) 'S${parsed.season}',
if (parsed.episode != null) 'E${parsed.episode}',
].join();
return (
item: QueueItem(
media: HelloMedia(name: name, size: await file.length(), duration: 0),
how: QueueHow.file,
title: parsed.series.trim().isEmpty ? name : parsed.series.trim(),
sub: ep,
duration: (shot?.duration.inMilliseconds ?? 0) / 1000,
key: _newKey(),
),
thumb: thumb,
);
case LinkMedia(:final url, :final anyway):
// Alone, a link plays direct: there is nobody to pass it on to.
final relay = coord.solo ? LinkRelay.direct : pick.relay;
final linked = await mediaForLink(coord.jellyfin, url);
final ref = linked.jellyfin;
if (ref != null) {
return (
item: QueueItem(
media: HelloMedia(name: linked.name, size: 0, duration: 0, jellyfin: ref),
how: QueueHow.jellyfin,
title: ref.title.isEmpty ? linked.name : ref.title,
sub: ref.subtitle ?? '',
duration: ref.runtimeSecs ?? 0),
thumb: null,
);
}
LinkProbe? probe;
String? probeError;
try {
probe = await coord.ytDlp.probe(url).timeout(const Duration(seconds: 45));
} catch (e) {
probeError = '$e';
}
final problem = probe == null ? playErrorText(probeError ?? '') : probe.problem;
if (problem != null && !anyway) return problem;
if (relay == LinkRelay.me) {
if (probe == null) return problem ?? "couldn't reach the site";
final relayProblem = probe.relayProblem;
if (relayProblem != null) return relayProblem;
}
if (relay == LinkRelay.server && !coord.serverHasPassword) return 'server fetch needs a server password';
if (relay == LinkRelay.server && probe != null && probe.isPage && probe.info != null) {
final relayProblem = probe.relayProblem;
if (relayProblem != null) return relayProblem;
}
final uri = Uri.tryParse(url) ?? Uri();
final info = probe?.info;
final title = probe?.title ?? linked.name;
final site = info == null
? uri.host
: (info.uploader.isNotEmpty ? info.uploader : (info.extractor.isNotEmpty ? info.extractor : uri.host));
final thumbUrl = info?.thumbnail == null ? null : Uri.tryParse(info!.thumbnail!);
final thumb = thumbUrl == null ? null : (await thumbnailArt(thumbUrl))?.poster;
final how = switch (relay) {
LinkRelay.direct => QueueHow.direct,
LinkRelay.server => QueueHow.server,
LinkRelay.me => QueueHow.through,
};
return (
item: QueueItem(
media: HelloMedia(
name: title,
size: 0,
duration: 0,
url: how == QueueHow.through ? null : url,
relay: how == QueueHow.server ? const MediaRelay(fetch: true) : null),
how: how,
title: title,
sub: site,
duration: info?.duration ?? 0,
key: how == QueueHow.through ? _newKey() : '',
),
thumb: thumb,
);
}
}
String _newKey() => [for (var i = 0; i < 12; i++) _random.nextInt(256).toRadixString(16).padLeft(2, '0')].join();
/// A wide picture of a Jellyfin item: an episode's own still, else the
/// item's (or series') thumb or backdrop, else its poster.
Future<Uint8List?> _jellyfinThumb(JellyfinClient client, String itemId) async {
try {
final item = await client.item(itemId).timeout(const Duration(seconds: 10));
final ref = (item.kind == JellyfinItemKind.episode ? item.ownImage(JellyfinImageType.primary) : null) ??
item.image(JellyfinImageType.thumb) ??
item.image(JellyfinImageType.backdrop) ??
item.image(JellyfinImageType.primary);
if (ref == null) return null;
return await fetchImage(
client.imageUrl(ref, maxWidth: 480, quality: 80), client.authHeaders, RelayConstants.maxPosterBytes);
} catch (e) {
debugPrint('queue: no Jellyfin picture for $itemId: $e');
return null;
}
}
/// [item]'s link as this app may hand it out (Copy link, Open in
/// browser): a link everyone opens (it is in the state), or a link this
/// app passes on (its adder). Null otherwise: a server-fetched link stays
/// with the server, another member's passed-on link with them.
String? linkOf(QueueItem item) => switch (item.how) {
QueueHow.direct => item.media.url,
QueueHow.through when item.by == _me => switch (_local[item.key]?.pick) {
LinkMedia(:final url) => url,
_ => null,
},
_ => null,
};
/// [item]'s page on its Jellyfin server, when that server is linked here.
String? jellyfinPageOf(QueueItem item) {
final ref = item.media.jellyfin;
return ref == null ? null : coord.jellyfin.webUrlOf(ref.serverId, ref.itemId);
}
// ------------------------------------------------------------ editing
void move(String id, int to) {
coord.markInput();
coord.sync.sendQueue(QueueIntent.move(id, to));
}
void remove(String id) {
coord.markInput();
coord.sync.sendQueue(QueueIntent.remove(id));
}
/// Plays [id] now: this app puts it up when it is the one that can,
/// else it asks the server, and the app that can puts it up.
void playNow(String id) {
coord.markInput();
final r = coord.serverState.peek();
final item = r?.queue.where((q) => q.id == id).firstOrNull;
if (r == null || item == null) return;
if (queuePerformer(item, r.members).id == _me) {
_perform(item);
} else {
coord.sync.sendQueue(QueueIntent.play(id));
}
}
/// Plays the first Up next item now.
void skip() {
final first = items.peek().firstOrNull;
if (first != null) playNow(first.id);
}
/// Cancel on the end-of-media card: no countdown here for this media.
void cancelCountdown() {
final c = countdown.peek();
_timer?.cancel();
_timer = null;
if (c == null) return;
_cancelledFor = c.mediaId;
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item);
}
/// "Clear": hides the continuation of what plays now (and its next
/// episode does not come up by itself).
void clearContinuation() {
final id = coord.serverState.peek()?.mediaId;
if (id != null) _hiddenFor.value = id;
if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance();
}
/// The continuation applies to [mediaId]: nothing is queued and it was
/// not cleared.
bool continuationShown(int mediaId) =>
(coord.serverState.peek()?.queue.isEmpty ?? true) && _hiddenFor.peek() != mediaId;
/// [continuation] unless cleared for the media playing.
late final Computed<List<EpisodeLink>> shownContinuation = computed(() {
final id = coord.serverState.value?.mediaId;
return id != null && _hiddenFor.value == id ? const <EpisodeLink>[] : continuation.value;
});
// --------------------------------------------------------- per state
void _onState(StateBroadcast r) {
_lastState = r;
_prune(r);
final queue = r.queue;
final play = r.queuePlay;
if (play != null) {
final item = queue.where((q) => q.id == play).firstOrNull;
if (item != null) _bringUp(item, r);
}
final finished = r.transport == 'finished';
if (queue.isNotEmpty && r.media == null) {
// Nothing up yet: the first item goes up at once.
_bringUp(queue.first, r);
} else if (finished && queue.isNotEmpty) {
_atEnd(r);
} else {
if (countdown.peek() != null) _stopCountdown();
if (!finished) _cancelledFor = -1;
}
_warm(r);
}
void _onFeed(FeedMessage f) {
if (f.action == 'queue-skip') {
coord.showToast('Skipped ${f.target ?? 'an item'}${f.reason.isEmpty ? '' : ': ${f.reason}'}');
}
}
/// Forgets what no longer matters: performing marks after a minute (a
/// swap that never landed may be tried again), local picks no longer
/// queued.
void _prune(StateBroadcast r) {
final now = DateTime.now();
_performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1));
final keys = {for (final q in r.queue) q.key};
_local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1));
final ids = {for (final q in r.queue) q.id};
_skipped.removeWhere((id) => !ids.contains(id));
}
/// [item] should go up now: by this app when it is the one that can;
/// nobody can: the lowest member takes it off with the reason.
void _bringUp(QueueItem item, StateBroadcast r) {
final perf = queuePerformer(item, r.members);
if (perf.id == null) {
final why = perf.why;
if (why != null && queueJanitor(r.members) == _me && _skipped.add(item.id)) {
coord.sync.sendQueue(QueueIntent.remove(item.id, why: why));
}
return;
}
if (perf.id == _me) _perform(item);
}
/// The media finished with Up next waiting: the card, and with
/// auto-advance on the countdown, for its first item.
void _atEnd(StateBroadcast r) {
// Up next goes before the next episode, even one already counting.
if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance();
final head = r.queue.first;
final perf = queuePerformer(head, r.members);
if (perf.id == null && perf.why != null) {
// Taken off by [_bringUp]'s rule; the next state shows the next one.
_bringUp(head, r);
return;
}
final c = countdown.peek();
if (c != null && c.mediaId == r.mediaId) {
if (c.item.id != head.id || c.item.thumb != head.thumb) {
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: head, secondsLeft: c.secondsLeft);
}
return;
}
final auto = AppSettings.instance.autoAdvance.peek() && _cancelledFor != r.mediaId;
countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: auto ? autoAdvanceSecs : null);
_timer?.cancel();
_timer = auto ? Timer.periodic(const Duration(seconds: 1), (_) => _tick()) : null;
}
void _tick() {
final c = countdown.peek();
final r = coord.serverState.peek();
final left = c?.secondsLeft;
if (c == null || left == null || r == null || r.mediaId != c.mediaId || r.transport != 'finished') {
_stopCountdown();
return;
}
if (left > 1) {
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item, secondsLeft: left - 1);
return;
}
_timer?.cancel();
_timer = null;
_cancelledFor = c.mediaId;
countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item);
// Decided on the latest state: only the app that can puts it up.
final head = r.queue.firstOrNull;
if (head != null && queuePerformer(head, r.members).id == _me) _perform(head);
}
void _stopCountdown() {
_timer?.cancel();
_timer = null;
countdown.value = null;
}
/// Puts [item] up from here (once per item while it is in flight).
/// When it cannot play after all, it is taken off with the reason.
void _perform(QueueItem item) {
if (_performing.containsKey(item.id)) return;
_performing[item.id] = DateTime.now();
unawaited(() async {
String? error;
try {
error = await _swapTo(item);
} catch (e) {
error = '$e';
}
if (error != null) {
debugPrint('queue: ${item.id} cannot go up: $error');
coord.sync.sendQueue(QueueIntent.remove(item.id, why: "couldn't play it ($error)"));
}
}());
}
Future<String?> _swapTo(QueueItem item) async {
switch (item.how) {
case QueueHow.jellyfin:
final ref = item.media.jellyfin;
final pick = ref == null ? null : pickForRef(coord.jellyfin, ref);
if (pick == null) return 'no Jellyfin account here for it';
coord.swapJellyfin(pick, queueId: item.id);
return null;
case QueueHow.direct:
final url = item.media.url;
if (url == null) return 'no link';
// Each viewer opens it: one computer's probe does not decide.
return coord.swapLink(url, relay: LinkRelay.direct, anyway: true, queueId: item.id);
case QueueHow.server:
coord.swapQueued(HelloMedia(name: item.title, size: 0, duration: 0, relay: const MediaRelay(fetch: true)),
item.id,
art: thumbs.bytesFor(item.thumb));
return null;
case QueueHow.through:
case QueueHow.file:
final local = _local[item.key];
if (local == null) return "it isn't on this computer any more";
// Only this app can pass it on, and a member sitting out has
// nothing open: back first.
if (coord.selfOut.peek()) coord.joinBack();
return coord.swapPicked(local.pick, queueId: item.id);
}
}
// ------------------------------------------------------------ warming
/// How long a warm result is kept, when it is resolved again, and when a
/// failed one is tried again.
static const _warmKeep = Duration(minutes: 35);
static const _warmRefresh = Duration(minutes: 30);
static const _warmRetry = Duration(minutes: 2);
String _warmKey = '';
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;
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) {
final performer = queuePerformer(head, r.members).id == _me;
final local = _local[head.key]?.pick;
// The server's warm relay of the item, once bytes can flow: what
// everyone without their own copy preloads.
final relay = head.media.relay;
final warmUrl = relay != null && relay.live ? coord.warmRelayUrl(head, relay) : 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`), else
// the warm relay a member with an account feeds.
final open = r.relayNext ? coord.jellyfinRelayUrl(ref) : warmUrl;
return open == null ? null : _WarmTarget(head.id, open: open);
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 (and feeds the warm relay).
if (!performer) return warmUrl == null ? null : _WarmTarget(head.id, open: warmUrl);
final url = local is LinkMedia ? local.url : null;
return _WarmTarget(head.id, probe: url, open: url);
case QueueHow.file:
if (!performer) return warmUrl == null ? null : _WarmTarget(head.id, open: warmUrl);
// 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:
// The server fetches it into its warm relay; everyone preloads that.
if (warmUrl != null) return _WarmTarget(head.id, open: warmUrl);
return performer ? _WarmTarget(head.id) : null;
}
}
final next = shownContinuation.peek().firstOrNull;
if (next == null) return null;
return _WarmTarget('next:${next.item.id}', ref: mediaForPick(next.pick).jellyfin);
}
/// 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) {
_feedWarm(r);
final t = _warmTarget(r);
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);
}
}
_syncPreload(r);
}
// The warm relay this app feeds (`<queue id>|<token>`), started once.
String _fedWarm = '';
/// Feeds the server's warm relay of Up next's first item from here when
/// it names this member as its source (a link passed on, a file), or a
/// Jellyfin item this app puts up with its own account when the server
/// asks for a source; see [SessionCoordinator.feedWarm]. The server
/// drops the relay (and with it the source) once the item is no longer
/// first.
void _feedWarm(StateBroadcast? r) {
final head = r?.queue.firstOrNull;
final relay = head?.media.relay;
if (r == null || head == null || relay == null || relay.fetch) return;
final key = '${head.id}|${coord.sync.token}';
if (key == _fedWarm) return;
switch (head.how) {
case QueueHow.through:
case QueueHow.file:
if (relay.source != _me) return;
final pick = _local[head.key]?.pick;
if (pick is FileMedia) {
_fedWarm = key;
coord.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
} else if (pick is LinkMedia) {
_fedWarm = key;
unawaited(() async {
try {
// The probe [_resolveWarm] keeps: no second yt-dlp run.
final probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60));
final y = await coord.ytDlp.ytDlp;
if (_fedWarm != key) return;
coord.feedWarm(head.id, LinkRelayMedia(Uri.parse(pick.url), ytDlp: y, probe: probe));
} catch (e) {
debugPrint('queue: feeding ${head.id}: $e');
}
}());
}
case QueueHow.jellyfin:
final ref = head.media.jellyfin;
if (ref == null || (relay.source.isNotEmpty && relay.source != _me)) return;
if (queuePerformer(head, r.members).id != _me) return;
// Resolved with this app's account by [_resolveWarm].
final w = coord.warmJellyfin;
if (w == null || !sameJellyfinItem(w.ref, ref)) return;
_fedWarm = key;
coord.feedWarm(
head.id, RelayMedia.single(HttpRelayBytes(Uri.parse(w.plan.url), headers: w.plan.headers)));
case QueueHow.direct:
case QueueHow.server:
return;
}
}
void _dropWarm(StateBroadcast? r) {
_warmTimer?.cancel();
_warmTimer = null;
_warmGen++;
_preloadWant = null;
final url = _warmUrl;
_warmUrl = null;
if (url != null) coord.ytDlp.release(url);
// Kept while it is what just went up: that open takes it.
final w = coord.warmJellyfin;
if (w != null && !sameJellyfinItem(w.ref, refOfStateMedia(r?.media))) coord.warmJellyfin = null;
}
void _resolveWarm(_WarmTarget t, {required bool fresh}) {
final gen = ++_warmGen;
unawaited(() async {
var ok = true;
({String url, Map<String, String>? headers})? open;
try {
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;
}
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));
open = (url: plan.url, headers: plan.headers);
_feedWarm(coord.serverState.peek());
}
} 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;
_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).
Future<void> _loadContinuation(EpisodeLink? next, JellyfinPlan? plan) async {
if (next == null || plan is! OwnPlay) {
_continuationFor = '';
if (continuation.peek().isNotEmpty) continuation.value = const [];
return;
}
final key = '${plan.account.serverId}/${next.item.id}';
if (key == _continuationFor) return;
_continuationFor = key;
continuation.value = [next];
final seriesId = next.item.seriesId;
if (seriesId == null) return;
try {
final list = await coord.jellyfin.cached(
plan.account.serverId, 'episodes:$seriesId', () => plan.client.episodes(seriesId));
if (_continuationFor != key) return;
final more = [next];
var at = next.item;
while (more.length < 4) {
final after = episodeAfter(list, at.id);
if (after == null) break;
more.add(EpisodeLink(after, pickFor(plan.account, after)));
at = after;
}
continuation.value = more;
} catch (e) {
debugPrint('queue: continuation of $seriesId: $e');
}
}
}
/// 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}';
}