Restructure step 5: one media pipeline on a player host
- PlayerHost port (MediaKitHost): players by role, pins, hwdec policy, render probe, log routing, disposal order; the only media_kit user. - MediaPipeline: plan (one pure switch over state, origin, caps, presence) -> resolve -> open (prepared player promoted, else fresh) -> ready; commands queued during a switch apply once (P15); viewers play the buffer and wait for a dropped source without seek loops (D6). - Every web page resolved by our yt-dlp into an EDL with headers and cookies (danmaku excluded); mpv's ytdl_hook no longer used (ytdl=no). - D11: sign-in errors say which cookies were used and whether they hold any for the site. - Relay: 256 KiB slices read as they fill, superseded pulls released (seek-to-play ~4.2 s -> ~2.8 s, P29); own Jellyfin relay channel only when someone needs it (P28). - STARSYNC_SMOKE run and a Windows CI smoke step after the build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8970f2694b
commit
25b19f2d6a
47 files changed
+4083
-1728
No files matched your search
@@ -0,0 +1,868 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint, mapEquals, visibleForTesting;
|
||||
import 'package:signals/signals.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../platform/ports/link_resolver.dart';
|
||||
import '../platform/ports/player_host.dart';
|
||||
import '../playback/playback_controller.dart';
|
||||
import '../sync/state_diff.dart';
|
||||
import 'app_events.dart';
|
||||
import 'delivery.dart' show stateMediaRelay;
|
||||
import 'media_plan.dart';
|
||||
import 'relay_feeder.dart';
|
||||
import 'switch_trace.dart';
|
||||
|
||||
export 'media_plan.dart';
|
||||
|
||||
final _log = createLogger('session');
|
||||
|
||||
// The one way to open media: plan → resolve → open → ready.
|
||||
//
|
||||
// - plan ([planMedia]): from the state, what this member serves and its
|
||||
// capabilities, where this app reads the media from.
|
||||
// - resolve: a Jellyfin item through this app's account, the server's
|
||||
// login or the member passing it on ([openJellyfin]); a web page through
|
||||
// this app's yt-dlp ([LinkResolver]: its stream URLs, an EDL of video and
|
||||
// audio, the CDN's headers, its subtitles; mpv's own yt-dlp hook is
|
||||
// off); a relay waits until it is fed.
|
||||
// - open: a player through the [PlayerHost]: the prepared one (Up next's
|
||||
// preload) promoted, else a fresh one per media; the same media again
|
||||
// keeps its player.
|
||||
// - ready: the buffer the gate needs is reported at once (the
|
||||
// coordinator's report).
|
||||
//
|
||||
// Being a relay source ([RelayFeeder]) and the viewer's side of a source
|
||||
// that drops ([recover]) live here too.
|
||||
|
||||
/// What the coordinator does next after [MediaPipeline.switchTo]: a
|
||||
/// Jellyfin item resolves and opens through [MediaPipeline.openJellyfin]
|
||||
/// (the coordinator keeps its per-item work: progress, episodes, tracks).
|
||||
sealed class SwitchResult {
|
||||
const SwitchResult();
|
||||
}
|
||||
|
||||
final class SwitchDone extends SwitchResult {
|
||||
const SwitchDone();
|
||||
}
|
||||
|
||||
final class SwitchJellyfin extends SwitchResult {
|
||||
final JellyfinSource source;
|
||||
const SwitchJellyfin(this.source);
|
||||
}
|
||||
|
||||
/// The session context the pipeline reads (the coordinator's connection
|
||||
/// and accounts).
|
||||
class MediaSession {
|
||||
final ServerAddress Function() server;
|
||||
final String Function() selfId;
|
||||
final ClientCaps Function() caps;
|
||||
final JellyfinAccounts jellyfin;
|
||||
const MediaSession({required this.server, required this.selfId, required this.caps, required this.jellyfin});
|
||||
|
||||
static final none = MediaSession(
|
||||
server: () => const ServerAddress('', 0, ''),
|
||||
selfId: () => '',
|
||||
caps: () => const ClientCaps(),
|
||||
jellyfin: JellyfinAccounts(store: MemoryJellyfinStore()));
|
||||
}
|
||||
|
||||
class MediaPipeline {
|
||||
final PlayerHost host;
|
||||
final LinkResolver? links;
|
||||
final PlaybackController playback;
|
||||
final SwitchTrace trace;
|
||||
late final RelayFeeder feeder;
|
||||
MediaSession session;
|
||||
|
||||
/// The player could not open the current media (raw words: the
|
||||
/// coordinator turns them into `report.error`; [cookies]: which ones a
|
||||
/// page's resolve used, D11).
|
||||
void Function(String raw, {String? cookies}) onError = (_, {cookies}) {};
|
||||
void Function(AppEvent event) notify = (_) {};
|
||||
|
||||
/// A media opened in the session player ([open]): the coordinator
|
||||
/// reports at once (a promoted player already has its buffer).
|
||||
void Function(int mediaId, String url) onOpened = (_, _) {};
|
||||
|
||||
MediaPipeline({
|
||||
required this.host,
|
||||
this.links,
|
||||
PlaybackController? playback,
|
||||
SwitchTrace? trace,
|
||||
MediaSession? session,
|
||||
}) : playback = playback ?? PlaybackController(host),
|
||||
trace = trace ?? SwitchTrace(),
|
||||
session = session ?? MediaSession.none {
|
||||
feeder = RelayFeeder(server: () => this.session.server(), notify: (e) => notify(e));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ players
|
||||
|
||||
// The current player has had media opened in it (one fresh from
|
||||
// [start] has not, so the first open after join or Reload video uses
|
||||
// it).
|
||||
bool _held = false;
|
||||
|
||||
/// A live session player exists.
|
||||
bool get ready => playback.alive.peek();
|
||||
|
||||
/// The session's first player (on join); each new media gets its own.
|
||||
Future<void> start() async {
|
||||
if (playback.current != null) return;
|
||||
final p = await host.create(PlayerRole.session);
|
||||
_held = false;
|
||||
playback.attach(p);
|
||||
}
|
||||
|
||||
/// "Reload video": a fresh player and video output, the current media
|
||||
/// opened again (the caller resyncs).
|
||||
Future<void> reload() async {
|
||||
final old = playback.detach();
|
||||
if (old != null) await host.retire(old);
|
||||
await start();
|
||||
final url = _openUrl, headers = _openHeaders;
|
||||
_openUrl = null;
|
||||
// A page is resolved again: its stream URLs may have expired.
|
||||
if (url != null) await open(url, headers: headers, fresh: true, resolve: _openResolve);
|
||||
}
|
||||
|
||||
/// Leave: the prepared player and the session player go.
|
||||
Future<void> release() async {
|
||||
await dropPrepared();
|
||||
final old = playback.detach();
|
||||
_held = false;
|
||||
if (old != null) await host.retire(old);
|
||||
}
|
||||
|
||||
/// App close: every player goes now (no mpv may outlive the engine).
|
||||
Future<void> disposeAll() async {
|
||||
_pre = null;
|
||||
playback.detach();
|
||||
await host.disposeAll();
|
||||
}
|
||||
|
||||
/// Replaces the current player with a fresh one (pins, hwdec, volume,
|
||||
/// yt-dlp hook, shaders), its video output ready before the view swaps
|
||||
/// to it when the view is up. True once it is the session player.
|
||||
Future<bool> _freshPlayer() async {
|
||||
await host.retiring;
|
||||
final old = playback.current;
|
||||
if (old == null) return false;
|
||||
final HostedPlayer p;
|
||||
try {
|
||||
p = await host.create(PlayerRole.session);
|
||||
} catch (e) {
|
||||
debugPrint('fresh player: $e');
|
||||
return false;
|
||||
}
|
||||
HostedVideo? video;
|
||||
try {
|
||||
// The view is up: the new output is created (and its texture
|
||||
// registered) before the view swaps, so the old picture stays until
|
||||
// then. Made by the view instead while the render mode is unknown,
|
||||
// so the probe hears its GL init.
|
||||
if (playback.hasVideo && host.renderMode.peek() != RenderMode.unknown) {
|
||||
video = host.video(p);
|
||||
await video.ready;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('fresh player setup: $e');
|
||||
}
|
||||
// Released or reloaded meanwhile: that player stays.
|
||||
if (!identical(old, playback.current)) {
|
||||
await host.dispose(p);
|
||||
return false;
|
||||
}
|
||||
final prev = playback.attach(p, video: video);
|
||||
_held = true;
|
||||
if (prev != null) unawaited(host.retire(prev));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ resolve
|
||||
|
||||
/// [url] is a web page this app resolves: http(s), not this server, not
|
||||
/// named like a media file.
|
||||
bool _isPage(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return false;
|
||||
final s = session.server();
|
||||
if (uri.host == s.host && uri.port == s.port) return false;
|
||||
return !hasMediaExtension(uri);
|
||||
}
|
||||
|
||||
/// The resolve step for a link: a page through this app's yt-dlp (the
|
||||
/// probe, remembered a while; [fresh] asks again: stream URLs expire)
|
||||
/// gives what a player opens; a media file is opened as is, and so is a
|
||||
/// stream opened with headers (a Jellyfin item's, with its token). Throws
|
||||
/// a [ResolveFailure] in plain words, with the cookies used (D11).
|
||||
Future<_Target> _resolve(String url, Map<String, String>? headers, {bool fresh = false}) async {
|
||||
if (!_isPage(url) || (headers != null && headers.isNotEmpty)) return _Target(url, headers ?? const {});
|
||||
final l = links;
|
||||
if (l == null) throw const ResolveFailure('no link resolver here');
|
||||
final LinkProbe probe;
|
||||
try {
|
||||
probe = await l.probe(url, fresh: fresh).timeout(const Duration(seconds: 60));
|
||||
} catch (e) {
|
||||
throw ResolveFailure('$e');
|
||||
}
|
||||
if (!probe.isPage) return _Target(url, headers ?? const {});
|
||||
final play = probe.info?.play;
|
||||
if (play == null) {
|
||||
throw ResolveFailure(probe.ytDlpError ?? (probe.info == null ? 'Not a video page yt-dlp knows' : 'no format a player opens'),
|
||||
cookies: probe.cookies);
|
||||
}
|
||||
return _Target(play.mpvUrl, {...play.headers, ...?headers}, subtitles: play.subtitles);
|
||||
}
|
||||
|
||||
/// The page's subtitles on [p] once its media is in (not selected: the
|
||||
/// usual choice applies).
|
||||
Future<void> _addSubtitles(HostedPlayer p, List<YtDlpSubtitle> subs) async {
|
||||
if (subs.isEmpty) return;
|
||||
final end = DateTime.now().add(const Duration(seconds: 30));
|
||||
while (p.state.duration <= Duration.zero) {
|
||||
if (p.disposed || DateTime.now().isAfter(end)) return;
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
}
|
||||
for (final sub in subs) {
|
||||
try {
|
||||
await p.command(['sub-add', sub.url, 'auto', sub.name.isEmpty ? sub.lang : sub.name, sub.lang]);
|
||||
} catch (e) {
|
||||
debugPrint('subtitle ${sub.lang}: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- open
|
||||
|
||||
String? _openUrl;
|
||||
Map<String, String>? _openHeaders;
|
||||
// [_openUrl] went through the page resolve (not a Jellyfin stream).
|
||||
bool _openResolve = true;
|
||||
bool _opening = false;
|
||||
int _opens = 0;
|
||||
|
||||
/// The URL open (or opening) in the player; null for none.
|
||||
String? get openUrl => _openUrl;
|
||||
|
||||
/// An open is in flight: the player's duration may still be the
|
||||
/// previous media's.
|
||||
bool get opening => _opening;
|
||||
|
||||
/// How many times this session opened a media (a drop and return must
|
||||
/// not add one).
|
||||
int get opens => _opens;
|
||||
|
||||
/// The mediaId whose media the player holds (or is opening); -1 none.
|
||||
int playerMediaId = -1;
|
||||
// [playerMediaId] at the last open: the same media again (a reconnect,
|
||||
// joining back) keeps the player, any other gets a fresh one.
|
||||
int _openedFor = -1;
|
||||
int _mediaId = -1;
|
||||
|
||||
/// 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 prepares
|
||||
/// what comes next only then, so the two never compete.
|
||||
final settledMediaId = signal(-1);
|
||||
|
||||
/// Opens [url] (with [headers] on every request) in the session player:
|
||||
/// the prepared player when it holds the same, else a fresh one when the
|
||||
/// player held other media. False on failure (the error went to
|
||||
/// [onError]). [resolve]: a web page is resolved first (not for a
|
||||
/// Jellyfin stream).
|
||||
Future<bool> open(String url, {Map<String, String>? headers, bool fresh = false, bool resolve = true}) async {
|
||||
if (url == _openUrl) return true;
|
||||
_opens++;
|
||||
final mediaId = _mediaId;
|
||||
_log.info('media $mediaId: open ${_urlKind(url, headers)}: ${logUrl(url)}'
|
||||
'${headers == null || headers.isEmpty ? '' : ' (headers: ${headers.keys.join(', ')})'}');
|
||||
final keepPlayer = playerMediaId == _openedFor;
|
||||
_openedFor = playerMediaId;
|
||||
_openUrl = url;
|
||||
_openHeaders = headers;
|
||||
_openResolve = resolve;
|
||||
_opening = true;
|
||||
try {
|
||||
trace.mark(mediaId, SwitchTrace.open);
|
||||
final promoted = await _openInPlayer(url, headers,
|
||||
keepPlayer: keepPlayer,
|
||||
fresh: fresh,
|
||||
resolve: resolve,
|
||||
onFresh: () => trace.mark(mediaId, SwitchTrace.player));
|
||||
trace.mark(mediaId, promoted ? SwitchTrace.preloaded : SwitchTrace.opened);
|
||||
_opening = false;
|
||||
onOpened(mediaId, url);
|
||||
return true;
|
||||
} on ResolveFailure catch (e) {
|
||||
_log.warning('media $mediaId: cannot resolve ${logUrl(url)}: ${e.message}${e.cookies == null ? '' : ' (${e.cookies})'}');
|
||||
onError(e.message, cookies: e.cookies);
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('open media failed: $e');
|
||||
onError('$e');
|
||||
return false;
|
||||
} finally {
|
||||
_opening = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _openInPlayer(String url, Map<String, String>? headers,
|
||||
{required bool keepPlayer, bool fresh = false, bool resolve = true, void Function()? onFresh}) async {
|
||||
if (playback.current == null) return false;
|
||||
final pre = _pre;
|
||||
if (pre != null && pre.matches(url, headers) && await _promote(pre)) return true;
|
||||
final target = resolve ? await _resolve(url, headers, fresh: fresh) : _Target(url, headers ?? const {});
|
||||
if (!identical(target.url, url)) trace.mark(_mediaId, SwitchTrace.resolved);
|
||||
if (_held && !keepPlayer && await _freshPlayer()) onFresh?.call();
|
||||
final p = playback.current;
|
||||
if (p == null) return false;
|
||||
_held = true;
|
||||
playback.beginOpen();
|
||||
await p.open(target.url, headers: target.headers);
|
||||
unawaited(_addSubtitles(p, target.subtitles));
|
||||
return false;
|
||||
}
|
||||
|
||||
/// What [url] is, for the log.
|
||||
String _urlKind(String url, Map<String, String>? headers) {
|
||||
if (url.startsWith('edl://')) return 'relay (tracks)';
|
||||
final u = Uri.tryParse(url);
|
||||
if (u == null || !(u.isScheme('http') || u.isScheme('https'))) return 'local file';
|
||||
final s = session.server();
|
||||
if (u.host == s.host && u.port == s.port && u.pathSegments.isNotEmpty) {
|
||||
return switch (u.pathSegments.first) {
|
||||
'r' => 'relay',
|
||||
'm' => "the host's file",
|
||||
'j' => "Jellyfin through the server's login",
|
||||
_ => 'server URL',
|
||||
};
|
||||
}
|
||||
if (headers != null && headers.isNotEmpty) return 'Jellyfin, own sign-in';
|
||||
return 'link, fetched here';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- plan and switch
|
||||
|
||||
// What this member serves: set at its swap, claimed by the state that
|
||||
// names it as the relay's source.
|
||||
MediaOrigin? _pendingOrigin;
|
||||
(int, MediaOrigin)? _activeOrigin;
|
||||
// The relayed media this viewer opens once it is live.
|
||||
int _awaitLiveFor = -1;
|
||||
|
||||
/// This member's swap is about to be relayed from [origin].
|
||||
void expectOrigin(MediaOrigin origin) => _pendingOrigin = origin;
|
||||
|
||||
/// Where this app reads [r]'s media from (claims a pending origin when
|
||||
/// the state names this member as the source).
|
||||
SourcePlan plan(StateBroadcast r) {
|
||||
final relay = stateMediaRelay(r);
|
||||
final self = session.selfId();
|
||||
if (relay != null && relay.source.isNotEmpty && relay.source == self) {
|
||||
final pending = _pendingOrigin;
|
||||
if (pending != null) {
|
||||
_pendingOrigin = null;
|
||||
_activeOrigin = (r.mediaId, pending);
|
||||
}
|
||||
}
|
||||
final active = _activeOrigin;
|
||||
return planMedia(PlanInput(
|
||||
state: r,
|
||||
selfId: self,
|
||||
caps: session.caps(),
|
||||
server: session.server(),
|
||||
origin: active != null && active.$1 == r.mediaId ? active.$2 : null,
|
||||
ownJellyfin: (id) =>
|
||||
session.jellyfin.account(id) != null &&
|
||||
session.jellyfin.statusOf(id) != JellyfinAccountStatus.expired,
|
||||
));
|
||||
}
|
||||
|
||||
/// A new media [r.mediaId]: the previous one leaves the player at once
|
||||
/// (nobody sees or hears it while the new one resolves, is fetched or
|
||||
/// opens), then plan → resolve → open. A Jellyfin item comes back to
|
||||
/// the coordinator ([SwitchJellyfin]).
|
||||
Future<SwitchResult> switchTo(StateBroadcast r) async {
|
||||
_mediaId = r.mediaId;
|
||||
_awaitLiveFor = -1;
|
||||
_recovery = null;
|
||||
final source = plan(r);
|
||||
if (feeder.mediaId != r.mediaId) feeder.stop();
|
||||
_jellyfinFeed = null;
|
||||
await leavePrevious(r.mediaId);
|
||||
final name = (r.media?['name'] as String?) ?? '';
|
||||
final queueId = stateMediaRelay(r)?.queueId ?? '';
|
||||
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||
switch (source) {
|
||||
case JellyfinSource():
|
||||
return SwitchJellyfin(source);
|
||||
case OwnFile(:final path):
|
||||
// The source first: viewers wait on it, not on this player.
|
||||
feeder.serve(r.mediaId, RelayMedia.single(FileRelayBytes(File(path))),
|
||||
announce: true, queueId: queueId, name: name, duration: duration);
|
||||
await open(path);
|
||||
case OwnLink(:final url, :final origin):
|
||||
feeder.serve(r.mediaId, LinkRelayMedia(Uri.parse(url), ytDlp: origin.ytDlp, probe: origin.probe),
|
||||
announce: true, queueId: queueId, name: name, duration: duration);
|
||||
await open(url);
|
||||
case RelayedSource(:final url, :final waiting, :final fetch, :final sourceId):
|
||||
if (waiting) {
|
||||
// The server is still getting it (yt-dlp takes seconds), or the
|
||||
// member passing it on has not dialled in yet: mpv gives up on a
|
||||
// silent request after 5 s, so open once it is live.
|
||||
_openUrl = null;
|
||||
_awaitLiveFor = r.mediaId;
|
||||
final who = r.members.where((m) => m.id == sourceId).firstOrNull?.name;
|
||||
_log.info('media ${r.mediaId}: waiting for ${fetch ? 'the server' : who} before opening the relay');
|
||||
} else {
|
||||
await open(url);
|
||||
}
|
||||
case Unresolvable(:final reason):
|
||||
_openUrl = null;
|
||||
onError(reason);
|
||||
case ServerFile(:final url) || DirectLink(:final url):
|
||||
await open(url);
|
||||
case NoSource():
|
||||
_openUrl = null;
|
||||
}
|
||||
return const SwitchDone();
|
||||
}
|
||||
|
||||
/// The previous media leaves the player at once; a reconnect to the
|
||||
/// media the player holds keeps it.
|
||||
Future<void> leavePrevious(int mediaId) async {
|
||||
final held = playerMediaId;
|
||||
playerMediaId = mediaId;
|
||||
// Opened from the welcome, before any state: that was this media.
|
||||
if (held == -1 && _openedFor == -1 && _openUrl != null) _openedFor = mediaId;
|
||||
if (held == -1 || held == mediaId) return;
|
||||
_openUrl = null;
|
||||
_openHeaders = null;
|
||||
await playback.stop();
|
||||
}
|
||||
|
||||
/// The media was settled here (opened, failed, nothing to open).
|
||||
void settled(int mediaId) {
|
||||
if (_mediaId == mediaId) settledMediaId.value = mediaId;
|
||||
}
|
||||
|
||||
/// Same media, a new state: a relay that became live opens, a relay
|
||||
/// opened while its source was away and never loaded opens again now
|
||||
/// that it is back (D6), a Jellyfin stream this member holds back starts
|
||||
/// once someone needs it.
|
||||
Future<void> onState(StateBroadcast r) async {
|
||||
final rec = _recovery;
|
||||
final relay = stateMediaRelay(r);
|
||||
final url = _openUrl;
|
||||
if (rec != null && rec.mediaId == r.mediaId && relay != null && relay.live && !relay.sourceGone &&
|
||||
url != null && !_opening && playback.durationSecs <= 0) {
|
||||
_recovery = null;
|
||||
_log.info('media ${r.mediaId}: the relay has its source again; opening it again (it never loaded)');
|
||||
_openUrl = null;
|
||||
_openedFor = -2; // a fresh player: the old one may be stuck in a broken open
|
||||
await open(url, headers: _openHeaders, resolve: _openResolve);
|
||||
}
|
||||
if (_awaitLiveFor == r.mediaId) {
|
||||
final source = plan(r);
|
||||
if (source is RelayedSource && !source.waiting) {
|
||||
_awaitLiveFor = -1;
|
||||
await open(source.url);
|
||||
}
|
||||
}
|
||||
final feed = _jellyfinFeed;
|
||||
if (feed != null && feed.mediaId == r.mediaId && jellyfinRelayNeeded(r, feed.ref, session.selfId())) {
|
||||
_jellyfinFeed = null;
|
||||
_log.info('media ${r.mediaId}: someone needs the Jellyfin item passed on, starting the relay');
|
||||
feeder.serve(r.mediaId, feed.media, queueId: feed.queueId, name: feed.name, duration: feed.duration);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sitting out: nothing open, nothing pulled. A relay this member serves
|
||||
/// goes on (it does not use the player).
|
||||
Future<void> unload() async {
|
||||
_awaitLiveFor = -1;
|
||||
if (_mediaId == -1 && _openUrl == null) return;
|
||||
_mediaId = -1;
|
||||
playerMediaId = -1;
|
||||
_openUrl = null;
|
||||
_openHeaders = null;
|
||||
_recovery = null;
|
||||
if (ready) await playback.stop();
|
||||
}
|
||||
|
||||
/// The next state's media is new to this player: it opens again from
|
||||
/// scratch (a relay this member served for the old one stops).
|
||||
void forget() {
|
||||
_mediaId = -1;
|
||||
playerMediaId = -1;
|
||||
_openUrl = null;
|
||||
_openHeaders = null;
|
||||
_awaitLiveFor = -1;
|
||||
_recovery = null;
|
||||
_activeOrigin = null;
|
||||
_jellyfinFeed = null;
|
||||
feeder.stopAll();
|
||||
}
|
||||
|
||||
/// The same media under a restarted server's numbering: the player keeps
|
||||
/// it.
|
||||
void renumber(int mediaId) => _mediaId = playerMediaId = _openedFor = mediaId;
|
||||
|
||||
/// A new session (connect): nothing held, nothing served.
|
||||
void reset() {
|
||||
_mediaId = -1;
|
||||
playerMediaId = -1;
|
||||
_openedFor = -1;
|
||||
_openUrl = null;
|
||||
_openHeaders = null;
|
||||
_awaitLiveFor = -1;
|
||||
_recovery = null;
|
||||
_pendingOrigin = null;
|
||||
_activeOrigin = null;
|
||||
_jellyfinFeed = null;
|
||||
settledMediaId.value = -1;
|
||||
feeder.stopAll();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- Jellyfin
|
||||
|
||||
// A Jellyfin stream this member holds back: nobody present needs it
|
||||
// passed on yet.
|
||||
({int mediaId, JellyfinRef ref, RelayMedia media, String queueId, String name, double duration})? _jellyfinFeed;
|
||||
|
||||
/// The resolve step of a Jellyfin item: this app's account (or the
|
||||
/// [warm] plan resolved ahead), else the server's login, else the member
|
||||
/// passing it on, else blocked. This member's own item is passed on to
|
||||
/// viewers without an account once someone needs it. Then it opens.
|
||||
/// Null when [current] went false meanwhile.
|
||||
Future<JellyfinPlan?> openJellyfin(JellyfinSource source, StateBroadcast r,
|
||||
{OwnPlay? warm, required bool Function() current}) async {
|
||||
// Until it resolves nothing is open: no syncing the previous media.
|
||||
final prev = _openUrl;
|
||||
_openUrl = null;
|
||||
_opening = true;
|
||||
if (ready) {
|
||||
try {
|
||||
await playback.player.pause();
|
||||
} catch (_) {}
|
||||
}
|
||||
JellyfinPlan plan;
|
||||
try {
|
||||
plan = warm ??
|
||||
await resolveJellyfinRef(session.jellyfin, source.ref,
|
||||
relay: source.serverLogin != null, relayUrl: source.serverLogin, memberRelayUrl: source.memberRelay);
|
||||
} finally {
|
||||
if (current()) _opening = false;
|
||||
}
|
||||
if (!current()) return null;
|
||||
trace.mark(r.mediaId, SwitchTrace.resolved);
|
||||
final origin = source.origin;
|
||||
if (origin != null) {
|
||||
final name = (r.media?['name'] as String?) ?? '';
|
||||
final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||
final queueId = stateMediaRelay(r)?.queueId ?? '';
|
||||
RelayMedia? media;
|
||||
if (plan is OwnPlay) {
|
||||
media = RelayMedia.single(HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers));
|
||||
} else if (origin.link != null) {
|
||||
plan = RelayPlay(origin.link!);
|
||||
media = RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!)));
|
||||
}
|
||||
if (media != null) {
|
||||
if (jellyfinRelayNeeded(r, source.ref, session.selfId())) {
|
||||
feeder.serve(r.mediaId, media, queueId: queueId, name: name, duration: duration);
|
||||
} else {
|
||||
_jellyfinFeed =
|
||||
(mediaId: r.mediaId, ref: source.ref, media: media, queueId: queueId, name: name, duration: duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
switch (plan) {
|
||||
case BlockedPlay():
|
||||
await playback.stop();
|
||||
case RelayPlay(:final url):
|
||||
// The same relay URL (a resumed member): the player still has it.
|
||||
if (url == prev) {
|
||||
_openUrl = url;
|
||||
} else if (!await open(url, resolve: false)) {
|
||||
return current() ? plan : null;
|
||||
}
|
||||
case OwnPlay(:final url, :final headers):
|
||||
if (!await open(url, headers: headers, resolve: false)) return current() ? plan : null;
|
||||
}
|
||||
return current() ? plan : null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ recovery (D6)
|
||||
|
||||
// The relay this viewer reads lost its source and the buffer ran dry:
|
||||
// what it waits for.
|
||||
_Recovery? _recovery;
|
||||
|
||||
/// This viewer reads the current media through a relay whose source is
|
||||
/// gone, or left: it plays what the server has.
|
||||
bool get sourceDown => _recovery != null;
|
||||
|
||||
/// The user sought to [to] (applied at once): while the source is down,
|
||||
/// that is the one try.
|
||||
void noteUserSeek(double to) {
|
||||
final rec = _recovery;
|
||||
if (rec == null) return;
|
||||
rec.tried = true;
|
||||
rec.tryTo = to;
|
||||
}
|
||||
|
||||
/// While the source is down, this player cannot be where the server is
|
||||
/// ([serverPosition]): a seek waits, or the player is stuck short of it.
|
||||
/// The report then says nothing is buffered, so the server waits for the
|
||||
/// source instead of playing on without this viewer.
|
||||
bool starved(double serverPosition) {
|
||||
final rec = _recovery;
|
||||
if (rec == null) return false;
|
||||
return rec.seekTo != null || (playback.positionSecs - serverPosition).abs() > 3;
|
||||
}
|
||||
|
||||
/// Seeks while the source is down, not applied (tests: never a loop).
|
||||
@visibleForTesting
|
||||
int heldBackSeeks = 0;
|
||||
|
||||
/// The viewer's side of a source that drops (D6): while the relay this
|
||||
/// app reads has no source, it plays what is buffered. A seek past it
|
||||
/// (the server's buffer may still hold it) is tried once; any further
|
||||
/// one waits (mpv would wait on bytes nobody sends, time out after 5 s
|
||||
/// and be asked again: the seek loop), and the report says nothing is
|
||||
/// buffered ([starved]), so the server holds everyone with the reason
|
||||
/// `source` ("waiting for Bob"). When the source is back, one seek to
|
||||
/// where the server is resumes the player. Returns the [actions] to
|
||||
/// apply.
|
||||
List<PlayerAction> recover(StateBroadcast r, List<PlayerAction> actions, {required double serverPosition}) {
|
||||
final relay = stateMediaRelay(r);
|
||||
final url = _openUrl;
|
||||
final viaRelay = url != null && (url.startsWith('edl://') || Uri.tryParse(url)?.pathSegments.firstOrNull == 'r');
|
||||
final down = relay != null &&
|
||||
viaRelay &&
|
||||
!relay.fetch &&
|
||||
relay.source != session.selfId() &&
|
||||
(relay.sourceGone || !relay.live);
|
||||
final rec = _recovery;
|
||||
if (down) {
|
||||
final at = playback.positionSecs;
|
||||
final reach = at + playback.bufferedSecsAhead;
|
||||
final now = _recovery ??= _Recovery(r.mediaId);
|
||||
if (rec == null) {
|
||||
_log.info('media ${r.mediaId}: the relay has no source; playing what is buffered '
|
||||
'(to ${reach.toStringAsFixed(1)} s)');
|
||||
}
|
||||
final out = <PlayerAction>[];
|
||||
for (final a in actions) {
|
||||
if (a is SeekAction && (a.position > reach + 0.5 || a.position < at - 30)) {
|
||||
final last = now.tryTo;
|
||||
if (!now.tried && (last == null || (a.position - last).abs() > 1.5)) {
|
||||
now.tried = true;
|
||||
now.tryTo = a.position;
|
||||
out.add(a);
|
||||
continue;
|
||||
}
|
||||
heldBackSeeks++;
|
||||
now.seekTo = a.position;
|
||||
} else {
|
||||
out.add(a);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
_recovery = null;
|
||||
if (rec == null || rec.mediaId != r.mediaId) return actions;
|
||||
final stuck = rec.seekTo != null ||
|
||||
(playback.positionSecs - serverPosition).abs() > 1.0 ||
|
||||
playback.bufferedSecsAhead < 1;
|
||||
_log.info('media ${r.mediaId}: the relay has its source again'
|
||||
'${stuck ? '; resuming at ${serverPosition.toStringAsFixed(1)} s' : ''}');
|
||||
if (!stuck) return actions;
|
||||
// One seek to where the server is: the demuxer reads from there again.
|
||||
return [
|
||||
for (final a in actions) if (a is! SeekAction) a,
|
||||
SeekAction(serverPosition),
|
||||
];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ prepare
|
||||
|
||||
/// Preparing on (env `SYNC_RELAY_PRELOAD=0` turns it off; tests set it).
|
||||
bool preloadEnabled = Platform.environment['SYNC_RELAY_PRELOAD'] != '0';
|
||||
|
||||
_Prepared? _pre;
|
||||
|
||||
/// The URL prepared now (null: none).
|
||||
String? get preloadUrl => _pre?.url;
|
||||
|
||||
/// This viewer's URL of the warm relay of Up next's first [item]: the
|
||||
/// same URL the media has once the item goes up, so the prepared player
|
||||
/// is promoted.
|
||||
String warmRelayUrl(QueueItem item, MediaRelay relay) =>
|
||||
session.server().relayUrl(item.media.name.isEmpty ? item.title : item.media.name, item.id, relay.tracks);
|
||||
|
||||
/// Opens [url] early in a hidden player (paused at the start, muted, a
|
||||
/// small cache, its own video output when the view is up), replacing any
|
||||
/// other; 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 one unless
|
||||
/// [fresh]. [open] for the same URL promotes it.
|
||||
Future<bool> prepare(String url, {Map<String, String>? headers, 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(_disposePrepared(had));
|
||||
final pre = _pre = _Prepared(url, headers ?? const {});
|
||||
try {
|
||||
final target = await _resolve(url, headers, fresh: fresh);
|
||||
if (!identical(_pre, pre)) {
|
||||
pre.fail();
|
||||
return false;
|
||||
}
|
||||
final p = pre.player = await host.create(PlayerRole.preload);
|
||||
pre.logSub = p.logs.listen((log) {
|
||||
if (mpvLoadFailed(log, p.state.duration)) pre.fail();
|
||||
});
|
||||
pre.subtitles = target.subtitles;
|
||||
if (playback.hasVideo) {
|
||||
pre.video = host.video(p);
|
||||
await pre.video!.ready;
|
||||
}
|
||||
if (!identical(_pre, pre)) {
|
||||
pre.fail();
|
||||
await _disposePrepared(pre);
|
||||
return false;
|
||||
}
|
||||
await p.open(target.url, headers: target.headers);
|
||||
if (!pre.opened.isCompleted) pre.opened.complete();
|
||||
} catch (e) {
|
||||
debugPrint('preload failed: $e');
|
||||
pre.fail();
|
||||
if (identical(_pre, pre)) _pre = null;
|
||||
await _disposePrepared(pre);
|
||||
return false;
|
||||
}
|
||||
unawaited(_watchPrepared(pre, need));
|
||||
return pre.ready.future;
|
||||
}
|
||||
|
||||
Future<void> _watchPrepared(_Prepared 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 prepared player (nothing to get ready, or something else
|
||||
/// is).
|
||||
Future<void> dropPrepared() async {
|
||||
final pre = _pre;
|
||||
if (pre == null) return;
|
||||
_pre = null;
|
||||
await _disposePrepared(pre);
|
||||
}
|
||||
|
||||
Future<void> _disposePrepared(_Prepared pre) async {
|
||||
pre.fail();
|
||||
await pre.logSub?.cancel();
|
||||
final p = pre.player;
|
||||
if (p != null) await host.dispose(p);
|
||||
}
|
||||
|
||||
/// [pre] becomes the session player; its buffer comes along.
|
||||
Future<bool> _promote(_Prepared pre) async {
|
||||
await pre.opened.future;
|
||||
final old = playback.current;
|
||||
if (!identical(_pre, pre) || old == null) return false;
|
||||
_pre = null;
|
||||
final p = pre.player;
|
||||
if (pre.failed || p == null) {
|
||||
unawaited(_disposePrepared(pre));
|
||||
return false;
|
||||
}
|
||||
await pre.logSub?.cancel();
|
||||
if (!pre.ready.isCompleted) pre.ready.complete(true);
|
||||
await host.promote(p);
|
||||
final prev = playback.attach(p, video: pre.video, loaded: true);
|
||||
_held = true;
|
||||
unawaited(_addSubtitles(p, pre.subtitles));
|
||||
if (prev != null) unawaited(host.retire(prev));
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await release();
|
||||
feeder.stopAll();
|
||||
await playback.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _Recovery {
|
||||
final int mediaId;
|
||||
|
||||
/// The latest seek held back (where the server went meanwhile).
|
||||
double? seekTo;
|
||||
|
||||
/// The one seek past the buffer was tried ([tryTo]: where to).
|
||||
bool tried = false;
|
||||
double? tryTo;
|
||||
_Recovery(this.mediaId);
|
||||
}
|
||||
|
||||
class _Prepared {
|
||||
final String url;
|
||||
final Map<String, String> headers;
|
||||
HostedPlayer? player;
|
||||
HostedVideo? video;
|
||||
StreamSubscription<PlayerLogLine>? logSub;
|
||||
List<YtDlpSubtitle> subtitles = const [];
|
||||
|
||||
/// Completes once the media is handed to mpv (or it failed).
|
||||
final opened = Completer<void>();
|
||||
bool failed = false;
|
||||
final ready = Completer<bool>();
|
||||
_Prepared(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();
|
||||
}
|
||||
}
|
||||
|
||||
/// What a player opens for a link (the resolve step).
|
||||
class _Target {
|
||||
final String url;
|
||||
final Map<String, String> headers;
|
||||
final List<YtDlpSubtitle> subtitles;
|
||||
const _Target(this.url, this.headers, {this.subtitles = const []});
|
||||
}
|
||||
|
||||
/// A page could not be resolved: why ([message], yt-dlp's words) and which
|
||||
/// cookies that used ([cookies], D11).
|
||||
class ResolveFailure implements Exception {
|
||||
final String message;
|
||||
final String? cookies;
|
||||
const ResolveFailure(this.message, {this.cookies});
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
Reference in new issue
Block a user