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;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/session_media.dart' show refOfStateMedia, relayUrlFor;
|
||||
import 'delivery.dart' show stateMediaRelay;
|
||||
import 'party.dart' show isPresent;
|
||||
|
||||
// The plan step of the media pipeline: from the server's state, what this
|
||||
// member serves, its own capabilities and who is present, where this app
|
||||
// reads the current media from. One switch, pure: `media_plan_test` holds
|
||||
// the table.
|
||||
|
||||
/// What this member serves for the media it put up (set at its swap,
|
||||
/// claimed by the state that names it as the relay's source).
|
||||
sealed class MediaOrigin {
|
||||
const MediaOrigin();
|
||||
}
|
||||
|
||||
/// A file on this computer, passed on through the relay.
|
||||
final class FileOrigin extends MediaOrigin {
|
||||
final String path;
|
||||
const FileOrigin(this.path);
|
||||
}
|
||||
|
||||
/// A link this computer reads and passes on ("through me").
|
||||
final class LinkOrigin extends MediaOrigin {
|
||||
final String url;
|
||||
|
||||
/// What the link is (a page goes through [ytDlp]).
|
||||
final LinkProbe probe;
|
||||
final YtDlp ytDlp;
|
||||
const LinkOrigin(this.url, this.probe, this.ytDlp);
|
||||
}
|
||||
|
||||
/// A Jellyfin item: served with this install's account, else with the
|
||||
/// pasted [link] (its api_key stays on this machine).
|
||||
final class JellyfinOrigin extends MediaOrigin {
|
||||
final String? link;
|
||||
const JellyfinOrigin(this.link);
|
||||
}
|
||||
|
||||
/// Where this app's server is, as this member dialled it.
|
||||
class ServerAddress {
|
||||
final String host;
|
||||
final int port;
|
||||
final String token;
|
||||
const ServerAddress(this.host, this.port, this.token);
|
||||
|
||||
/// This viewer's `/m` URL of your server's file [name].
|
||||
String fileUrl(String name) => 'http://$host:$port/m/default/$token/${Uri.encodeComponent(name)}';
|
||||
|
||||
/// This viewer's `/r` URL of a relay ([key]: the media id, or the queue
|
||||
/// id of one started in Up next); with tracks, a pieces playlist or an
|
||||
/// mpv EDL of every track.
|
||||
String relayUrl(String name, String key, [List<RelayTrackSpec> tracks = const []]) =>
|
||||
relayViewerUrl('http://$host:$port/r/default/$token/$key', name, tracks: tracks);
|
||||
|
||||
/// This member's `/j` URL of [ref] (the server's Jellyfin login).
|
||||
String jellyfinUrl(JellyfinRef ref) => relayUrlFor(host, port, token, ref.itemId);
|
||||
|
||||
/// The relay channel this member feeds ([queueId]: the warm relay of
|
||||
/// that Up next item).
|
||||
Uri sourceChannel([String queueId = '']) => Uri.parse('ws://$host:$port/r/default/$token/source'
|
||||
'${queueId.isEmpty ? '' : '?q=${Uri.encodeQueryComponent(queueId)}'}');
|
||||
}
|
||||
|
||||
/// Where this app reads the current media from.
|
||||
sealed class SourcePlan {
|
||||
const SourcePlan();
|
||||
|
||||
/// The URL a player opens; null when there is nothing to open yet.
|
||||
String? get url => null;
|
||||
}
|
||||
|
||||
/// No media, or nothing this app can open (yet).
|
||||
final class NoSource extends SourcePlan {
|
||||
const NoSource();
|
||||
}
|
||||
|
||||
/// This member passes its file on: the player plays the file itself, the
|
||||
/// relay source feeds the server.
|
||||
final class OwnFile extends SourcePlan {
|
||||
final String path;
|
||||
const OwnFile(this.path);
|
||||
@override
|
||||
String get url => path;
|
||||
}
|
||||
|
||||
/// This member passes a link on: it plays the link itself (a page through
|
||||
/// its yt-dlp), the relay source reads it for the server.
|
||||
final class OwnLink extends SourcePlan {
|
||||
@override
|
||||
final String url;
|
||||
final LinkOrigin origin;
|
||||
const OwnLink(this.url, this.origin);
|
||||
}
|
||||
|
||||
/// Your server's own file (`/m`).
|
||||
final class ServerFile extends SourcePlan {
|
||||
@override
|
||||
final String url;
|
||||
const ServerFile(this.url);
|
||||
}
|
||||
|
||||
/// A link everyone opens itself; [page]: a web page, resolved by this
|
||||
/// app's yt-dlp (the pipeline's resolve step).
|
||||
final class DirectLink extends SourcePlan {
|
||||
@override
|
||||
final String url;
|
||||
final bool page;
|
||||
const DirectLink(this.url, {required this.page});
|
||||
}
|
||||
|
||||
/// The server's relay (`/r`): a member passing it on ([sourceId]) or the
|
||||
/// server's own fetch ([fetch]). [waiting]: nobody feeds it yet, so it opens
|
||||
/// once `relay.live` (mpv gives up on a silent request after 5 s).
|
||||
final class RelayedSource extends SourcePlan {
|
||||
@override
|
||||
final String url;
|
||||
final bool fetch;
|
||||
final String sourceId;
|
||||
final bool waiting;
|
||||
const RelayedSource(this.url, {required this.fetch, required this.sourceId, required this.waiting});
|
||||
}
|
||||
|
||||
/// A Jellyfin item: this app's own account first ([ownAccount]), then the
|
||||
/// server's login ([serverLogin], `/j`), then the member who put it up
|
||||
/// ([memberRelay], `/r`); the resolve step decides. [origin]: this member
|
||||
/// put it up (it passes it on to viewers without an account).
|
||||
final class JellyfinSource extends SourcePlan {
|
||||
final JellyfinRef ref;
|
||||
final bool ownAccount;
|
||||
final String? serverLogin;
|
||||
final String? memberRelay;
|
||||
final JellyfinOrigin? origin;
|
||||
const JellyfinSource(this.ref, {required this.ownAccount, this.serverLogin, this.memberRelay, this.origin});
|
||||
}
|
||||
|
||||
/// A page link this app cannot resolve (no yt-dlp here: `caps.resolve`
|
||||
/// false): [via] names who can pass it on.
|
||||
final class Unresolvable extends SourcePlan {
|
||||
final String link;
|
||||
final String via;
|
||||
const Unresolvable(this.link, this.via);
|
||||
|
||||
String get reason => "This device can't open web pages; ${via.isEmpty ? 'ask someone' : via} to pass it on (Through me)";
|
||||
}
|
||||
|
||||
/// What [planMedia] reads.
|
||||
class PlanInput {
|
||||
final StateBroadcast state;
|
||||
final String selfId;
|
||||
final ClientCaps caps;
|
||||
final ServerAddress server;
|
||||
|
||||
/// What this member serves for [StateBroadcast.mediaId] (null: nothing).
|
||||
final MediaOrigin? origin;
|
||||
|
||||
/// This app plays Jellyfin [serverId] with its own account.
|
||||
final bool Function(String serverId) ownJellyfin;
|
||||
const PlanInput({
|
||||
required this.state,
|
||||
required this.selfId,
|
||||
required this.caps,
|
||||
required this.server,
|
||||
this.origin,
|
||||
required this.ownJellyfin,
|
||||
});
|
||||
}
|
||||
|
||||
/// A relay nobody feeds yet: the server's fetch before it is read, or a
|
||||
/// member's before its channel is up while that member is still here
|
||||
/// (once it is gone or left, the server's buffer is all there is: open
|
||||
/// that).
|
||||
bool relayWaitsForSource(StateBroadcast r, MediaRelay relay) =>
|
||||
!relay.live && !relay.sourceGone && (relay.fetch || r.members.any((m) => m.id == relay.source));
|
||||
|
||||
/// The members that can resolve a page link (`caps.resolve`), present
|
||||
/// ones, lowest id first.
|
||||
List<MemberView> linkResolvers(StateBroadcast r, {String except = ''}) =>
|
||||
[for (final m in r.members) if (m.caps.resolve && isPresent(m) && m.id != except) m];
|
||||
|
||||
/// Where this app reads [input]'s media from.
|
||||
SourcePlan planMedia(PlanInput input) {
|
||||
final r = input.state;
|
||||
final media = r.media;
|
||||
if (media == null) return const NoSource();
|
||||
final name = (media['name'] as String?) ?? '';
|
||||
final relay = stateMediaRelay(r);
|
||||
final mine = relay != null && relay.source.isNotEmpty && relay.source == input.selfId;
|
||||
final origin = mine ? input.origin : null;
|
||||
final key = relay == null || relay.queueId.isEmpty ? '${r.mediaId}' : relay.queueId;
|
||||
|
||||
final ref = refOfStateMedia(media);
|
||||
if (ref != null) {
|
||||
return JellyfinSource(
|
||||
ref,
|
||||
ownAccount: input.ownJellyfin(ref.serverId),
|
||||
serverLogin: r.relay && input.server.token.isNotEmpty ? input.server.jellyfinUrl(ref) : null,
|
||||
memberRelay: relay != null && !mine ? input.server.relayUrl(name, key) : null,
|
||||
origin: origin is JellyfinOrigin ? origin : (mine ? const JellyfinOrigin(null) : null),
|
||||
);
|
||||
}
|
||||
switch (origin) {
|
||||
case FileOrigin(:final path):
|
||||
return OwnFile(path);
|
||||
case LinkOrigin(:final url):
|
||||
return OwnLink(url, origin);
|
||||
case JellyfinOrigin() || null:
|
||||
break;
|
||||
}
|
||||
if (relay != null) {
|
||||
// Another member's relay or the server's fetch; this member's own
|
||||
// relay without its origin (a restarted app) reads the server's buffer.
|
||||
return RelayedSource(input.server.relayUrl(name, key, relay.tracks),
|
||||
fetch: relay.fetch, sourceId: relay.source, waiting: !mine && relayWaitsForSource(r, relay));
|
||||
}
|
||||
final url = media['url'];
|
||||
if (url is String && url.isNotEmpty) {
|
||||
final uri = Uri.tryParse(url);
|
||||
final web = uri != null && (uri.isScheme('http') || uri.isScheme('https'));
|
||||
final page = web && !hasMediaExtension(uri);
|
||||
if (page && !input.caps.resolve) {
|
||||
final who = linkResolvers(r, except: input.selfId).firstOrNull;
|
||||
return Unresolvable(url, who == null ? '' : 'ask ${who.name}');
|
||||
}
|
||||
return DirectLink(url, page: page);
|
||||
}
|
||||
if (name.isNotEmpty && input.server.token.isNotEmpty) return ServerFile(input.server.fileUrl(name));
|
||||
return const NoSource();
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:signals/signals.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'app_events.dart';
|
||||
import 'media_plan.dart' show ServerAddress;
|
||||
import 'party.dart' show isPresent;
|
||||
|
||||
/// This member as a relay source: the current media's bytes (its file, a
|
||||
/// link it passes on, its Jellyfin stream) on `/r/.../source`, and Up next's
|
||||
/// first item on the warm relay (`?q=<queueId>`), which the swap that puts
|
||||
/// the item up carries on as the media's. A source re-dials by itself
|
||||
/// through a drop of this app (the server keeps the relay through the
|
||||
/// grace; the same `info` continues it).
|
||||
class RelayFeeder {
|
||||
final ServerAddress Function() server;
|
||||
final void Function(AppEvent event) notify;
|
||||
RelayFeeder({required this.server, required this.notify});
|
||||
|
||||
RelaySource? _source;
|
||||
int _mediaId = -1;
|
||||
({String queueId, RelaySource src})? _warm;
|
||||
|
||||
/// This member serves the current media's bytes through the server.
|
||||
final relaying = signal(false);
|
||||
|
||||
/// The media id this member serves (-1: none).
|
||||
int get mediaId => _mediaId;
|
||||
|
||||
/// The tracks of Up next item [queueId] when this member feeds its warm
|
||||
/// relay and has opened them (empty otherwise).
|
||||
List<RelayTrack> warmTracks(String queueId) => _warm?.queueId == queueId ? _warm!.src.tracks : const [];
|
||||
|
||||
/// Serves [media] for media [mediaId] on this member's relay channel.
|
||||
/// The server only pulls while someone watches through it. When the
|
||||
/// media came from Up next item [queueId] whose warm relay this member
|
||||
/// feeds, that source carries on instead. [name] and [duration] go in
|
||||
/// the `info`, the same on every dial, so the server knows a re-dial
|
||||
/// after a drop as the same media.
|
||||
void serve(int mediaId, RelayMedia media,
|
||||
{bool announce = false, String queueId = '', String name = '', double duration = 0}) {
|
||||
// Already serving this media (it re-dials by itself through a drop).
|
||||
if (_source != null && _mediaId == mediaId) {
|
||||
unawaited(media.close());
|
||||
return;
|
||||
}
|
||||
stop();
|
||||
final warm = _warm;
|
||||
if (queueId.isNotEmpty && warm != null && warm.queueId == queueId) {
|
||||
_warm = null;
|
||||
unawaited(media.close());
|
||||
_adopt(mediaId, warm.src, announce: announce, started: true);
|
||||
return;
|
||||
}
|
||||
final src = RelaySource(server().sourceChannel(), media, name: name, duration: duration);
|
||||
_adopt(mediaId, src, announce: announce, started: false);
|
||||
}
|
||||
|
||||
void _adopt(int mediaId, RelaySource src, {required bool announce, required bool started}) {
|
||||
_source = src;
|
||||
_mediaId = mediaId;
|
||||
relaying.value = true;
|
||||
if (announce) notify(const RelayStarted());
|
||||
unawaited(() async {
|
||||
if (!started) {
|
||||
try {
|
||||
await src.start();
|
||||
} catch (e) {
|
||||
debugPrint('relay source failed: $e');
|
||||
if (identical(_source, src)) {
|
||||
_clear();
|
||||
notify(RelayFailed('$e'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
await src.done;
|
||||
if (identical(_source, src)) _clear();
|
||||
}());
|
||||
}
|
||||
|
||||
void _clear() {
|
||||
_source = null;
|
||||
_mediaId = -1;
|
||||
relaying.value = false;
|
||||
}
|
||||
|
||||
/// Stops serving the current media.
|
||||
void stop() {
|
||||
final src = _source;
|
||||
_clear();
|
||||
if (src != null) unawaited(src.close());
|
||||
}
|
||||
|
||||
/// Feeds the warm relay of Up next item [queueId] from here with [media];
|
||||
/// one already feeding it is kept. The server pulls only while someone
|
||||
/// preloads it, and closes the channel once the item is no longer first
|
||||
/// (the source then stops).
|
||||
void feedWarm(String queueId, RelayMedia media) {
|
||||
if (_warm?.queueId == queueId) {
|
||||
unawaited(media.close());
|
||||
return;
|
||||
}
|
||||
stopWarm();
|
||||
final src = RelaySource(server().sourceChannel(queueId), media);
|
||||
_warm = (queueId: queueId, src: src);
|
||||
unawaited(() async {
|
||||
try {
|
||||
await src.start();
|
||||
} catch (e) {
|
||||
debugPrint('warm relay source of $queueId failed: $e');
|
||||
if (identical(_warm?.src, src)) _warm = null;
|
||||
return;
|
||||
}
|
||||
await src.done;
|
||||
if (identical(_warm?.src, src)) _warm = null;
|
||||
}());
|
||||
}
|
||||
|
||||
void stopWarm() {
|
||||
final w = _warm;
|
||||
_warm = null;
|
||||
if (w != null) unawaited(w.src.close());
|
||||
}
|
||||
|
||||
/// Leave, or a media this member no longer serves.
|
||||
void stopAll() {
|
||||
stop();
|
||||
stopWarm();
|
||||
}
|
||||
}
|
||||
|
||||
/// Someone present needs this member's relay of Jellyfin [ref]: the
|
||||
/// server's own login does not pass it on (`state.relay`) and a present
|
||||
/// member other than [selfId] does not play that server with its own
|
||||
/// account. A member whose account list is not reported yet (`caps` say it
|
||||
/// has Jellyfin, `jf` still empty) is asked again with the next state.
|
||||
/// Without anyone needing it, no relay channel is opened.
|
||||
bool jellyfinRelayNeeded(StateBroadcast r, JellyfinRef ref, String selfId) {
|
||||
if (r.relay) return false;
|
||||
for (final m in r.members) {
|
||||
if (m.id == selfId || !isPresent(m)) continue;
|
||||
if (m.jf[ref.serverId] == true) continue;
|
||||
if (m.caps.jellyfin && m.jf.isEmpty) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -15,7 +15,9 @@ import '../jellyfin/track_prefs.dart';
|
||||
import '../library/media_refs.dart';
|
||||
import '../playback/media_marks.dart';
|
||||
import '../playback/playback_controller.dart';
|
||||
import '../platform/desktop/media_kit_host.dart' show MediaKitHost;
|
||||
import '../platform/ports/link_resolver.dart';
|
||||
import '../platform/ports/player_host.dart';
|
||||
import '../playback/yt_dlp_app.dart' show AppYtDlp;
|
||||
import '../schedule/anilist.dart';
|
||||
import '../screens/home/picked_media.dart';
|
||||
@@ -30,6 +32,7 @@ import 'auto_sit_out.dart';
|
||||
import 'connection.dart';
|
||||
import 'delivery.dart';
|
||||
import 'media_art.dart';
|
||||
import 'media_pipeline.dart';
|
||||
import 'notifier.dart';
|
||||
import 'now_playing.dart';
|
||||
import 'now_playing_info.dart';
|
||||
@@ -41,6 +44,7 @@ export 'connection.dart' show ConnectCancelled, ConnectParams;
|
||||
export 'notifier.dart' show ToastItem;
|
||||
export 'delivery.dart' show stateMediaRelay;
|
||||
export 'status.dart';
|
||||
export 'media_pipeline.dart' show MediaPipeline;
|
||||
|
||||
final _log = createLogger('session');
|
||||
|
||||
@@ -74,32 +78,6 @@ bool unreadyForNewMedia(
|
||||
required bool ready}) =>
|
||||
enabled && lastMediaId >= 0 && mediaId != lastMediaId && ready;
|
||||
|
||||
/// What this member serves when its own swap is relayed: set at swap time,
|
||||
/// claimed by the state that names this member as the source.
|
||||
sealed class _RelayOrigin {
|
||||
const _RelayOrigin();
|
||||
}
|
||||
|
||||
final class _FileOrigin extends _RelayOrigin {
|
||||
final String path;
|
||||
const _FileOrigin(this.path);
|
||||
}
|
||||
|
||||
final class _LinkOrigin extends _RelayOrigin {
|
||||
final String url;
|
||||
/// What the link is (a page goes through [ytDlp]).
|
||||
final LinkProbe probe;
|
||||
final YtDlp ytDlp;
|
||||
const _LinkOrigin(this.url, this.probe, this.ytDlp);
|
||||
}
|
||||
|
||||
/// A Jellyfin item: served with this install's account, else with the
|
||||
/// pasted [link] (its api_key stays on this machine).
|
||||
final class _JellyfinOrigin extends _RelayOrigin {
|
||||
final String? link;
|
||||
const _JellyfinOrigin(this.link);
|
||||
}
|
||||
|
||||
String? stateMediaUrl(StateBroadcast state) {
|
||||
final media = state.media;
|
||||
if (media == null) return null;
|
||||
@@ -153,9 +131,13 @@ class SessionCoordinator {
|
||||
late final AvatarCache avatars = AvatarCache(connection.client);
|
||||
/// The current media's poster and banner by `state.media.art` hash.
|
||||
late final ArtCache art = ArtCache(connection.client);
|
||||
final PlaybackController playback = PlaybackController();
|
||||
/// The one way to open media: plan → resolve → open → ready, the
|
||||
/// players, Up next's prepared one, being a relay source.
|
||||
final MediaPipeline media;
|
||||
/// The session player's marks and commands.
|
||||
PlaybackController get playback => media.playback;
|
||||
/// Timings of each media change here (see [SwitchTrace]).
|
||||
final SwitchTrace switchTrace = SwitchTrace();
|
||||
SwitchTrace get switchTrace => media.trace;
|
||||
final StateDiff diff = StateDiff();
|
||||
|
||||
/// The server's latest state. A drop keeps it (with the player, the
|
||||
@@ -297,28 +279,15 @@ class SessionCoordinator {
|
||||
// out, so only a real swap counts as a new video.
|
||||
int _seenMediaId = -1;
|
||||
int _mediaId = -1;
|
||||
// The mediaId whose media the player holds (or is opening); -1 for none.
|
||||
// A swap stops the previous media.
|
||||
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 (see
|
||||
// [_openMedia]).
|
||||
int _openedFor = -1;
|
||||
String? _openUrl;
|
||||
String _wsHost = '';
|
||||
int _wsPort = 0;
|
||||
_RelayOrigin? _pendingOrigin;
|
||||
// This client's last swap, until a state shows it landed (a newer
|
||||
// mediaId than [after] with this member as `swapBy`): its art goes up then.
|
||||
// [link]: the web link it came from (a relayed one has no `url`).
|
||||
({HelloMedia media, int after, String? link, Uint8List? art})? _pendingArt;
|
||||
AniListClient? _aniList;
|
||||
// What this member serves for media [$1].
|
||||
(int, _RelayOrigin)? _activeOrigin;
|
||||
RelaySource? _relaySource;
|
||||
int _relaySourceMediaId = -1;
|
||||
/// This member serves the current media's bytes through the server.
|
||||
final relaying = signal<bool>(false);
|
||||
Signal<bool> get relaying => media.feeder.relaying;
|
||||
/// Names by member id, kept after they leave (a relay source that left).
|
||||
final memberNames = MemberNames();
|
||||
// The media this member was told its upload is too slow for.
|
||||
@@ -331,13 +300,14 @@ class SessionCoordinator {
|
||||
int _lastEventId = -1;
|
||||
final UserSeekGate _seekGate = UserSeekGate();
|
||||
Timer? _seekTimer;
|
||||
// The media is open here (or opening).
|
||||
String? get _openUrl => media.openUrl;
|
||||
// True while a media open is in flight: the player's duration may still
|
||||
// belong to the previous media then.
|
||||
bool _opening = false;
|
||||
bool get _opening => media.opening;
|
||||
/// Auto sit-out (Settings: "Auto sit-out"): idle or minimised for a
|
||||
/// while sets this member away, coming back sets it in again.
|
||||
final AutoSitOut autoSitOut = AutoSitOut(DateTime.now().toUtc());
|
||||
Map<String, String>? _openHeaders;
|
||||
|
||||
// Jellyfin: the resolved ref, the mediaId it belongs to, a generation
|
||||
// that stale resolutions check, and the progress reporter.
|
||||
@@ -366,14 +336,17 @@ class SessionCoordinator {
|
||||
Connection? connection,
|
||||
JellyfinAccounts? jellyfin,
|
||||
TrackPrefs? trackPrefs,
|
||||
LinkResolver? ytDlp}) {
|
||||
LinkResolver? ytDlp,
|
||||
PlayerHost? players}) {
|
||||
final n = notifier ?? Notifier(sfx: sfx);
|
||||
final links = ytDlp ?? AppYtDlp.instance;
|
||||
return SessionCoordinator._(
|
||||
notifier: n,
|
||||
connection: connection ?? Connection(notify: n.notify),
|
||||
ytDlp: ytDlp ?? AppYtDlp.instance,
|
||||
ytDlp: links,
|
||||
jellyfin: jellyfin ?? JellyfinAccounts.instance,
|
||||
trackPrefs: trackPrefs ?? TrackPrefs(),
|
||||
media: MediaPipeline(host: players ?? (MediaKitHost()..init()), links: links),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -382,10 +355,22 @@ class SessionCoordinator {
|
||||
required this.connection,
|
||||
required this.ytDlp,
|
||||
required this.jellyfin,
|
||||
required this.trackPrefs}) {
|
||||
required this.trackPrefs,
|
||||
required this.media}) {
|
||||
connection
|
||||
..onWelcome = _onWelcome
|
||||
..caps = () => caps;
|
||||
media
|
||||
..session = MediaSession(
|
||||
server: () => ServerAddress(_wsHost, _wsPort, connection.token),
|
||||
selfId: () => connection.memberId,
|
||||
// What this app can open itself: pages through the link
|
||||
// resolver (desktop: always), Jellyfin with its accounts.
|
||||
caps: () => ClientCaps(source: true, resolve: ytDlp.resolvesLinks, jellyfin: caps.jellyfin),
|
||||
jellyfin: jellyfin)
|
||||
..notify = notify
|
||||
..onError = _setPlayerError
|
||||
..onOpened = _onOpened;
|
||||
// The Notifier's view of the session (Status → Notifier, never back).
|
||||
notifier.session = () {
|
||||
final r = serverState.peek();
|
||||
@@ -427,17 +412,16 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
/// The session's first player: created here on connect, disposed by
|
||||
/// [leave]; each new media gets its own ([PlaybackController.openUrl]).
|
||||
/// Never reused across sessions or media (a GPU-side video freeze sticks
|
||||
/// to the player).
|
||||
/// [leave]; each new media gets its own (the [media] pipeline). Never
|
||||
/// reused across sessions or media (a GPU-side video freeze sticks to
|
||||
/// the player).
|
||||
Future<void> initPlayer() async {
|
||||
if (_playerReady) return;
|
||||
try {
|
||||
playback.onYtdlError = _setPlayerError;
|
||||
playback.onLoadError = _setPlayerError;
|
||||
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
|
||||
_bufferSub ??= playback.buffered.subscribe((_) => _onBuffered());
|
||||
await playback.init();
|
||||
await media.start();
|
||||
_playerReady = true;
|
||||
} catch (e, st) {
|
||||
debugPrint('player init failed: $e\n$st');
|
||||
@@ -445,11 +429,13 @@ class SessionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
void _setPlayerError(String raw) {
|
||||
_log.warning('player cannot open media $_mediaId: $raw');
|
||||
/// [raw]: why this player cannot open the current media; [cookies]:
|
||||
/// which cookies a page's resolve used (D11), said with a sign-in error.
|
||||
void _setPlayerError(String raw, {String? cookies}) {
|
||||
_log.warning('player cannot open media $_mediaId: $raw${cookies == null ? '' : ' ($cookies)'}');
|
||||
_playerErrorFor = _mediaId;
|
||||
_playerErrorDuration = playback.duration.peek();
|
||||
playerError.value = playErrorText(raw);
|
||||
playerError.value = withCookieNote(playErrorText(raw), cookies);
|
||||
}
|
||||
|
||||
/// Clears [playerError] once the media opened (a new duration) or the
|
||||
@@ -500,12 +486,9 @@ class SessionCoordinator {
|
||||
_playerReady = false;
|
||||
playerFault.value = '';
|
||||
try {
|
||||
await playback.recreate();
|
||||
final url = _openUrl;
|
||||
final headers = _openHeaders;
|
||||
_openUrl = null;
|
||||
_clearPendingSeek();
|
||||
if (url != null) await _openMedia(url, headers: headers);
|
||||
diff.reset();
|
||||
await media.reload();
|
||||
} catch (e, st) {
|
||||
debugPrint('video reload failed: $e\n$st');
|
||||
playerFault.value = 'video reload failed: $e';
|
||||
@@ -532,10 +515,7 @@ class SessionCoordinator {
|
||||
_wsPort = params.port;
|
||||
diff.reset();
|
||||
_mediaId = -1;
|
||||
_playerMediaId = -1;
|
||||
_openedFor = -1;
|
||||
settledMediaId.value = -1;
|
||||
_openUrl = null;
|
||||
media.reset();
|
||||
playerError.value = '';
|
||||
playerFault.value = '';
|
||||
fetchFailedFor.value = -1;
|
||||
@@ -571,7 +551,7 @@ class SessionCoordinator {
|
||||
_startTicker();
|
||||
try {
|
||||
final url = welcome.mediaUrl;
|
||||
if (url != null && url.isNotEmpty) await _openMedia(url);
|
||||
if (url != null && url.isNotEmpty) await this.media.open(url);
|
||||
final media = params.media ??
|
||||
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
|
||||
if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
|
||||
@@ -593,50 +573,19 @@ class SessionCoordinator {
|
||||
_errSub ??= connection.errors.listen((msg) => notify(ServerRefused(_sentenceCase(msg))));
|
||||
}
|
||||
|
||||
/// Opens [url] (with [headers] on every request); false on failure. A
|
||||
/// new media gets a fresh player (or the preload holding it); the media
|
||||
/// the player held before (back from sitting out, a reload) keeps it.
|
||||
Future<bool> _openMedia(String url, {Map<String, String>? headers}) async {
|
||||
if (url == _openUrl) return true;
|
||||
_opens++;
|
||||
_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;
|
||||
/// A media opened (the pipeline's open step): the server hears at once
|
||||
/// (a prepared media already has its buffer).
|
||||
void _onOpened(int mediaId, String url) {
|
||||
diff.reset();
|
||||
_opening = true;
|
||||
final mediaId = _mediaId;
|
||||
var ok = false;
|
||||
try {
|
||||
await _prepareYtDlp(url);
|
||||
switchTrace.mark(mediaId, SwitchTrace.open);
|
||||
final promoted = await playback.openUrl(url,
|
||||
headers: headers,
|
||||
keepPlayer: keepPlayer,
|
||||
onFreshPlayer: () => switchTrace.mark(mediaId, SwitchTrace.player));
|
||||
switchTrace.mark(mediaId, promoted ? SwitchTrace.preloaded : SwitchTrace.opened);
|
||||
ok = true;
|
||||
} catch (e) {
|
||||
debugPrint('open media failed: $e');
|
||||
_setPlayerError('$e');
|
||||
} 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 && connection.token.isNotEmpty && _playerReady) {
|
||||
if (r != null && r.mediaId == mediaId && url == _openUrl && connection.token.isNotEmpty && _playerReady) {
|
||||
_sendReport(r);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
int _opens = 0;
|
||||
|
||||
/// How many times this session opened a media in the player (a drop
|
||||
/// and return must not add one).
|
||||
int get opens => _opens;
|
||||
int get opens => media.opens;
|
||||
|
||||
// The media whose buffer reached the gate's need was reported at once.
|
||||
int _bufferReportedFor = -1;
|
||||
@@ -646,202 +595,16 @@ class SessionCoordinator {
|
||||
void _onBuffered() {
|
||||
final r = serverState.peek();
|
||||
if (r == null || r.transport != 'held' || _bufferReportedFor == r.mediaId) return;
|
||||
if (_lastAway || _opening || _openUrl == null || _playerMediaId != r.mediaId || !_playerReady) return;
|
||||
if (_lastAway || _opening || _openUrl == null || media.playerMediaId != r.mediaId || !_playerReady) return;
|
||||
if (connection.token.isEmpty || playback.bufferedSecsAhead < r.bufferNeed) return;
|
||||
_bufferReportedFor = r.mediaId;
|
||||
_sendReport(r);
|
||||
}
|
||||
|
||||
/// 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';
|
||||
if (u.host == _wsHost && u.port == _wsPort && 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';
|
||||
}
|
||||
|
||||
/// 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 null;
|
||||
if (uri.host == _wsHost && uri.port == _wsPort) return null;
|
||||
try {
|
||||
final y = await ytDlp.ytDlp;
|
||||
final ready = y.ensure();
|
||||
return hasMediaExtension(uri)
|
||||
? y.path
|
||||
: await ready.timeout(const Duration(seconds: 90), onTimeout: () => null);
|
||||
} 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);
|
||||
}
|
||||
|
||||
/// The server-fetched media this viewer opens once it is live.
|
||||
int _awaitLiveFor = -1;
|
||||
|
||||
/// A relay nobody feeds yet: the server's fetch before it is read, or a
|
||||
/// member's before its channel is up while that member is still here
|
||||
/// (once it is gone or left, the server's buffer is all there is: open
|
||||
/// that).
|
||||
bool _waitForSource(StateBroadcast r, MediaRelay relay) =>
|
||||
!relay.live && !relay.sourceGone && (relay.fetch || r.members.any((m) => m.id == relay.source));
|
||||
|
||||
/// This viewer's `/r/` URL; with tracks, a pieces track's playlist, or
|
||||
/// an mpv EDL of every track when there are several (a page's separate
|
||||
/// video and audio). A relay started in Up next ([queueId]) keeps its
|
||||
/// queue id in the URL, so the preload of its warm relay matches.
|
||||
String _relayMediaUrlFor(String name, int mediaId,
|
||||
[List<RelayTrackSpec> tracks = const [], String queueId = '']) =>
|
||||
relayViewerUrl('http://$_wsHost:$_wsPort/r/default/${connection.token}/${queueId.isEmpty ? mediaId : queueId}', name,
|
||||
tracks: tracks);
|
||||
|
||||
/// This viewer's URL of the warm relay of Up next's first [item]
|
||||
/// (`queue[0].media.relay`, see ARCHITECTURE "Getting ready"): the same
|
||||
/// URL the media has once the item goes up, so the preload is promoted.
|
||||
String warmRelayUrl(QueueItem item, MediaRelay relay) =>
|
||||
_relayMediaUrlFor(item.media.name.isEmpty ? item.title : item.media.name, -1, relay.tracks, item.id);
|
||||
|
||||
// This member feeds the warm relay of Up next item `queueId` (its
|
||||
// link, file or Jellyfin stream) before the item goes up; the swap
|
||||
// that puts it up takes the source over.
|
||||
({String queueId, RelaySource src})? _warmSource;
|
||||
|
||||
/// Feeds the warm relay of Up next item [queueId] from here with
|
||||
/// [media]; one already feeding it is kept. The server pulls only while
|
||||
/// someone preloads it, and closes the channel once the item is no
|
||||
/// longer first (the source then stops).
|
||||
void feedWarm(String queueId, RelayMedia media) {
|
||||
if (_warmSource?.queueId == queueId) {
|
||||
unawaited(media.close());
|
||||
return;
|
||||
}
|
||||
stopWarmSource();
|
||||
final src = RelaySource(
|
||||
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${connection.token}/source?q=${Uri.encodeQueryComponent(queueId)}'),
|
||||
media);
|
||||
_warmSource = (queueId: queueId, src: src);
|
||||
unawaited(() async {
|
||||
try {
|
||||
await src.start();
|
||||
} catch (e) {
|
||||
debugPrint('warm relay source of $queueId failed: $e');
|
||||
if (identical(_warmSource?.src, src)) _warmSource = null;
|
||||
return;
|
||||
}
|
||||
await src.done;
|
||||
if (identical(_warmSource?.src, src)) _warmSource = null;
|
||||
}());
|
||||
}
|
||||
|
||||
void stopWarmSource() {
|
||||
final w = _warmSource;
|
||||
_warmSource = null;
|
||||
if (w != null) unawaited(w.src.close());
|
||||
}
|
||||
|
||||
/// Serves [media] for media [mediaId] on this member's relay channel.
|
||||
/// The server only pulls while someone watches through it. When the
|
||||
/// media came from Up next item [queueId] whose warm relay this member
|
||||
/// feeds, that source carries on instead.
|
||||
void _startRelaySource(int mediaId, RelayMedia media,
|
||||
{bool announce = false, String queueId = '', String name = '', double duration = 0}) {
|
||||
// Already serving this media (it redials by itself through a drop and
|
||||
// goes on once this member is back).
|
||||
if (_relaySource != null && _relaySourceMediaId == mediaId) {
|
||||
unawaited(media.close());
|
||||
return;
|
||||
}
|
||||
_stopRelaySource();
|
||||
final warm = _warmSource;
|
||||
if (queueId.isNotEmpty && warm != null && warm.queueId == queueId) {
|
||||
_warmSource = null;
|
||||
unawaited(media.close());
|
||||
_relaySource = warm.src;
|
||||
_relaySourceMediaId = mediaId;
|
||||
relaying.value = true;
|
||||
if (announce) notify(const RelayStarted());
|
||||
unawaited(warm.src.done.then((_) {
|
||||
if (identical(_relaySource, warm.src)) {
|
||||
_relaySource = null;
|
||||
relaying.value = false;
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
// The same `info` on every dial (name and length included), so the
|
||||
// server knows a re-dial after a drop as the same media and continues.
|
||||
final src = RelaySource(
|
||||
Uri.parse('ws://$_wsHost:$_wsPort/r/default/${connection.token}/source'), media,
|
||||
name: name, duration: duration);
|
||||
_relaySource = src;
|
||||
_relaySourceMediaId = mediaId;
|
||||
relaying.value = true;
|
||||
if (announce) notify(const RelayStarted());
|
||||
unawaited(() async {
|
||||
try {
|
||||
await src.start();
|
||||
} catch (e) {
|
||||
debugPrint('relay source failed: $e');
|
||||
if (identical(_relaySource, src)) {
|
||||
_relaySource = null;
|
||||
relaying.value = false;
|
||||
notify(RelayFailed('$e'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
await src.done;
|
||||
if (identical(_relaySource, src)) {
|
||||
_relaySource = null;
|
||||
relaying.value = false;
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
void _stopRelaySource() {
|
||||
final src = _relaySource;
|
||||
_relaySource = null;
|
||||
_relaySourceMediaId = -1;
|
||||
relaying.value = false;
|
||||
if (src != null) unawaited(src.close());
|
||||
}
|
||||
|
||||
String _fileUrlFor(String name) {
|
||||
return 'http://$_wsHost:$_wsPort/m/default/${connection.token}/${Uri.encodeComponent(name)}';
|
||||
}
|
||||
/// prepares what comes next only then.
|
||||
Signal<int> get settledMediaId => media.settledMediaId;
|
||||
|
||||
StateBroadcast? _queuedState;
|
||||
bool _applying = false;
|
||||
@@ -881,7 +644,7 @@ class SessionCoordinator {
|
||||
_trackSelfReady(r);
|
||||
if (_lastAway) {
|
||||
// A newer media than the one this member serves: that relay ended.
|
||||
if (_relaySourceMediaId != -1 && _relaySourceMediaId != r.mediaId) _stopRelaySource();
|
||||
if (media.feeder.mediaId != -1 && media.feeder.mediaId != r.mediaId) media.feeder.stop();
|
||||
await _unloadForOut();
|
||||
return;
|
||||
}
|
||||
@@ -898,73 +661,21 @@ class SessionCoordinator {
|
||||
r.mediaId == _jfMediaId &&
|
||||
sameJellyfinItem(ref, _jfRef) &&
|
||||
jellyfinPlan.peek() is OwnPlay;
|
||||
final relay = stateMediaRelay(r);
|
||||
final mine = relay != null && relay.source.isNotEmpty && relay.source == connection.memberId;
|
||||
_RelayOrigin? origin;
|
||||
if (mine) {
|
||||
final pending = _pendingOrigin;
|
||||
if (pending != null) {
|
||||
_pendingOrigin = null;
|
||||
_activeOrigin = (r.mediaId, pending);
|
||||
}
|
||||
origin = _activeOrigin?.$1 == r.mediaId ? _activeOrigin!.$2 : null;
|
||||
}
|
||||
if (_relaySourceMediaId != r.mediaId) _stopRelaySource();
|
||||
if (!same) {
|
||||
if (same) {
|
||||
media.plan(r);
|
||||
} else {
|
||||
_seekGate.reset();
|
||||
_clearPendingSeek();
|
||||
_leaveJellyfinItem();
|
||||
await _leavePreviousMedia(r.mediaId);
|
||||
final name = (r.media?['name'] as String?) ?? '';
|
||||
if (ref != null) {
|
||||
await _playJellyfin(ref, r,
|
||||
origin: origin is _JellyfinOrigin ? origin : null,
|
||||
memberRelayUrl:
|
||||
relay != null && !mine ? _relayMediaUrlFor(name, r.mediaId, const [], relay.queueId) : null);
|
||||
} else if (origin is _FileOrigin) {
|
||||
// The source first: viewers wait on it, not on this player.
|
||||
_startRelaySource(r.mediaId, RelayMedia.single(FileRelayBytes(File(origin.path))),
|
||||
announce: true, queueId: relay?.queueId ?? '', name: name, duration: _durationOf(r));
|
||||
await _openMedia(origin.path);
|
||||
} else if (origin is _LinkOrigin) {
|
||||
_startRelaySource(
|
||||
r.mediaId, LinkRelayMedia(Uri.parse(origin.url), ytDlp: origin.ytDlp, probe: origin.probe),
|
||||
announce: true, queueId: relay?.queueId ?? '', name: name, duration: _durationOf(r));
|
||||
await _openMedia(origin.url);
|
||||
} else if (relay != null && !mine) {
|
||||
if (_waitForSource(r, relay)) {
|
||||
// The server is still getting it (yt-dlp takes seconds), or the
|
||||
// member passing it on has not dialled in yet (its app opens the
|
||||
// link first): 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 == relay.source).firstOrNull?.name;
|
||||
_log.info('media ${r.mediaId}: waiting for ${relay.fetch ? 'the server' : who} before opening the relay');
|
||||
} else {
|
||||
await _openMedia(_relayMediaUrlFor(name, r.mediaId, relay.tracks, relay.queueId));
|
||||
}
|
||||
} else {
|
||||
var url = stateMediaUrl(r);
|
||||
if ((url == null || url.isEmpty) && connection.token.isNotEmpty) {
|
||||
final name = (r.media?['name'] as String?) ?? '';
|
||||
if (name.isNotEmpty) url = _fileUrlFor(name);
|
||||
}
|
||||
if (url != null && url.isNotEmpty) {
|
||||
await _openMedia(url);
|
||||
} else {
|
||||
_openUrl = null;
|
||||
}
|
||||
}
|
||||
diff.reset();
|
||||
final next = await media.switchTo(r);
|
||||
if (next is SwitchJellyfin) await _playJellyfin(next.source, r);
|
||||
}
|
||||
_seekGate.reset();
|
||||
_clearPendingSeek();
|
||||
if (_mediaId == r.mediaId) settledMediaId.value = r.mediaId;
|
||||
} else if (_awaitLiveFor == r.mediaId && stateMediaRelay(r) != null && !_waitForSource(r, stateMediaRelay(r)!)) {
|
||||
_awaitLiveFor = -1;
|
||||
final relay = stateMediaRelay(r)!;
|
||||
await _openMedia(
|
||||
_relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId, relay.tracks, relay.queueId));
|
||||
if (_mediaId == r.mediaId) media.settled(r.mediaId);
|
||||
} else {
|
||||
await media.onState(r);
|
||||
}
|
||||
_jellyfinTransport(r);
|
||||
if (!_playerReady || _openUrl == null) {
|
||||
@@ -990,6 +701,9 @@ class SessionCoordinator {
|
||||
];
|
||||
}
|
||||
_wasPlayAllowed = allowed;
|
||||
// A relay without its source: what is buffered plays, nothing seeks
|
||||
// past it; one seek resumes when the source is back (D6).
|
||||
actions = media.recover(r, actions, serverPosition: serverPositionAt(r, now));
|
||||
// The user is seeking (or our seek has not echoed yet), or this is the
|
||||
// echo of our own seek arriving: the player is where the user put it.
|
||||
// Later drift corrections within that event still apply.
|
||||
@@ -1038,38 +752,13 @@ class SessionCoordinator {
|
||||
/// [joinBack] opens the media as new.
|
||||
Future<void> _unloadForOut() async {
|
||||
_wasPlayAllowed = false;
|
||||
_awaitLiveFor = -1;
|
||||
if (_mediaId == -1 && _openUrl == null) return;
|
||||
_mediaId = -1;
|
||||
_playerMediaId = -1;
|
||||
_openUrl = null;
|
||||
_openHeaders = null;
|
||||
_seekGate.reset();
|
||||
_clearPendingSeek();
|
||||
diff.reset();
|
||||
_leaveJellyfinItem();
|
||||
if (!_playerReady) return;
|
||||
try {
|
||||
await playback.stop();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// A new media [mediaId]: the previous one leaves the player at once, so
|
||||
/// nobody sees or hears it while the new one resolves, is fetched by the
|
||||
/// server or opens. A reconnect to the media the player holds keeps it.
|
||||
Future<void> _leavePreviousMedia(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;
|
||||
diff.reset();
|
||||
if (!_playerReady) return;
|
||||
try {
|
||||
await playback.stop();
|
||||
} catch (_) {}
|
||||
await media.unload();
|
||||
}
|
||||
|
||||
void _onFeed(FeedMessage f) {
|
||||
@@ -1114,10 +803,13 @@ class SessionCoordinator {
|
||||
// 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 = !_lastAway && !_opening && _openUrl != null && _playerMediaId == r.mediaId;
|
||||
final holds = !_lastAway && !_opening && _openUrl != null && media.playerMediaId == r.mediaId;
|
||||
_checkPlayerError(r.mediaId);
|
||||
final error = playerError.peek();
|
||||
final buffered = holds ? playback.bufferedSecsAhead : 0.0;
|
||||
// A relay without its source this player cannot follow: nothing
|
||||
// buffered, so the server waits for the source (D6).
|
||||
final starved = holds && media.starved(serverPositionAt(r, connection.serverNow()));
|
||||
final buffered = holds && !starved ? playback.bufferedSecsAhead : 0.0;
|
||||
if (holds && buffered >= r.bufferNeed) switchTrace.mark(r.mediaId, SwitchTrace.buffered);
|
||||
connection.sendReport(
|
||||
position: _lastAway ? 0 : playback.positionSecs,
|
||||
@@ -1204,7 +896,8 @@ class SessionCoordinator {
|
||||
final at = _pendingSeekAt;
|
||||
if (event == null || at == null) return;
|
||||
final now = DateTime.now().toUtc();
|
||||
if (event != r.eventId || _seekGate.holdsPlayer(now)) {
|
||||
// A relay without its source: never asked again (the seek loop).
|
||||
if (event != r.eventId || _seekGate.holdsPlayer(now) || media.sourceDown) {
|
||||
_clearPendingSeek();
|
||||
return;
|
||||
}
|
||||
@@ -1234,6 +927,7 @@ class SessionCoordinator {
|
||||
_seekGate.request(position, now);
|
||||
_clearPendingSeek();
|
||||
unawaited(playback.apply([SeekAction(position)]));
|
||||
media.noteUserSeek(position);
|
||||
_seekTimer?.cancel();
|
||||
_seekTimer = Timer(_seekGate.debounce, _flushUserSeek);
|
||||
}
|
||||
@@ -1246,9 +940,7 @@ class SessionCoordinator {
|
||||
// Playing on since the request: send where the player is now, so the
|
||||
// server's clock and ours agree when the echo comes back.
|
||||
final own = playback.positionSecs;
|
||||
if (_playerReady &&
|
||||
playback.player.state.playing &&
|
||||
(own - position).abs() < 2.0) {
|
||||
if (_playerReady && playback.playing && (own - position).abs() < 2.0) {
|
||||
position = own;
|
||||
}
|
||||
connection.sendSeek(position, seq: settled.seq);
|
||||
@@ -1482,7 +1174,7 @@ class SessionCoordinator {
|
||||
markInput();
|
||||
final media = await mediaForLink(jellyfin, url);
|
||||
if (media.jellyfin != null) {
|
||||
_pendingOrigin = _JellyfinOrigin(url);
|
||||
this.media.expectOrigin(JellyfinOrigin(url));
|
||||
_swap(media.copyWith(relay: const MediaRelay()), queueId: queueId);
|
||||
return null;
|
||||
}
|
||||
@@ -1511,13 +1203,13 @@ class SessionCoordinator {
|
||||
if (relayProblem != null) return relayProblem;
|
||||
final y = await ytDlp.ytDlp;
|
||||
// Up next's item this app already feeds (its warm relay): read.
|
||||
final warm = _warmSource;
|
||||
final fed = queueId != null && warm != null && warm.queueId == queueId && warm.src.tracks.isNotEmpty;
|
||||
final warm = queueId == null ? const <RelayTrack>[] : this.media.feeder.warmTracks(queueId);
|
||||
final fed = warm.isNotEmpty;
|
||||
final check = fed ? null : LinkRelayMedia(uri, ytDlp: y, probe: probe);
|
||||
var size = 0;
|
||||
final List<RelayTrackSpec> specs;
|
||||
try {
|
||||
final tracks = fed ? warm.src.tracks : await check!.open().timeout(const Duration(seconds: 15));
|
||||
final tracks = fed ? warm : await check!.open().timeout(const Duration(seconds: 15));
|
||||
for (final t in tracks) {
|
||||
// A playlist is read (DRM and live refused) but has no size.
|
||||
final pieces = t.pieces;
|
||||
@@ -1533,7 +1225,7 @@ class SessionCoordinator {
|
||||
} finally {
|
||||
await check?.close();
|
||||
}
|
||||
_pendingOrigin = _LinkOrigin(url, probe, y);
|
||||
this.media.expectOrigin(LinkOrigin(url, probe, y));
|
||||
_swap(HelloMedia(name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs), ref: what),
|
||||
link: url, queueId: queueId);
|
||||
return null;
|
||||
@@ -1567,7 +1259,7 @@ class SessionCoordinator {
|
||||
/// relayed by this member for viewers without an account.
|
||||
void swapJellyfin(JellyfinMedia media, {String? queueId}) {
|
||||
markInput();
|
||||
_pendingOrigin = const _JellyfinOrigin(null);
|
||||
this.media.expectOrigin(const JellyfinOrigin(null));
|
||||
_swap(mediaForPick(media).copyWith(relay: const MediaRelay()), queueId: queueId);
|
||||
}
|
||||
|
||||
@@ -1586,7 +1278,7 @@ class SessionCoordinator {
|
||||
}
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return 'file not found — check the path';
|
||||
_pendingOrigin = _FileOrigin(path);
|
||||
media.expectOrigin(FileOrigin(path));
|
||||
_swap(HelloMedia(
|
||||
name: file.uri.pathSegments.last,
|
||||
size: await file.length(),
|
||||
@@ -1642,7 +1334,6 @@ class SessionCoordinator {
|
||||
_jfMediaId = -1;
|
||||
_reporter = null;
|
||||
_lastSnap = null;
|
||||
_openHeaders = null;
|
||||
_blockedKey = '';
|
||||
currentRef.value = null;
|
||||
jellyfinPlan.value = null;
|
||||
@@ -1683,7 +1374,7 @@ class SessionCoordinator {
|
||||
final last = _lastSnap;
|
||||
return PlayerSnapshot(
|
||||
position: playback.positionSecs,
|
||||
paused: !playback.player.state.playing,
|
||||
paused: !playback.playing,
|
||||
aid: last?.aid,
|
||||
sid: last?.sid,
|
||||
);
|
||||
@@ -1695,25 +1386,15 @@ class SessionCoordinator {
|
||||
if (!_playerReady) return null;
|
||||
return _lastSnap = PlayerSnapshot(
|
||||
position: playback.positionSecs,
|
||||
paused: !playback.player.state.playing,
|
||||
paused: !playback.playing,
|
||||
aid: ids.aid,
|
||||
sid: ids.sid,
|
||||
);
|
||||
}
|
||||
|
||||
/// This member's `/j/` URL for [ref] (the server's Jellyfin login).
|
||||
String? jellyfinRelayUrl(JellyfinRef ref) => _relayUrlFor(ref);
|
||||
|
||||
String? _relayUrlFor(JellyfinRef ref) {
|
||||
if (connection.token.isEmpty) return null;
|
||||
final welcome = connection.relayUrl;
|
||||
if (welcome != null &&
|
||||
welcome.contains('/${connection.token}/') &&
|
||||
welcome.endsWith('/${Uri.encodeComponent(ref.itemId)}')) {
|
||||
return welcome;
|
||||
}
|
||||
return relayUrlFor(_wsHost, _wsPort, connection.token, ref.itemId);
|
||||
}
|
||||
String? jellyfinRelayUrl(JellyfinRef ref) =>
|
||||
connection.token.isEmpty ? null : ServerAddress(_wsHost, _wsPort, connection.token).jellyfinUrl(ref);
|
||||
|
||||
/// `report.jf`: every linked account that works (so a queued Jellyfin
|
||||
/// item finds someone to put it up while other media plays), then how
|
||||
@@ -1729,67 +1410,31 @@ class SessionCoordinator {
|
||||
return {...linked, ref.serverId: access};
|
||||
}
|
||||
|
||||
/// Resolves [ref] for this viewer and opens it: own account (direct
|
||||
/// play, progress reports, neighbouring episodes), the relay, or the
|
||||
/// blocked state. Then carries the audio/subtitle choice over.
|
||||
Future<void> _playJellyfin(JellyfinRef ref, StateBroadcast r,
|
||||
{_JellyfinOrigin? origin, String? memberRelayUrl}) async {
|
||||
/// Resolves [source]'s item for this viewer and opens it (the media
|
||||
/// pipeline: own account, the server's login, the member passing it on,
|
||||
/// or blocked), then this item's own work: progress reports and
|
||||
/// neighbouring episodes with an own account, the audio/subtitle choice
|
||||
/// carried over.
|
||||
Future<void> _playJellyfin(JellyfinSource source, StateBroadcast r) async {
|
||||
final ref = source.ref;
|
||||
final gen = ++_jfGen;
|
||||
_jfRef = ref;
|
||||
_jfMediaId = r.mediaId;
|
||||
currentRef.value = ref;
|
||||
// Until it resolves nothing is open: no syncing the previous media.
|
||||
final prevUrl = _openUrl;
|
||||
_openUrl = null;
|
||||
_opening = true;
|
||||
if (_playerReady) {
|
||||
try {
|
||||
await playback.player.pause();
|
||||
} catch (_) {}
|
||||
}
|
||||
JellyfinPlan plan;
|
||||
try {
|
||||
plan = _takeWarmJellyfin(ref) ??
|
||||
await resolveJellyfinRef(jellyfin, ref,
|
||||
relay: r.relay, relayUrl: _relayUrlFor(ref), memberRelayUrl: memberRelayUrl);
|
||||
} finally {
|
||||
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) {
|
||||
final name = (r.media?['name'] as String?) ?? '';
|
||||
if (plan is OwnPlay) {
|
||||
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(plan.url), headers: plan.headers)),
|
||||
queueId: stateMediaRelay(r)?.queueId ?? '', name: name, duration: _durationOf(r));
|
||||
} else if (origin.link != null) {
|
||||
plan = RelayPlay(origin.link!);
|
||||
_startRelaySource(r.mediaId, RelayMedia.single(HttpRelayBytes(Uri.parse(origin.link!))),
|
||||
name: name, duration: _durationOf(r));
|
||||
}
|
||||
}
|
||||
final plan = await media.openJellyfin(source, r, warm: _takeWarmJellyfin(ref), current: () => gen == _jfGen);
|
||||
if (plan == null || gen != _jfGen) return;
|
||||
jellyfinPlan.value = plan;
|
||||
switch (plan) {
|
||||
case BlockedPlay():
|
||||
jellyfinBlocked.value = plan;
|
||||
_blockedKey = _accountKey(ref.serverId);
|
||||
try {
|
||||
await playback.stop();
|
||||
} catch (_) {}
|
||||
return;
|
||||
case RelayPlay(:final url):
|
||||
case RelayPlay():
|
||||
jellyfinBlocked.value = null;
|
||||
// Same relay URL (resumed member): the player still has it.
|
||||
if (url == prevUrl) {
|
||||
_openUrl = url;
|
||||
return;
|
||||
}
|
||||
if (!await _openMedia(url) || gen != _jfGen) return;
|
||||
case OwnPlay(:final url, :final headers):
|
||||
if (_openUrl == null) return;
|
||||
case OwnPlay():
|
||||
jellyfinBlocked.value = null;
|
||||
if (!await _openMedia(url, headers: headers) || gen != _jfGen) return;
|
||||
if (_openUrl == null) return;
|
||||
// Played before and the resume point, fresh (a warmed plan's item
|
||||
// is older): the watched rule never unmarks a played item.
|
||||
final known = plan.item?.userData;
|
||||
@@ -1992,9 +1637,9 @@ class SessionCoordinator {
|
||||
/// "Try again" on the blocked card (and after linking an account).
|
||||
void retryJellyfin() {
|
||||
final r = serverState.peek();
|
||||
final ref = refOfStateMedia(r?.media);
|
||||
if (r == null || ref == null) return;
|
||||
unawaited(_playJellyfin(ref, r));
|
||||
if (r == null) return;
|
||||
final source = media.plan(r);
|
||||
if (source is JellyfinSource) unawaited(_playJellyfin(source, r));
|
||||
}
|
||||
|
||||
/// A welcome's part here (see [WelcomeHandler]), before its state is
|
||||
@@ -2027,7 +1672,8 @@ class SessionCoordinator {
|
||||
} else if (!rejoin.sameServer && _mediaId != -1) {
|
||||
// The same media under a restarted server's numbering: the player
|
||||
// keeps it.
|
||||
_mediaId = _playerMediaId = _openedFor = now.mediaId;
|
||||
_mediaId = now.mediaId;
|
||||
media.renumber(now.mediaId);
|
||||
_seenMediaId = now.mediaId;
|
||||
}
|
||||
// The intent the server lost: ready (unless the video changed and the
|
||||
@@ -2060,21 +1706,12 @@ class SessionCoordinator {
|
||||
/// scratch (a relay this member served for the old one stops).
|
||||
void _forgetMedia() {
|
||||
_mediaId = -1;
|
||||
_playerMediaId = -1;
|
||||
_openUrl = null;
|
||||
_openHeaders = null;
|
||||
_awaitLiveFor = -1;
|
||||
_wasPlayAllowed = false;
|
||||
diff.reset();
|
||||
_clearPendingSeek();
|
||||
_stopRelaySource();
|
||||
stopWarmSource();
|
||||
_activeOrigin = null;
|
||||
media.forget();
|
||||
}
|
||||
|
||||
/// The media's length from a state (0 unknown).
|
||||
static double _durationOf(StateBroadcast r) => (r.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||
|
||||
/// "Stop reconnecting".
|
||||
void cancelReconnect() => connection.cancelReconnect();
|
||||
|
||||
@@ -2090,7 +1727,7 @@ class SessionCoordinator {
|
||||
_playerReady = false;
|
||||
_ticker?.cancel();
|
||||
_ticker = null;
|
||||
await playback.disposeAll();
|
||||
await media.disposeAll();
|
||||
}
|
||||
|
||||
/// Out of the session: with [bye] (Leave, closing the app) the member
|
||||
@@ -2110,15 +1747,12 @@ class SessionCoordinator {
|
||||
_seekTimer = null;
|
||||
_seekGate.reset();
|
||||
await _stopJellyfin();
|
||||
_stopRelaySource();
|
||||
stopWarmSource();
|
||||
_pendingOrigin = null;
|
||||
media.reset();
|
||||
_pendingArt = null;
|
||||
_ownLink = null;
|
||||
_activeOrigin = null;
|
||||
_playerReady = false;
|
||||
try {
|
||||
await playback.dispose();
|
||||
await media.release();
|
||||
} catch (_) {}
|
||||
// The last lines go before the token does, then the bye.
|
||||
await connection.close(bye: bye);
|
||||
@@ -2139,6 +1773,6 @@ class SessionCoordinator {
|
||||
_bufferSub?.call();
|
||||
_aniList?.close();
|
||||
await connection.dispose();
|
||||
await playback.dispose();
|
||||
await media.dispose();
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,20 @@ StatusLine? _line(StatusFacts f, StateBroadcast? r, MemberView? self, SourceTrou
|
||||
return StatusLine(StatusKind.problem, "Can't play this: ${f.playerError}",
|
||||
level: StatusLevel.error, action: StatusAction.reload, short: "Can't play this");
|
||||
}
|
||||
// The server holds everyone for the relay's source (D6): a viewer's
|
||||
// buffer ran dry while it is gone.
|
||||
if (r.transport == 'held' && r.hold?.reason == HoldReason.source) {
|
||||
final relayed = stateMediaRelay(r);
|
||||
final id = relayed?.source ?? '';
|
||||
final name = source?.name ??
|
||||
r.members.where((m) => m.id == id).firstOrNull?.name ??
|
||||
f.names?[id] ??
|
||||
'the member passing it on';
|
||||
return StatusLine(StatusKind.waiting, 'Waiting for $name',
|
||||
detail: "$name's connection dropped; what the server had has played",
|
||||
level: StatusLevel.warn,
|
||||
short: 'Waiting for $name');
|
||||
}
|
||||
// The relay: what everyone reads comes through the server.
|
||||
final relay = f.ownPlan ? null : stateMediaRelay(r);
|
||||
if (relay != null && relay.source != f.selfId) {
|
||||
|
||||
Reference in new issue
Block a user