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:
foxyzandClaude Opus 5.5 committed 2026-10-16 01:08:54 +03:00
1 parent 8970f2694b
commit 25b19f2d6a
47 files changed
+4083 -1728

No files matched your search

+9 -2
View File
@@ -3,15 +3,16 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'app.dart';
import 'src/app/app_graph.dart';
import 'src/files/thumbnails.dart' show thumbnailPlayers;
import 'src/platform/app_log.dart';
import 'src/platform/app_shutdown.dart' show closeAppWith;
import 'src/platform/desktop/desktop_app_dirs.dart';
import 'src/platform/desktop_entry.dart';
import 'src/platform/smoke.dart';
import 'src/screens/home/ui.dart';
import 'src/session/app_events.dart' show UpdateAvailable;
import 'src/state/settings.dart';
@@ -67,7 +68,8 @@ Future<void> main(List<String> args) async {
}
// The library's matches, pins and offsets (device-owned).
unawaited(graph.library.ensureLoaded());
MediaKit.ensureInitialized();
ports.players.init();
thumbnailPlayers = ports.players;
// yt-dlp for web page links: downloaded on first run, kept current.
ports.links.warmUp();
// Linux: the launcher entry and icon Wayland needs (release installs).
@@ -82,6 +84,11 @@ Future<void> main(List<String> args) async {
}
});
runApp(AppScope(graph: graph, child: const WatchTogetherApp()));
// CI's close smoke run: open a file, play, close (see smoke.dart).
final smoke = smokeMedia;
if (smoke != null) {
unawaited(WidgetsBinding.instance.endOfFrame.then((_) => runSmoke(graph, smoke)));
}
// Home: your own server, quietly (only this app can reach it until
// Invite).
unawaited(graph.navigation.start());
+2 -2
View File
@@ -88,8 +88,8 @@ class AppGraph {
prefs: () => AppSettings.instance.notifyPrefs,
);
final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored());
final coordinator =
SessionCoordinator(notifier: notifier, connection: connection, ytDlp: p.links, jellyfin: jellyfin);
final coordinator = SessionCoordinator(
notifier: notifier, connection: connection, ytDlp: p.links, jellyfin: jellyfin, players: p.players);
// Device-owned: the library box; AnimeFlow's runs when signed in.
final matcher = library ??
ShowMatcher(
@@ -121,7 +121,7 @@ class PlayerControlsModel {
/// A seek bar's press or drag; through `player.seek`, the user-seek
/// path, so everyone follows.
void seek(Duration to) => playback.player.seek(to);
void seek(Duration to) => playback.userSeek(to);
/// Arrow keys: ±[secs], relative to a seek still in flight.
void nudge(double secs) => coord.nudge(secs);
@@ -157,7 +157,7 @@ class PlayerControlsModel {
void playNextEpisode() => coord.playNextEpisode();
void setVolume(double v) => playback.player.setVolume(v.clamp(0.0, vol.maxVolume));
void setVolume(double v) => playback.setVolume(v.clamp(0.0, vol.maxVolume));
/// One volume step up (+1) or down (-1) from the wheel or ↑ / ↓;
/// flashes the volume readout.
@@ -222,7 +222,9 @@ class ShaderPresetSetting extends StatelessWidget {
final preset = s.shaderPreset.value;
final quality = s.shaderQuality.value;
final hasCustom = s.customShaders.value.isNotEmpty;
final mode = (renderMode ?? PlaybackController.lastRenderMode).value;
final mode = (renderMode ??
context.getInheritedWidgetOfExactType<AppScope>()?.graph.ports.players.renderMode)
?.value;
final warning = switch (mode) {
RenderMode.software => 'Software rendering: shaders have no effect',
RenderMode.softwareGl => 'OpenGL on the CPU: shaders will be slow',
@@ -267,10 +269,10 @@ class ShaderPresetSetting extends StatelessWidget {
/// on the fly). `SYNC_RELAY_HWDEC` wins, as at player start.
Future<void> applyHwdecLive(PlaybackController playback) async {
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
if (!playback.alive.peek()) return;
final p = playback.current;
if (p == null) return;
try {
final dynamic platform = playback.player.platform;
await platform?.setProperty('hwdec', AppSettings.instance.hwdec.peek());
await p.setProperty('hwdec', AppSettings.instance.hwdec.peek());
} catch (e) {
debugPrint('live hwdec failed: $e');
}
+12 -16
View File
@@ -4,9 +4,11 @@ import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:media_kit/media_kit.dart';
import 'package:path_provider/path_provider.dart';
import '../platform/desktop/media_kit_host.dart' show MediaKitHost;
import '../platform/ports/player_host.dart';
// Poster frames for the Files source, made by mpv itself (no ffmpeg on
// users' machines; Windows ships only libmpv). A hidden player per file
// with `vo=null`: mpv still keeps the last decoded frame, so
@@ -23,6 +25,9 @@ class Thumbnail {
const thumbnailWidth = 480;
/// The players thumbnails are made with (the app's host, set in `main`).
PlayerHost thumbnailPlayers = MediaKitHost()..init();
/// Where thumbnails are cached; swapped in tests.
Future<Directory> Function() thumbnailCacheRoot = getApplicationCacheDirectory;
@@ -33,7 +38,7 @@ const _timeout = Duration(seconds: 20);
int _running = 0;
final _waiting = <Completer<void>>[];
final _inFlight = <String, Future<Thumbnail?>>{};
final _players = <Player>{};
final _players = <HostedPlayer>{};
bool _closed = false;
/// App close: no new thumbnails, running ones stop at their next check
@@ -49,7 +54,7 @@ Future<void> cancelAllThumbnails({
for (final p in _players.toList()) {
_players.remove(p);
try {
await p.dispose().timeout(deadline);
await thumbnailPlayers.dispose(p).timeout(deadline);
} catch (_) {}
}
}
@@ -147,19 +152,10 @@ Future<Thumbnail?> _generate(
String out,
bool Function() cancelled,
) async {
final player = Player(
configuration: const PlayerConfiguration(
vo: 'null',
muted: true,
osc: false,
libass: false,
title: 'thumbnail',
bufferSize: 4 << 20,
),
);
final player = await thumbnailPlayers.create(PlayerRole.thumbnail);
_players.add(player);
try {
final mpv = player.platform as NativePlayer;
final mpv = player;
for (final (k, v) in [
('ao', 'null'),
('aid', 'no'),
@@ -176,7 +172,7 @@ Future<Thumbnail?> _generate(
]) {
await mpv.setProperty(k, v);
}
await player.open(Media(path), play: false);
await player.open(path);
final target = File(out);
final deadline = DateTime.now().add(_timeout);
while (DateTime.now().isBefore(deadline)) {
@@ -199,6 +195,6 @@ Future<Thumbnail?> _generate(
return null;
} finally {
// [cancelAllThumbnails] may have taken it already.
if (_players.remove(player)) await player.dispose();
if (_players.remove(player)) await thumbnailPlayers.dispose(player);
}
}
@@ -47,6 +47,8 @@ Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = fa
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
_step('sounds', graph.sfx.dispose(), const Duration(milliseconds: 500)),
]);
// The host owns the disposal order: anything still alive goes now.
await _step('all players', graph.ports.players.disposeAll(), const Duration(milliseconds: 1500));
// Windows: media_kit_video frees a player's video output on a thread of
// its own after dispose returns; it must be done before the engine
// stops, or the plugin's teardown waits on it for good.
@@ -4,6 +4,7 @@ import 'desktop_folders.dart';
import 'desktop_notifications.dart';
import 'desktop_shell.dart';
import 'desktop_urls.dart';
import 'media_kit_host.dart';
/// The desktop's [Ports].
Ports desktopPorts() {
@@ -14,5 +15,6 @@ Ports desktopPorts() {
folders: const DesktopFolders(urls),
links: AppYtDlp.instance,
notifications: DesktopNotifications(),
players: MediaKitHost(),
);
}
@@ -0,0 +1,502 @@
import 'dart:async';
import 'dart:ffi' show Int8;
import 'dart:io';
import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc;
import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals.dart';
import 'package:sync_relay_core/core.dart' show LogThrottle, createLogger;
import '../../state/settings.dart';
import '../ports/player_host.dart';
// The desktop's players: media_kit over libmpv. Everything mpv-specific
// that is not about one media lives here: the cache and HTTP pins, hwdec,
// mpv's log level (media_kit has no API for it: `mpv_request_log_messages`
// on its handle), the render-mode probe and its diagnostics, mpv's log into
// the app log, and the order players are torn down in.
/// Read-ahead target: about two minutes rides out short outages and makes
/// skipping an intro instant.
const readAheadSecs = 150;
/// Forward demuxer cache of a session player. Holds [readAheadSecs] up to
/// ~14 Mbit/s (a 1.4 GB / 24 min file is ~8); higher bitrates get less
/// time. In RAM: `cache-on-disk=no` (media_kit sets it on without a
/// cache-dir, and mpv warned "Failed to create file cache." per open).
const sessionAheadBytes = 256 * 1024 * 1024;
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
/// seek back without holding another 256 MiB.
const sessionBackBytes = 64 * 1024 * 1024;
/// The preload's forward cache: its first seconds and the container's
/// index, at little memory. Raised to [sessionAheadBytes] when promoted.
const preloadAheadBytes = 48 * 1024 * 1024;
const preloadBackBytes = 8 * 1024 * 1024;
/// mpv's `volume-max` (its default is 130): the volume controls go this
/// far, past 100 % amplified.
const mpvMaxVolume = 150;
/// mpv's log level for session players. media_kit drains mpv's log on the
/// UI thread one line at a time, so a chatty level (ffmpeg retrying a
/// refused request logs every attempt) can starve the UI: warnings and
/// errors only, except during the render-mode probe, which needs mpv's
/// verbose GL init lines.
const sessionLogLevel = 'warn';
/// ffmpeg's HTTP options (mpv `stream-lavf-o`, comma form): reconnect after
/// a dropped connection, also on a streamed (growing) relay response, and a
/// server error (5xx) while connecting; a refused request (4xx) fails at
/// once. At most 3 tries per read, 4 s apart at most.
const streamLavfOptions = 'reconnect_streamed=1,reconnect_on_http_error=5xx,'
'reconnect_delay_max=4,reconnect_max_retries=3';
/// mpv echoes numbers in its own format (`150` reads back `150.000000`).
bool pinMatches(String wanted, String got) {
if (wanted == got) return true;
final a = double.tryParse(wanted), b = double.tryParse(got);
return a != null && b != null && a == b;
}
/// The `VideoController` behind [video], for the `Video` widget (the only
/// media_kit use outside this file).
VideoController videoControllerOf(HostedVideo video) => (video as _MkVideo).controller;
final _mpvLog = createLogger('mpv');
class MediaKitHost implements PlayerHost {
/// The hwdec setting ([AppSettings.hwdec] by default).
final String? Function() _hwdecSetting;
MediaKitHost({String? Function()? hwdecSetting})
: _hwdecSetting = hwdecSetting ?? (() => AppSettings.instance.hwdec.peek());
final _live = <_MkPlayer>{};
final _renderMode = signal(RenderMode.unknown);
final _http = StreamController<String>.broadcast();
final _throttle = LogThrottle(burst: 5);
Future<void> _retiring = Future.value();
var _closing = false;
@override
final List<String> pinMismatches = [];
@override
void init() => MediaKit.ensureInitialized();
@override
ReadonlySignal<RenderMode> get renderMode => _renderMode;
@override
Stream<String> get httpErrors => _http.stream;
@override
int get live => _live.length;
@override
Future<void> get retiring => _retiring;
@override
String? get hwdec {
final env = Platform.environment['SYNC_RELAY_HWDEC'];
if (env != null && env.isNotEmpty) return env;
return _hwdecSetting();
}
@override
Future<HostedPlayer> create(PlayerRole role, {bool muted = false}) async {
if (_closing) throw StateError('closing: no new players');
final mk = _SyncPlayer(
configuration: switch (role) {
PlayerRole.session => PlayerConfiguration(
bufferSize: sessionAheadBytes,
libass: true,
vo: Platform.environment['SYNC_RELAY_VO'],
logLevel: MPVLogLevel.warn),
PlayerRole.preload => PlayerConfiguration(
bufferSize: preloadAheadBytes,
libass: true,
vo: Platform.environment['SYNC_RELAY_VO'],
logLevel: MPVLogLevel.error),
PlayerRole.thumbnail => const PlayerConfiguration(
vo: 'null', muted: true, osc: false, libass: false, title: 'thumbnail', bufferSize: 4 << 20),
});
final p = _MkPlayer(this, mk, role);
_live.add(p);
if (role != PlayerRole.thumbnail) {
p._logSub = p.logs.listen((l) => _route(p, l));
final mismatches = <String>[];
await _pinCache(p, role, mismatches);
if (await p.pin('volume-max', '$mpvMaxVolume') case final m?) mismatches.add(m);
if (muted || role == PlayerRole.preload) {
if (await p.pin('mute', 'yes') case final m?) mismatches.add(m);
}
if (role == PlayerRole.session) {
pinMismatches
..clear()
..addAll(mismatches);
}
}
return p;
}
@override
Future<void> promote(HostedPlayer p) async {
final mk = p as _MkPlayer;
mk._role = PlayerRole.session;
mk.setLogLevel(sessionLogLevel);
final mismatches = <String>[];
if (await p.pin('mute', 'no') case final m?) mismatches.add(m);
await _pinCache(mk, PlayerRole.session, mismatches);
pinMismatches
..clear()
..addAll(mismatches);
}
Future<void> _pinCache(_MkPlayer p, PlayerRole role, List<String> out) async {
final session = role == PlayerRole.session;
for (final (k, v) in [
('demuxer-readahead-secs', '$readAheadSecs'),
('cache-secs', '$readAheadSecs'),
('demuxer-max-bytes', '${session ? sessionAheadBytes : preloadAheadBytes}'),
('demuxer-max-back-bytes', '${session ? sessionBackBytes : preloadBackBytes}'),
('cache-on-disk', 'no'),
if (hwdec case final h?) ('hwdec', h),
('stream-lavf-o', streamLavfOptions),
// Pages are resolved by the app's own yt-dlp (the media pipeline):
// mpv's Lua hook never runs one.
('ytdl', 'no'),
]) {
if (await p.pin(k, v) case final m?) out.add(m);
}
}
@override
HostedVideo video(HostedPlayer player, {bool probe = false}) {
final p = player as _MkPlayer;
if (probe) {
_logRenderEnv();
p.setLogLevel('v'); // back to the session level at the probe's end
}
// media_kit's video output sets hwdec itself on attach ('auto' unless
// configured), which used to override SYNC_RELAY_HWDEC.
final controller = VideoController(p.mk, configuration: VideoControllerConfiguration(hwdec: hwdec));
final v = _MkVideo(p, controller);
if (probe) unawaited(_probeRender(p, v));
return v;
}
// ------------------------------------------------------------ the log
// mpv's warnings and errors to the app log, a few per kind at a time (a
// refused request makes ffmpeg retry and warn on every try); ffmpeg's
// HTTP status to [httpErrors]; the GL init lines while probing.
void _route(_MkPlayer p, PlayerLogLine log) {
if (log.prefix == 'ffmpeg') {
final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text);
if (http != null) _http.add(http[0]!.trim());
}
if (log.prefix == 'libmpv_render') {
if (_glInfo.hasMatch(log.text)) _mpvLog.info('render: [libmpv_render] ${log.text.trim()}');
if (log.text.contains('suspected software renderer')) {
_probeMode = RenderMode.softwareGl;
_renderMode.value = RenderMode.softwareGl;
} else if (log.text.startsWith('GL_VERSION') && p._probing && _probeMode == RenderMode.unknown) {
_probeMode = RenderMode.gpu;
_renderMode.value = RenderMode.gpu;
}
}
final level = log.level;
if (level != 'warn' && !log.isError) return;
final text = log.text.trim();
if (text.isEmpty) return;
final who = p.role == PlayerRole.preload ? 'preload ' : '';
final pass = _throttle.allow('$who${log.prefix} $level ${text.length > 40 ? text.substring(0, 40) : text}');
if (pass == null) return;
final line = '$pass$who[${log.prefix}] $text';
level == 'warn' ? _mpvLog.info(line) : _mpvLog.warning(line);
}
// mpv's GL init lines (`GL_VENDOR='...'`, `Detected desktop OpenGL 4.6.`).
static final _glInfo =
RegExp(r'^\s*(GL_(VENDOR|RENDERER|VERSION|SHADING_LANGUAGE_VERSION)=|Detected (GLES|desktop OpenGL))');
// Once per app start: what decides how media_kit and mpv render (the
// display server, Flutter's and Mesa's overrides). Not secrets.
static bool _renderEnvLogged = false;
static void _logRenderEnv() {
if (_renderEnvLogged || !Platform.isLinux) return;
_renderEnvLogged = true;
const names = [
'XDG_SESSION_TYPE',
'WAYLAND_DISPLAY',
'DISPLAY',
'GDK_BACKEND',
'FLUTTER_LINUX_RENDERER',
'LIBGL_ALWAYS_SOFTWARE',
'__EGL_VENDOR_LIBRARY_FILENAMES',
];
final env = Platform.environment;
_mpvLog.info('render env: ${[for (final n in names) '$n=${env[n] ?? '-'}'].join(' ')}');
}
RenderMode _probeMode = RenderMode.unknown;
// media_kit's Linux/Windows video output creates mpv's render context
// while the controller is created: OpenGL when it can, else the software
// renderer, which never logs GL init (and ignores shaders). No GL line
// within 1.5 s of the output being up means software.
Future<void> _probeRender(_MkPlayer p, _MkVideo v) async {
p._probing = true;
_probeMode = RenderMode.unknown;
try {
await v.controller.platform.future;
await Future.delayed(const Duration(milliseconds: 1500));
} catch (_) {
return;
} finally {
p._probing = false;
if (!p.disposed) p.setLogLevel(sessionLogLevel);
}
if (p.disposed) return;
final mode = _probeMode;
if (mode != RenderMode.unknown) {
_renderMode.value = mode;
_mpvLog.info('render: ${mode.name} (${mode == RenderMode.gpu ? 'OpenGL on the GPU' : 'OpenGL on the CPU'})');
return;
}
_renderMode.value = RenderMode.software;
_mpvLog.info("render: software (no GL init logged: media_kit's own fallback; "
'its reason is on stderr, "media_kit: VideoOutput: ..." when run from a terminal)');
}
// Once per player, after its first frame: the decoder mpv picked; the
// first time per app start also mpv's and ffmpeg's versions.
static bool _versionsLogged = false;
Future<void> _logFirstFrame(_MkPlayer p) async {
try {
final current = await p.getProperty('hwdec-current');
final wanted = await p.getProperty('hwdec');
_mpvLog.info('render: first frame ${p.state.width}x${p.state.height}, '
'hwdec-current=${current.isEmpty ? 'no' : current} (hwdec=$wanted), mode ${_renderMode.peek().name}');
if (!_versionsLogged) {
_versionsLogged = true;
_mpvLog.info('render: ${await versions(p)}');
}
} catch (e) {
debugPrint('first frame info: $e');
}
}
@override
Future<String?> versions(HostedPlayer player) async {
try {
return '${await player.getProperty('mpv-version')}, ffmpeg ${await player.getProperty('ffmpeg-version')}';
} catch (_) {
return null;
}
}
// --------------------------------------------------------- tear-down
@override
Future<void> retire(HostedPlayer player) {
final p = player as _MkPlayer;
p._retired = true;
unawaited(p.mk.pause().catchError((_) {}));
return _retiring = () async {
await _settleUi();
await dispose(p);
}();
}
@override
Future<void> dispose(HostedPlayer player) async {
final p = player as _MkPlayer;
if (!_live.remove(p)) return;
p._retired = true;
await p._logSub?.cancel();
try {
await p.mk.dispose();
} catch (_) {}
}
@override
Future<void> disposeAll({Duration each = const Duration(milliseconds: 1500)}) async {
_closing = true;
await Future.wait([
for (final p in _live.toList()) dispose(p).timeout(each).catchError((Object _) {}),
]);
_closing = false;
}
// Two frames: one may already be mid-build when the view drops the
// output. Then the length of a route pop (the fullscreen route holds its
// own `Video` and leaves animated). Bounded, since a hidden window
// renders no frames; headless tests have no binding and skip it all.
static Future<void> _settleUi() async {
try {
for (var i = 0; i < 2; i++) {
await WidgetsBinding.instance.endOfFrame.timeout(const Duration(milliseconds: 300));
}
await Future.delayed(const Duration(milliseconds: 350));
} catch (_) {}
}
}
/// A [Player] whose public [seek] belongs to the user. media_kit's controls
/// call `player.seek`; with [onUserSeek] set those calls become explicit
/// events for the sync layer instead of moving mpv behind its back.
class _SyncPlayer extends Player {
_SyncPlayer({super.configuration});
void Function(Duration position)? onUserSeek;
@override
Future<void> seek(Duration duration) async {
final hook = onUserSeek;
if (hook == null) return super.seek(duration);
hook(duration);
}
Future<void> syncSeek(Duration duration) => super.seek(duration);
}
class _MkPlayer implements HostedPlayer {
final MediaKitHost host;
final _SyncPlayer mk;
PlayerRole _role;
_MkPlayer(this.host, this.mk, this._role) {
_firstFrame = mk.stream.width.listen((w) {
if (w == null || w <= 0 || _role == PlayerRole.thumbnail) return;
unawaited(_firstFrame?.cancel());
unawaited(host._logFirstFrame(this));
});
}
StreamSubscription<PlayerLogLine>? _logSub;
StreamSubscription<int?>? _firstFrame;
bool _probing = false;
bool _retired = false;
dynamic get _mpv => mk.platform;
@override
PlayerRole get role => _role;
@override
bool get disposed => _retired;
@override
HostedPlayerState get state {
final s = mk.state;
return HostedPlayerState(
position: s.position,
duration: s.duration,
buffer: s.buffer,
volume: s.volume,
playing: s.playing,
completed: s.completed,
width: s.width,
height: s.height,
);
}
@override
Stream<Duration> get positionStream => mk.stream.position;
@override
Stream<Duration> get durationStream => mk.stream.duration;
@override
Stream<Duration> get bufferStream => mk.stream.buffer;
@override
Stream<double> get volumeStream => mk.stream.volume;
@override
Stream<bool> get completedStream => mk.stream.completed;
@override
Stream<int?> get widthStream => mk.stream.width;
@override
Stream<PlayerLogLine> get logs => mk.stream.log.map((l) => PlayerLogLine(l.prefix, l.level, l.text));
@override
void Function(Duration position)? get onUserSeek => mk.onUserSeek;
@override
set onUserSeek(void Function(Duration position)? f) => mk.onUserSeek = f;
@override
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false}) =>
// An empty map, not null: media_kit caches headers per URI.
mk.open(Media(url, httpHeaders: headers), play: play);
@override
Future<void> play() => mk.play();
@override
Future<void> pause() => mk.pause();
@override
Future<void> stop() => mk.stop();
@override
Future<void> seek(Duration position) => mk.seek(position);
@override
Future<void> syncSeek(Duration position) => mk.syncSeek(position);
@override
Future<void> setRate(double rate) => mk.setRate(rate);
@override
Future<void> setVolume(double volume) => mk.setVolume(volume);
// media_kit has no public property API: a guarded dynamic call on
// `player.platform` (its NativePlayer).
@override
Future<void> setProperty(String name, String value) async => await _mpv?.setProperty(name, value);
@override
Future<String> getProperty(String name) async => '${await _mpv?.getProperty(name)}';
@override
Future<void> observeProperty(String name, Future<void> Function(String value) onChange) async =>
await _mpv?.observeProperty(name, onChange);
@override
Future<void> command(List<String> args) async => await _mpv?.command(args);
@override
Future<String?> pin(String name, String value) async {
try {
await setProperty(name, value);
final got = await getProperty(name);
if (pinMatches(value, got)) return null;
debugPrint('pin $name=$value not applied, mpv has "$got"');
return '$name: $value -> $got';
} catch (e) {
debugPrint('pin $name=$value failed: $e');
return '$name: $value -> error $e';
}
}
// mpv_request_log_messages on media_kit's handle.
@override
void setLogLevel(String level) {
try {
final dynamic platform = mk.platform;
final name = level.toNativeUtf8();
try {
platform.mpv.mpv_request_log_messages(platform.ctx, name.cast<Int8>());
} finally {
malloc.free(name);
}
} catch (e) {
debugPrint('mpv log level $level not set: $e');
}
}
}
class _MkVideo implements HostedVideo {
@override
final _MkPlayer player;
final VideoController controller;
_MkVideo(this.player, this.controller);
@override
Future<void> get ready => controller.platform.future;
}
@@ -10,8 +10,10 @@ abstract interface class LinkResolver {
/// Gets it ready in the background (download on first run, updates).
void warmUp();
/// mpv's `ytdl-raw-options` (sub languages, cookies).
Future<String> mpvRawOptions();
/// This device opens web pages itself (the media plan's `resolve`
/// capability). Desktop: always (yt-dlp is downloaded when first
/// needed); a device without it gets pages passed on by someone else.
bool get resolvesLinks;
/// What [url] is, remembered for a while ([keep]); [fresh] asks again.
Future<LinkProbe> probe(String url, {Duration keep, bool fresh});
@@ -0,0 +1,192 @@
import 'dart:async';
import 'package:signals/signals.dart';
// The media players as the app sees them: mpv instances, their video
// outputs, how the video is rendered, mpv's log and the order players are
// torn down in. Desktop: media_kit over libmpv
// (`platform/desktop/media_kit_host.dart`); nothing else in the app
// touches media_kit except the `Video` widget glue.
/// How the video output renders (shaders need [gpu]).
enum RenderMode {
unknown,
/// OpenGL on a real GPU: shaders work.
gpu,
/// OpenGL on a software rasterizer (llvmpipe): shaders crawl.
softwareGl,
/// The video output's own software fallback: shaders are ignored.
software,
}
/// What a player is for: its cache, log level and log routing.
enum PlayerRole {
/// The session's player (and a fresh one per media): full cache, mpv log
/// at `warn` into the app log.
session,
/// The hidden player holding what plays next: small cache, muted, log
/// at `error`.
preload,
/// A poster frame for Files: no audio or video output, no cache, no log.
thumbnail,
}
/// One line of mpv's log.
class PlayerLogLine {
final String prefix;
final String level;
final String text;
const PlayerLogLine(this.prefix, this.level, this.text);
bool get isError => level == 'error' || level == 'fatal';
}
/// A snapshot of a player's state.
class HostedPlayerState {
final Duration position;
final Duration duration;
/// End of the cached range (mpv `demuxer-cache-time`).
final Duration buffer;
final double volume;
final bool playing;
/// At the end of the file.
final bool completed;
final int? width;
final int? height;
const HostedPlayerState({
this.position = Duration.zero,
this.duration = Duration.zero,
this.buffer = Duration.zero,
this.volume = 100,
this.playing = false,
this.completed = false,
this.width,
this.height,
});
}
/// One mpv player. Each media gets its own (see ARCHITECTURE "Player
/// lifecycle"); [PlayerHost.retire] / [PlayerHost.disposeAll] end it.
abstract interface class HostedPlayer {
PlayerRole get role;
HostedPlayerState get state;
Stream<Duration> get positionStream;
Stream<Duration> get durationStream;
Stream<Duration> get bufferStream;
Stream<double> get volumeStream;
Stream<bool> get completedStream;
Stream<int?> get widthStream;
/// mpv's log lines at the player's level.
Stream<PlayerLogLine> get logs;
/// The player is gone (disposed or being disposed): commands do nothing.
bool get disposed;
/// Seeks from the video widget's own controls land here instead of in
/// mpv when set ([seek] goes through it too); [syncSeek] always moves
/// mpv.
void Function(Duration position)? onUserSeek;
/// Opens [url] (with [headers] on every request), paused unless [play].
Future<void> open(String url, {Map<String, String> headers = const {}, bool play = false});
Future<void> play();
Future<void> pause();
Future<void> stop();
/// A user seek: through [onUserSeek] when set, else [syncSeek].
Future<void> seek(Duration position);
/// Moves mpv (sync corrections, scrub preview).
Future<void> syncSeek(Duration position);
Future<void> setRate(double rate);
Future<void> setVolume(double volume);
Future<void> setProperty(String name, String value);
Future<String> getProperty(String name);
Future<void> observeProperty(String name, Future<void> Function(String value) onChange);
Future<void> command(List<String> args);
/// Sets [name] and reads it back: null when mpv took [value], else
/// `name: value -> got` (mpv ignores invalid values silently).
Future<String?> pin(String name, String value);
/// mpv's log level for this player (`warn`, `v`, ...).
void setLogLevel(String level);
}
/// A player's video output (a texture the `Video` widget shows).
abstract interface class HostedVideo {
HostedPlayer get player;
/// Completes once the output is up (its texture registered).
Future<void> get ready;
}
/// Creates, owns and disposes the app's players. One per app (the
/// composition root's `Ports`); tests make their own.
abstract interface class PlayerHost {
/// Once at start, before any player.
void init();
/// A new player for [role], set up before it is returned to the caller's
/// first await: cache, pins ([pinMismatches]), hwdec, log level.
Future<HostedPlayer> create(PlayerRole role, {bool muted = false});
/// [player] (a preload) becomes a session player: unmuted, the full
/// cache pins, the session's log level.
Future<void> promote(HostedPlayer player);
/// [player]'s video output, with the hwdec policy. [probe]: listen to
/// mpv's render init to learn [renderMode] (the first output of an app
/// start).
HostedVideo video(HostedPlayer player, {bool probe = false});
/// How video renders, as last learned (kept after leaving).
ReadonlySignal<RenderMode> get renderMode;
/// mpv `hwdec` for new players: env `SYNC_RELAY_HWDEC` wins over the
/// setting (Windows' auto is `auto-copy`).
String? get hwdec;
/// Pins that did not take on the last session player created.
List<String> get pinMismatches;
/// ffmpeg's HTTP errors (`HTTP error 403 Forbidden`), from whichever
/// player ffmpeg logs through (one per process).
Stream<String> get httpErrors;
/// `mpv-version` and `ffmpeg-version` of [player], for diagnostics.
Future<String?> versions(HostedPlayer player);
/// Ends [player] once the view let go of its texture (two frames and a
/// route pop); at most one retiring player lingers, a new [create] of a
/// session player waits for it ([retiring]).
Future<void> retire(HostedPlayer player);
/// The last retire, until done.
Future<void> get retiring;
/// Disposes [player] at once (no view showed it).
Future<void> dispose(HostedPlayer player);
/// App close: every live player goes, each bounded by [each], before
/// the engine does (a live mpv calls back into a dead isolate).
Future<void> disposeAll({Duration each});
/// Live players, for tests and the close.
int get live;
}
/// [log] says the media could not be opened: before a [duration] is known,
/// mpv's fatal "nothing to play" or a demuxer error.
bool mpvLoadFailed(PlayerLogLine log, Duration duration) {
return duration == Duration.zero &&
((log.prefix == 'cplayer' && log.level == 'fatal') || (log.prefix == 'demux' && log.level == 'error'));
}
@@ -1,5 +1,6 @@
import 'folder_access.dart';
import 'link_resolver.dart';
import 'player_host.dart';
import 'shell_port.dart';
import 'system_notifications.dart';
import 'url_opener.dart';
@@ -7,6 +8,7 @@ import 'url_opener.dart';
export 'app_dirs.dart';
export 'folder_access.dart';
export 'link_resolver.dart';
export 'player_host.dart';
export 'shell_port.dart';
export 'system_notifications.dart';
export 'url_opener.dart';
@@ -19,11 +21,14 @@ class Ports {
final FolderAccess folders;
final LinkResolver links;
final SystemNotifications notifications;
/// The media players (mpv): created, rendered and disposed here.
final PlayerHost players;
const Ports({
required this.shell,
required this.urls,
required this.folders,
required this.links,
required this.notifications,
required this.players,
});
}
+70
View File
@@ -0,0 +1,70 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart' show debugPrint;
import '../app/app_graph.dart';
import 'app_shutdown.dart';
import 'ports/player_host.dart';
// The close smoke run (CI, `tool/smoke_windows.ps1` / `tool/smoke_linux.sh`):
// env `STARSYNC_SMOKE=<media file>` makes the app, once up, open that file
// in a session player with a video output through the media pipeline,
// play a second and close the normal way ([shutDownApp]). Progress goes to
// `STARSYNC_SMOKE_OUT` (one line per step: `start`, `opened <ms> render=<mode>`,
// `closing`, or `skip <why>` / `fail <why>`), so the script can tell a
// runner that cannot show a window (skip) from a close that hangs or
// leaves a process behind (fail).
/// The smoke run's media, or null for a normal start.
String? get smokeMedia {
final m = Platform.environment['STARSYNC_SMOKE'];
return m == null || m.isEmpty ? null : m;
}
Future<void> runSmoke(AppGraph graph, String media) async {
final outPath = Platform.environment['STARSYNC_SMOKE_OUT'];
void step(String line) {
debugPrint('smoke: $line'); // into the app log too
if (outPath == null || outPath.isEmpty) return;
try {
File(outPath).writeAsStringSync('$line\n', mode: FileMode.append, flush: true);
} catch (_) {}
}
step('start');
final sw = Stopwatch()..start();
final pipe = graph.coordinator.media;
try {
if (!await File(media).exists()) {
step('fail no media file at $media');
} else {
await pipe.start();
await pipe.playback.setVolume(0);
// The video output the session view would make: the render path
// (EGL / ANGLE) is what breaks on some machines.
final video = pipe.playback.video;
await video.ready.timeout(const Duration(seconds: 15));
final ok = await pipe.open(media);
final end = DateTime.now().add(const Duration(seconds: 20));
while (ok && pipe.playback.durationSecs <= 0 && DateTime.now().isBefore(end)) {
await Future.delayed(const Duration(milliseconds: 100));
}
if (pipe.playback.durationSecs <= 0) {
step('fail the media did not open in 20 s');
} else {
await pipe.playback.player.play();
await Future.delayed(const Duration(seconds: 1));
final mode = graph.ports.players.renderMode.peek();
step('opened ${sw.elapsedMilliseconds} render=${mode.name}');
if (mode == RenderMode.unknown) await Future.delayed(const Duration(seconds: 2));
}
}
} on TimeoutException {
step('skip the video output did not come up (no display for this runner?)');
} catch (e) {
step('fail $e');
}
step('closing');
await shutDownApp();
}
File diff suppressed because it is too large. Load diff
+2 -21
View File
@@ -6,15 +6,6 @@ import 'package:sync_relay_core/core.dart';
import '../platform/ports/link_resolver.dart';
/// mpv's `ytdl-format` for links every player opens itself.
const ytdlFormat = 'bv*[height<=1080]+ba/b';
/// yt-dlp's `--sub-langs` for mpv's hook (its default is `all`), without
/// Bilibili's danmaku: an XML comment feed served raw-deflate, which
/// ffmpeg fails to inflate on every read (a warning each time) while the
/// video waits on it.
const ytdlSubLangs = 'all,-danmaku';
/// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with
/// the hosted server. [probe] remembers what pasted links are for a while,
/// so the Link preview and the swap ask once.
@@ -36,20 +27,10 @@ class AppYtDlp implements LinkResolver {
/// Gets yt-dlp ready in the background (download on first run, a daily
/// update check afterwards).
@override
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
bool get resolvesLinks => true;
/// mpv's `ytdl-raw-options`: [ytdlSubLangs] and the cookies in the
/// yt-dlp folder (see [YtDlpCookies]).
@override
Future<String> mpvRawOptions() async {
const subs = 'sub-langs=%${ytdlSubLangs.length}%$ytdlSubLangs';
try {
final cookies = (await ytDlp).cookies.mpvRawOptions();
return cookies.isEmpty ? subs : '$subs,$cookies';
} catch (_) {
return subs;
}
}
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
final _probes = <String, ({DateTime at, DateTime until, Future<LinkProbe> f})>{};
static const _probeTtl = Duration(minutes: 10);
@@ -550,7 +550,7 @@ class QueueController {
// The server's warm relay of the item, once bytes can flow: what
// everyone without their own copy preloads.
final relay = head.media.relay;
final warmUrl = relay != null && relay.live ? coord.warmRelayUrl(head, relay) : null;
final warmUrl = relay != null && relay.live ? coord.media.warmRelayUrl(head, relay) : null;
switch (head.how) {
case QueueHow.jellyfin:
final ref = head.media.jellyfin;
@@ -589,7 +589,7 @@ class QueueController {
/// puts it up (`AppYtDlp.probe`, kept while it stays first), a Jellyfin
/// item resolved with this app's account
/// ([SessionCoordinator.warmJellyfin]), and then opened in the hidden
/// preload player ([SessionCoordinator.preloadMedia]) once the media
/// preload player ([MediaPipeline.prepare]) once the media
/// playing has started. Done as soon as it is first, again before that
/// runs out, dropped when it is no longer first.
void _warm(StateBroadcast? r) {
@@ -617,7 +617,7 @@ class QueueController {
/// Feeds the server's warm relay of Up next's first item from here when
/// it names this member as its source (a link passed on, a file), or a
/// Jellyfin item this app puts up with its own account when the server
/// asks for a source; see [SessionCoordinator.feedWarm]. The server
/// asks for a source; see [RelayFeeder.feedWarm]. The server
/// drops the relay (and with it the source) once the item is no longer
/// first.
void _feedWarm(StateBroadcast? r) {
@@ -633,7 +633,7 @@ class QueueController {
final pick = _local[head.key]?.pick;
if (pick is FileMedia) {
_fedWarm = key;
coord.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
coord.media.feeder.feedWarm(head.id, RelayMedia.single(FileRelayBytes(File(pick.path))));
} else if (pick is LinkMedia) {
_fedWarm = key;
unawaited(() async {
@@ -642,7 +642,7 @@ class QueueController {
final probe = await coord.ytDlp.probe(pick.url, keep: _warmKeep).timeout(const Duration(seconds: 60));
final y = await coord.ytDlp.ytDlp;
if (_fedWarm != key) return;
coord.feedWarm(head.id, LinkRelayMedia(Uri.parse(pick.url), ytDlp: y, probe: probe));
coord.media.feeder.feedWarm(head.id, LinkRelayMedia(Uri.parse(pick.url), ytDlp: y, probe: probe));
} catch (e) {
debugPrint('queue: feeding ${head.id}: $e');
}
@@ -656,7 +656,7 @@ class QueueController {
final w = coord.warmJellyfin;
if (w == null || !sameJellyfinItem(w.ref, ref)) return;
_fedWarm = key;
coord.feedWarm(
coord.media.feeder.feedWarm(
head.id, RelayMedia.single(HttpRelayBytes(Uri.parse(w.plan.url), headers: w.plan.headers)));
case QueueHow.direct:
case QueueHow.server:
@@ -710,7 +710,7 @@ class QueueController {
// A refresh opens a link again (a page's stream URLs expire); the
// same Jellyfin stream stays as it is.
_preloadFresh = fresh && ok && t.ref == null && open != null;
final waits = ok && open != null && coord.playback.preloadEnabled;
final waits = ok && open != null && coord.media.preloadEnabled;
warm.value = (id: t.id, state: !ok ? WarmState.failed : (waits ? WarmState.warming : WarmState.ready));
_syncPreload(coord.serverState.peek());
_warmTimer = Timer(ok ? _warmRefresh : _warmRetry, () => _resolveWarm(t, fresh: true));
@@ -730,25 +730,25 @@ class QueueController {
final out = coord.selfAway.peek();
if (!out && r.media != null && coord.settledMediaId.peek() != r.mediaId) return;
final want = out ? null : _preloadWant;
final playback = coord.playback;
final media = coord.media;
if (want == null) {
_preloadKey = '';
_preloadGen++;
if (playback.preloadUrl != null) unawaited(playback.dropPreload());
if (media.preloadUrl != null) unawaited(media.dropPrepared());
return;
}
if (!playback.preloadEnabled) return;
if (!media.preloadEnabled) return;
final key = '${want.url}|${want.headers}';
final fresh = _preloadFresh;
// Asked for already: in flight, there, or failed (tried again with the
// next refresh). One that went away (Reload video) is asked again.
if (!fresh && key == _preloadKey && (_preloadBusy || !_preloadOk || playback.preloadUrl == want.url)) return;
if (!fresh && key == _preloadKey && (_preloadBusy || !_preloadOk || media.preloadUrl == want.url)) return;
_preloadFresh = false;
_preloadKey = key;
_preloadBusy = true;
final gen = ++_preloadGen;
final id = warm.peek()?.id;
unawaited(coord.preloadMedia(want.url, headers: want.headers, fresh: fresh).then((ok) {
unawaited(coord.media.prepare(want.url, headers: want.headers, fresh: fresh).then((ok) {
if (gen != _preloadGen) return;
_preloadBusy = false;
_preloadOk = ok;
@@ -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();
}
}
+14
View File
@@ -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) {
@@ -11,6 +11,7 @@ import '../controls/controls_probe.dart';
import '../controls/model/controls_model.dart';
import '../controls/session_controls.dart';
import '../controls/volume.dart';
import '../platform/desktop/media_kit_host.dart' show videoControllerOf;
import '../queue/queue_ui.dart';
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
import '../screens/session/jellyfin_widgets.dart';
@@ -321,7 +322,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
Widget _video(VideoControlsBuilder? controls) => Video(
key: _videoKey,
controller: _coord.playback.video,
controller: videoControllerOf(_coord.playback.video),
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
controls: controls,
);
@@ -653,7 +654,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
],
);
}),
_MiniSeekBar(onSeek: (d) => playback.player.seek(d)),
_MiniSeekBar(onSeek: playback.userSeek),
],
),
),
-21
View File
@@ -1,26 +1,5 @@
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
/// A [Player] whose public [seek] belongs to the user. media_kit's seek bar
/// (and anything else in the video controls) calls `player.seek`; with
/// [onUserSeek] set those calls become explicit events for the sync layer
/// instead of moving mpv behind its back. Sync-driven seeks use [syncSeek].
class SyncPlayer extends Player {
SyncPlayer({super.configuration});
void Function(Duration position)? onUserSeek;
@override
Future<void> seek(Duration duration) async {
final hook = onUserSeek;
if (hook == null) return super.seek(duration);
hook(duration);
}
/// Seek mpv directly (preview while scrubbing, server corrections).
Future<void> syncSeek(Duration duration) => super.seek(duration);
}
/// Turns a burst of user seeks (a seek bar drag fires one per pointer move,
/// arrow keys one per press) into one `seek` intent, and tells the
/// coordinator when server seeks must not touch the player:
+152
View File
@@ -0,0 +1,152 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/session/media_plan.dart';
import 'package:starsync/src/session/relay_feeder.dart';
import 'package:starsync/src/session/status.dart';
// The plan step of the media pipeline as a table: the state, what this
// member serves and its capabilities -> where this app reads the media
// from.
const _server = ServerAddress('h', 9, 'TOK');
const _jf = JellyfinRef(serverId: 'S1', itemId: 'I1', title: 'Ep');
MemberView _m(String id, {Presence presence = Presence.watching, Map<String, bool> jf = const {}, ClientCaps caps = const ClientCaps()}) =>
MemberView(id: id, name: id.toUpperCase(), ready: false, inSet: true, buffered: 0, presence: presence, jf: jf, caps: caps);
StateBroadcast _state(Map<String, Object?>? media,
{List<MemberView>? members, bool relay = false, HoldInfo? hold, String transport = 'held'}) =>
StateBroadcast(
eventId: 1,
transport: transport,
position: 0,
serverTime: DateTime.utc(2026),
mediaId: 7,
media: media,
members: members ?? [_m('m1'), _m('m2')],
relay: relay,
hold: hold,
);
Map<String, Object?> _relay(MediaRelay r) => r.toJson();
SourcePlan _plan(StateBroadcast r,
{String self = 'm1', MediaOrigin? origin, bool resolve = true, Set<String> accounts = const {}}) =>
planMedia(PlanInput(
state: r,
selfId: self,
caps: ClientCaps(source: true, resolve: resolve),
server: _server,
origin: origin,
ownJellyfin: accounts.contains,
));
void main() {
group('plan: state + origin + caps -> source', () {
final probe = LinkProbe.file(Uri.parse('https://x/v.mkv'), 10);
final ytDlp = YtDlp('${Directory.systemTemp.path}/plan_no_ytdlp', enabled: false);
final rows = <(String, StateBroadcast, Map<String, Object?>, String)>[
('nothing up', _state(null), {}, 'NoSource'),
('your server\'s file', _state({'name': 'Ep 1.mkv', 'size': 5}), {}, 'ServerFile http://h:9/m/default/TOK/Ep%201.mkv'),
('a media file link', _state({'name': 'v', 'url': 'https://cdn/v.mkv'}), {}, 'DirectLink https://cdn/v.mkv page=false'),
('a page link', _state({'name': 'v', 'url': 'https://site/watch?v=1'}), {}, 'DirectLink https://site/watch?v=1 page=true'),
('a page link, no resolver here, m2 can',
_state({'name': 'v', 'url': 'https://site/watch?v=1'},
members: [_m('m1'), _m('m2', caps: const ClientCaps(resolve: true))]),
{'resolve': false},
"Unresolvable This device can't open web pages; ask M2 to pass it on (Through me)"),
('another member\'s relay, live',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm2', live: true))}), {},
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=false'),
('another member\'s relay, not live yet: wait',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm2'))}), {},
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=true'),
('its source left: open what the server has',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm9'))}), {},
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=false'),
('its source gone: open what the server has',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm2', sourceGone: true))}), {},
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=false'),
('the server fetches, not read yet',
_state({'name': 'p', 'relay': _relay(const MediaRelay(fetch: true))}), {},
'RelayedSource http://h:9/r/default/TOK/7/p waiting=true'),
('a warm relay promoted: its queue id in the URL',
_state({'name': 'p', 'relay': _relay(const MediaRelay(source: 'm2', live: true, queueId: 'q3'))}), {},
'RelayedSource http://h:9/r/default/TOK/q3/p waiting=false'),
('your own file, passed on',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm1'))}), {'origin': const FileOrigin('/v/f.mkv')},
'OwnFile /v/f.mkv'),
('your own link, passed on',
_state({'name': 'p', 'relay': _relay(const MediaRelay(source: 'm1', live: true))}),
{'origin': LinkOrigin('https://site/p', probe, ytDlp)},
'OwnLink https://site/p'),
('your relay, origin forgotten (a restart): read the server\'s buffer',
_state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm1', live: true))}), {},
'RelayedSource http://h:9/r/default/TOK/7/f.mkv waiting=false'),
('Jellyfin, own account',
_state({'name': 'Ep', 'jellyfin': _jf.toJson()}), {'accounts': {'S1'}},
'JellyfinSource own=true login=null member=null origin=false'),
('Jellyfin, the server\'s login',
_state({'name': 'Ep', 'jellyfin': _jf.toJson()}, relay: true), {},
'JellyfinSource own=false login=http://h:9/j/default/TOK/I1 member=null origin=false'),
('Jellyfin, a member passes it on',
_state({'name': 'Ep', 'jellyfin': _jf.toJson(), 'relay': _relay(const MediaRelay(source: 'm2'))}), {},
'JellyfinSource own=false login=null member=http://h:9/r/default/TOK/7/Ep origin=false'),
('Jellyfin, you put it up',
_state({'name': 'Ep', 'jellyfin': _jf.toJson(), 'relay': _relay(const MediaRelay(source: 'm1'))}),
{'accounts': {'S1'}, 'origin': const JellyfinOrigin(null)},
'JellyfinSource own=true login=null member=null origin=true'),
];
for (final (what, r, opts, want) in rows) {
test(what, () {
final got = _plan(r,
origin: opts['origin'] as MediaOrigin?,
resolve: opts['resolve'] as bool? ?? true,
accounts: opts['accounts'] as Set<String>? ?? const {});
expect(_describe(got), want);
});
}
});
test('a Jellyfin relay channel only when someone present needs it', () {
StateBroadcast with_(List<MemberView> ms, {bool relay = false}) =>
_state({'name': 'Ep', 'jellyfin': _jf.toJson()}, members: [_m('m1'), ...ms], relay: relay);
expect(jellyfinRelayNeeded(with_([_m('m2', jf: {'S1': true})]), _jf, 'm1'), isFalse, reason: 'everyone has an account');
expect(jellyfinRelayNeeded(with_([_m('m2', jf: {'S1': false})]), _jf, 'm1'), isTrue);
expect(jellyfinRelayNeeded(with_([_m('m2')]), _jf, 'm1'), isTrue, reason: 'no Jellyfin at all');
expect(jellyfinRelayNeeded(with_([_m('m2', caps: const ClientCaps(jellyfin: true))]), _jf, 'm1'), isFalse,
reason: 'its accounts are not reported yet: asked again with the next state');
expect(jellyfinRelayNeeded(with_([_m('m2')], relay: true), _jf, 'm1'), isFalse, reason: "the server's login passes it on");
expect(jellyfinRelayNeeded(with_([_m('m2', presence: Presence.away)]), _jf, 'm1'), isFalse, reason: 'sitting out');
expect(jellyfinRelayNeeded(with_([]), _jf, 'm1'), isFalse, reason: 'alone');
});
test("D6: held for the source: everyone's status waits for it by name", () {
final r = _state({'name': 'f.mkv', 'relay': _relay(const MediaRelay(source: 'm2', live: true, sourceGone: true))},
members: [
_m('m1'),
MemberView(id: 'm2', name: 'Bob', ready: true, inSet: true, buffered: 0, presence: Presence.gone, goneSince: DateTime.utc(2026)),
],
hold: const HoldInfo(reason: HoldReason.source, members: ['m1']));
for (final own in [false, true]) {
final line = statusOf(StatusFacts(state: r, selfId: 'm1', ownPlan: own, now: DateTime.utc(2026))).line!;
expect(line.text, 'Waiting for Bob');
expect(line.kind, StatusKind.waiting);
expect(line.level, StatusLevel.warn);
}
});
}
String _describe(SourcePlan p) => switch (p) {
NoSource() => 'NoSource',
ServerFile(:final url) => 'ServerFile $url',
DirectLink(:final url, :final page) => 'DirectLink $url page=$page',
Unresolvable() => 'Unresolvable ${p.reason}',
RelayedSource(:final url, :final waiting) => 'RelayedSource $url waiting=$waiting',
OwnFile(:final path) => 'OwnFile $path',
OwnLink(:final url) => 'OwnLink $url',
JellyfinSource(:final ownAccount, :final serverLogin, :final memberRelay, :final origin) =>
'JellyfinSource own=$ownAccount login=$serverLogin member=$memberRelay origin=${origin != null}',
};
@@ -0,0 +1,106 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/playback/yt_dlp_app.dart';
import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart';
// Commands that come while a media is switching (no player yet, or its
// media not loaded) wait and go to the new player once, so mpv never gets a
// seek it cannot run ("error running command seek", "event:5").
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
Future<String> _media() async {
final path = '$_tmp/media_switch_60s.mp4';
if (await File(path).exists()) return path;
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24',
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
'-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-movflags', '+faststart', path,
]);
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
return path;
}
Future<void> _poll(FutureOr<bool> Function() cond, String what,
{Duration timeout = const Duration(seconds: 30), Duration every = const Duration(milliseconds: 100)}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(every);
}
fail('timed out waiting for $what');
}
void main() {
test('commands during a switch wait and apply once to the new player, without mpv errors',
timeout: const Timeout(Duration(minutes: 2)), () async {
final file = await _media();
// A host that answers a media request only after 2 s (a slow CDN):
// the new player has nothing loaded for that long.
final slow = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
slow.listen((req) async {
final size = await File(file).length();
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mp4');
if (r.status == 206 || r.status == 200) await Future.delayed(const Duration(seconds: 2));
req.response.statusCode = r.status;
r.headers.forEach(req.response.headers.set);
try {
if (r.status == 200 || r.status == 206) {
await req.response.addStream(File(file).openRead(r.start, (r.end ?? size - 1) + 1));
}
await req.response.close();
} catch (_) {}
});
final server = SyncServer(const AppConfig(port: 0, secret: 'p15', name: 'p15'));
await server.start();
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
await acc.ensureLoaded(validate: false);
final c = SessionCoordinator(
jellyfin: acc,
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
ytDlp: AppYtDlp(YtDlp('$_tmp/media_switch_no_ytdlp', enabled: false)));
final errors = <String>[];
try {
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'p15', name: 'solo'));
await c.playback.setVolume(0);
final before = c.serverState.peek()?.mediaId ?? 0;
expect(await c.swapPicked(LinkMedia('http://127.0.0.1:${slow.port}/clip.mp4')), isNull);
// The switch: the state names the new media, the new player waits on
// the slow host.
await _poll(() => (c.serverState.peek()?.mediaId ?? 0) > before && c.media.openUrl != null, 'switching');
final p = c.playback.player;
final sub = p.logs.listen((l) {
if (l.text.contains('error running command') || l.text.contains('event:5')) errors.add(l.text);
});
expect(c.playback.switching, isTrue, reason: 'its media is not loaded yet');
final seeks = c.playback.seeksSent;
// The user skips ahead twice, changes the volume and readies, all
// while the media is still on its way.
c.seekTo(12);
c.seekTo(20);
await c.playback.setVolume(30);
c.toggleReady();
expect(c.playback.seeksSent, seeks, reason: 'nothing reached mpv yet');
await _poll(() => !c.playback.switching, 'loaded', timeout: const Duration(seconds: 20));
await _poll(() => c.playback.playing && c.playback.positionSecs > 20, 'playing from 20 s');
expect(c.playback.positionSecs, lessThan(23), reason: 'the latest seek, applied once it loaded');
expect(c.playback.seeksSent - seeks, 1, reason: 'the queued seeks went out as one');
expect(c.playback.player.state.volume, 30, reason: 'the volume went to the new player');
await sub.cancel();
expect(errors, isEmpty, reason: 'no command reached a player without its media');
} finally {
await c.dispose();
await server.stop();
await slow.close(force: true);
}
});
}
+39 -21
View File
@@ -1,30 +1,30 @@
// restructure: rewrite in step 5 (Media pipeline): preload and the warm
// relay become one pipeline; keep the timing per source.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/session_media.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
import 'package:starsync/src/playback/yt_dlp_app.dart';
import 'package:starsync/src/queue/queue_controller.dart' show WarmState;
import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/session/switch_trace.dart';
// How long an Up next switch takes, end to end, over real mpv and a real
// sync server: two Jellyfin account holders and a guest (through the
// server's /j login) watch an episode, then ⏭ puts up the next episode,
// then a direct link, then a link passed on "through" alice, then one the
// server fetches (both relayed: the guests preload them from the server's
// warm relay of the item). The fake Jellyfin and the link's host answer with a
// delay and a capped rate, like a server elsewhere. Prints each client's
// phases (SwitchTrace) and the wall time from ⏭ to the server playing.
// How long an Up next switch takes through the media pipeline, end to
// end, over real mpv and a real sync server: two Jellyfin account holders
// and a guest (through the server's /j login) watch an episode, then ⏭ puts
// up the next episode, then a direct link, then a link passed on "through"
// alice, then one the server fetches (both relayed: the guests prepare
// them from the server's warm relay of the item). Every client prepares
// the next item in a hidden player (`MediaPipeline.prepare`) and the swap
// promotes it. The fake Jellyfin and the link's host answer with a delay
// and a capped rate, like a server elsewhere. Prints each client's phases
// (SwitchTrace) and the wall time from ⏭ to the server playing.
//
// WT_SWITCH_PRELOAD=0 turns the preload off (the cold path, for comparison);
// WT_SWITCH_DELAY_MS and WT_SWITCH_RATE_KBS set the remote's delay and rate.
@@ -202,7 +202,12 @@ void main() {
'Up next: ⏭ to a Jellyfin episode, then to a direct link, timed per client',
timeout: const Timeout(Duration(minutes: 4)),
() async {
MediaKit.ensureInitialized();
// mpv's warnings, as the host writes them to the app log.
final mpvLines = <String>[];
// (The root's records, the app's logging not being hierarchical.)
final logSub = createLogger('mpv').onRecord.listen((r) {
if (r.loggerName == 'mpv') mpvLines.add(r.message);
});
final preload = Platform.environment['WT_SWITCH_PRELOAD'] != '0';
final file = await _media();
final jelly = _FakeJellyfin(file);
@@ -236,9 +241,15 @@ void main() {
final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC');
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
await accB.ensureLoaded(validate: false);
SessionCoordinator coord(JellyfinAccounts acc) => SessionCoordinator(
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp)
..playback.preloadEnabled = preload;
final hosts = <SessionCoordinator, MediaKitHost>{};
SessionCoordinator coord(JellyfinAccounts acc) {
final host = MediaKitHost()..init();
final c = SessionCoordinator(
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp, players: host)
..media.preloadEnabled = preload;
hosts[c] = host;
return c;
}
final a = coord(accA), b = coord(accB), c = coord(accC);
final all = {'alice': a, 'guest': b, 'carol': c};
ConnectParams params(String name, [HelloMedia? media]) =>
@@ -252,7 +263,7 @@ void main() {
if (preload) {
for (final x in all.values) {
expect(x.queue.warm.peek()?.state, WarmState.ready, reason: 'ready on every client before ⏭');
expect(x.playback.preloadUrl, isNotNull, reason: 'preloaded on every client');
expect(x.media.preloadUrl, isNotNull, reason: 'preloaded on every client');
}
}
final clock = Stopwatch()..start();
@@ -269,11 +280,14 @@ void main() {
}
await _poll(() => all.values.every((x) => x.playback.player.state.playing), 'all play $what');
// The promoted player is the session player: marks, tracks and
// the volume carried over, the clock moves.
// the volume carried over, the clock moves; the old one retired.
for (final x in all.values) {
expect(x.playback.durationSecs, greaterThan(59));
expect(x.playback.player.state.volume, 0, reason: 'volume kept');
await _poll(() => x.playback.tracks.peek().isNotEmpty, 'tracks listed');
final host = hosts[x]!;
await host.retiring;
expect(host.live, lessThanOrEqualTo(2), reason: 'the session player and at most the next prepared one');
}
final at = a.playback.positionSecs;
await Future.delayed(const Duration(seconds: 1));
@@ -286,7 +300,7 @@ void main() {
await b.connect(params('guest'));
await c.connect(params('carol'));
for (final x in all.values) {
await x.playback.player.setVolume(0);
await x.playback.setVolume(0);
}
await _poll(() => all.values.every((x) => x.playback.durationSecs > 59), 'all opened episode 1');
expect(await a.queue.add(pickForRef(accA, const JellyfinRef(serverId: _sid, itemId: _ep2, title: 'Second'))!),
@@ -332,14 +346,17 @@ void main() {
// ignore: avoid_print
print(report.join('\n'));
expect(serverErrors, isEmpty);
// No command reached a player still opening, and the
// cache is in RAM on every player (no "Failed to create file cache").
expect(mpvLines.where((l) => l.contains('error running command') || l.contains('file cache')), isEmpty);
// Nothing queued: no preload. Queued and taken off again: dropped.
if (preload) {
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'nothing preloaded');
await _poll(() => all.values.every((x) => x.media.preloadUrl == null), 'nothing preloaded');
expect(await a.queue.add(LinkMedia(linkUrl, relay: LinkRelay.direct)), isNull);
await _poll(() => all.values.every((x) => x.playback.preloadUrl == linkUrl), 'preloaded again');
await _poll(() => all.values.every((x) => x.media.preloadUrl == linkUrl), 'preloaded again');
a.queue.remove(a.serverState.peek()!.queue.first.id);
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'dropped when taken off');
await _poll(() => all.values.every((x) => x.media.preloadUrl == null), 'dropped when taken off');
}
// Preloaded everywhere: every client starts from its preload.
if (preload) {
@@ -348,6 +365,7 @@ void main() {
}
}
} finally {
await logSub.cancel();
for (final x in all.values) {
await x.dispose();
}
+9 -6
View File
@@ -4,7 +4,8 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:starsync/src/playback/playback_controller.dart';
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
import 'package:starsync/src/session/media_pipeline.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
@@ -42,13 +43,15 @@ void main() {
final server = SyncServer(AppConfig(
port: 0, secret: '', name: 'readahead', mediaPath: media));
await server.start();
final p = PlaybackController();
final host = MediaKitHost();
final m = MediaPipeline(host: host);
final p = m.playback;
try {
await p.init();
expect(p.pinMismatches, isEmpty,
await m.start();
expect(host.pinMismatches, isEmpty,
reason: 'every pinned mpv property took effect');
final token = server.session.issueToken('probe', '');
await p.openUrl(
await m.open(
'http://127.0.0.1:${server.boundPort}/m/default/$token/readahead.mp4');
var best = 0.0;
final end = DateTime.now().add(const Duration(seconds: 30));
@@ -62,7 +65,7 @@ void main() {
expect(best, greaterThan(120),
reason: 'paused player reads ahead about two minutes');
} finally {
await p.dispose();
await m.dispose();
await server.stop();
}
},
+226 -2
View File
@@ -35,15 +35,130 @@ Future<String> _media() async {
}
Future<void> _poll(FutureOr<bool> Function() cond, String what,
{Duration timeout = const Duration(seconds: 30)}) async {
{Duration timeout = const Duration(seconds: 30), Duration every = const Duration(milliseconds: 200)}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(const Duration(milliseconds: 200));
await Future.delayed(every);
}
fail('timed out waiting for $what');
}
/// 60 s at about 3 Mbit/s: long enough that a slow source's relay holds
/// only part of it. An mp4 with its index first: headless players have no
/// video track selected (vid=no), and mkv's cues list only the video, so
/// an mkv would be seeked by scanning from its start (an app's player,
/// with its video output, uses the cues).
Future<String> _longMedia() async {
final path = '$_tmp/relay_e2e_60s.mp4';
if (await File(path).exists()) return path;
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=24',
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
'-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-b:v', '3M', '-minrate', '3M', '-maxrate', '3M',
'-bufsize', '2M', '-g', '24', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', path,
]);
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
return path;
}
/// A file read at most [bytesPerSec] (a source's uplink).
class _SlowFile implements RelayBytes {
final File file;
final int bytesPerSec;
_SlowFile(this.file, this.bytesPerSec);
@override
Future<int> size() => file.length();
@override
Stream<List<int>> read(int start, int end) async* {
const chunk = 32 * 1024;
final sw = Stopwatch()..start();
var sent = 0;
await for (final c in file.openRead(start, end + 1)) {
for (var o = 0; o < c.length; o += chunk) {
final part = c.sublist(o, o + chunk > c.length ? c.length : o + chunk);
sent += part.length;
final due = sent * 1000 ~/ bytesPerSec - sw.elapsedMilliseconds;
if (due > 0) await Future<void>.delayed(Duration(milliseconds: due));
yield part;
}
}
}
@override
Future<void> close() async {}
}
/// A member that is only a source: it sits out (out of the gate), puts
/// [file] up relayed and feeds it at [bytesPerSec]. [drop] goes away
/// without a `bye` (the member `gone`, the relay kept); [back] returns as
/// the same member and feeds again.
class _BareSource {
final int port;
final File file;
final String name;
static const _client = 'bareSourceClient01';
var _seq = 0;
WebSocket? ws;
String token = '';
RelaySource? src;
Timer? _alive;
_BareSource(this.port, this.file) : name = 'slow.mp4';
Future<void> join() async {
final w = ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
final welcome = Completer<Map>();
w.listen((f) {
final j = jsonDecode('$f') as Map;
if (j['t'] == 'welcome' && !welcome.isCompleted) welcome.complete(j);
}, onError: (_) {});
w.add(jsonEncode(HelloMessage(
secret: 'pw', user: const HelloUser(name: 'bob'), client: _client, seq: ++_seq)
.toJson()));
token = (await welcome.future.timeout(const Duration(seconds: 5)))['token'] as String;
w.add(jsonEncode(const PresenceIntent(state: Presence.away).toJson()));
// Not silent, like an app (a silent member goes gone).
_alive?.cancel();
_alive = Timer.periodic(const Duration(seconds: 1),
(_) => w.add(jsonEncode({'t': 'report', 'position': 0, 'buffered': 0, 'eventId': 0})));
}
Future<void> swap() async {
ws!.add(jsonEncode({
't': 'swap',
'media': {'name': name, 'size': await file.length(), 'duration': 0, 'relay': <String, Object?>{}},
}));
}
Future<void> feed(int bytesPerSec) async {
final s = src = RelaySource(Uri.parse('ws://127.0.0.1:$port/r/default/$token/source'),
RelayMedia.single(_SlowFile(file, bytesPerSec)),
name: name);
await s.start();
}
/// Gone without a word: the channel and the socket close, no `bye`.
Future<void> drop() async {
_alive?.cancel();
await src?.close();
await ws?.close();
}
Future<void> back(int bytesPerSec) async {
await join();
await feed(bytesPerSec);
}
Future<void> close() async {
_alive?.cancel();
await src?.close();
await ws?.close();
}
}
/// Serves one file with ranges; [requests] counts hits.
Future<(HttpServer, List<String>)> _origin(String file) async {
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
@@ -267,4 +382,113 @@ void main() {
await proxy.close();
}
});
// A seek into a part the relay has not buffered yet. The source's
// uplink is about 3x the media's bitrate; the time from the seek to the
// player playing there again is printed and loosely bounded.
test('a seek past what a slow source has sent plays within a few seconds',
timeout: const Timeout(Duration(minutes: 2)), () async {
final file = File(await _longMedia());
final alice = await member('alice');
final bob = _BareSource(server.boundPort, file);
try {
await bob.join();
await bob.swap();
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state');
await bob.feed(1200 * 1024);
await _poll(() => alice.playback.durationSecs > 59, 'alice opened it');
alice.toggleReady();
await _poll(() => alice.playback.playing && alice.playback.positionSecs > 2, 'playing');
final took = <int>[];
for (final to in [40.0, 52.0]) {
final sw = Stopwatch()..start();
alice.seekTo(to);
await _poll(() => alice.playback.playing && alice.playback.positionSecs > to + 0.5, 'played at $to',
every: const Duration(milliseconds: 20));
took.add(sw.elapsedMilliseconds);
}
// ignore: avoid_print
print('relay seek: seek to an unbuffered part until playing there: ${took.join(', ')} ms');
for (final t in took) {
expect(t, lessThan(5000), reason: "a seek into what the source has not sent yet (4 s of buffer at about 3x the bitrate)");
}
} finally {
await bob.close();
}
});
// D6, the viewer's side: the source goes, a seek lands past what
// the server got from it, and the buffer runs dry.
test('the source drops: what is buffered plays, a seek past it waits for the source, no seek loop, it resumes (D6)',
timeout: const Timeout(Duration(minutes: 3)), () async {
final file = File(await _longMedia());
final alice = await member('alice');
final carol = await member('carol');
final bob = _BareSource(server.boundPort, file);
final errors = <String>[];
final subs = <StreamSubscription<Object?>>[];
try {
await bob.join();
await bob.swap();
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state');
// Just above the bitrate: the relay holds only a little ahead.
await bob.feed(560 * 1024);
await _poll(() => alice.playback.durationSecs > 59 && carol.playback.durationSecs > 59, 'both opened it');
for (final x in [alice, carol]) {
subs.add(x.playback.player.logs.listen((l) {
if (l.text.contains('error running command')) errors.add(l.text);
}));
x.toggleReady();
}
await _poll(() => alice.playback.playing && alice.playback.positionSecs > 4, 'playing');
await bob.drop();
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.sourceGone ?? false, 'source gone');
expect(alice.status.peek().line?.text, startsWith("bob's connection dropped"));
final seeks = [alice.playback.seeksSent, carol.playback.seeksSent];
// Carol skips to where the source never got to.
carol.seekTo(50);
await _poll(() => alice.serverState.peek()!.hold?.reason == HoldReason.source, 'held for the source',
timeout: const Duration(seconds: 60));
expect(alice.status.peek().line?.text, 'Waiting for bob');
expect(carol.status.peek().line?.text, 'Waiting for bob');
// Held for a while: no seek loop into bytes nobody sends.
await Future.delayed(const Duration(seconds: 12));
final sent = [alice.playback.seeksSent - seeks[0], carol.playback.seeksSent - seeks[1]];
expect(sent.every((n) => n <= 1), isTrue, reason: 'one try each, no loop: $sent');
expect(alice.serverState.peek()!.transport, 'held');
expect([alice.playerError.peek(), carol.playerError.peek()], everyElement(isEmpty), reason: 'not a broken player');
// Someone joins meanwhile: it opens what the server has and waits too.
final dave = await member('dave');
subs.add(dave.playback.player.logs.listen((l) {
if (l.text.contains('error running command')) errors.add(l.text);
}));
await _poll(() => dave.status.peek().line?.text == 'Waiting for bob', 'dave waits for bob');
dave.toggleReady();
await bob.back(4 * 1024 * 1024);
await _poll(() {
final relay = stateMediaRelay(alice.serverState.peek()!);
return relay != null && relay.live && !relay.sourceGone;
}, 'source back');
await _poll(
() =>
alice.serverState.peek()!.transport == 'playing' &&
alice.playback.playing &&
alice.playback.positionSecs > 50.5 &&
carol.playback.positionSecs > 50.5 &&
dave.playback.positionSecs > 50.5,
'all playing at 50 again',
timeout: const Duration(seconds: 40));
// ignore: avoid_print
print('D6: seeks while the source was gone $sent, held back '
'${alice.media.heldBackSeeks} + ${carol.media.heldBackSeeks}');
expect(errors, isEmpty, reason: 'no command reached a player without its media');
} finally {
for (final s in subs) {
await s.cancel();
}
await bob.close();
}
});
}
+12 -10
View File
@@ -2,7 +2,8 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:starsync/src/playback/playback_controller.dart';
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
import 'package:starsync/src/session/media_pipeline.dart';
import 'package:starsync/src/shaders/shader_assets.dart';
import 'package:starsync/src/shaders/shader_presets.dart';
import 'package:starsync/src/state/settings.dart';
@@ -21,12 +22,14 @@ void main() {
support.createSync(recursive: true);
shaderSupportDir = () async => support;
final s = AppSettings.instance;
final p = PlaybackController();
final m = MediaPipeline(host: MediaKitHost());
final p = m.playback;
try {
// The saved choice goes on at player creation.
s.setShaderPreset(ShaderPreset.a);
await p.init();
final dynamic mpv = p.player.platform;
await m.start();
await p.applyShaders();
final mpv = p.player;
final modeA =
await shaderPathsFor(ShaderPreset.a, ShaderQuality.hq, const []);
expect(shaderListMatches(modeA, await mpv.getProperty('glsl-shaders')), isTrue);
@@ -59,17 +62,16 @@ void main() {
expect(await mpv.getProperty('glsl-shaders'), good.path);
// A recreated player (Reload video) gets the choice again.
await p.recreate();
expect(await (p.player.platform as dynamic).getProperty('glsl-shaders'),
good.path);
await m.reload();
await p.applyShaders();
expect(await p.player.getProperty('glsl-shaders'), good.path);
s.setShaderPreset(ShaderPreset.off);
await p.applyShaders(announce: true);
expect(await (p.player.platform as dynamic).getProperty('glsl-shaders'),
'');
expect(await p.player.getProperty('glsl-shaders'), '');
expect(p.shaderNotice.value?.text, 'Shaders: Off');
} finally {
await p.dispose();
await m.dispose();
}
});
}
+1 -1
View File
@@ -12,7 +12,7 @@ const baseline = {
'UpdateController': 8,
'Profile': 8,
'FolderStore': 6,
'AppYtDlp': 4,
'AppYtDlp': 2,
'RecentLinks': 3,
};
@@ -26,8 +26,7 @@ Future<void> _poll(
Future<void> _quiet(SessionCoordinator c) async {
await c.playback.player.setVolume(0);
try {
final dynamic platform = c.playback.player.platform;
await platform?.setProperty('loop-file', 'inf');
await c.playback.player.setProperty('loop-file', 'inf');
} catch (_) {}
}
+23 -22
View File
@@ -1,14 +1,16 @@
// restructure: rewrite in step 6 (Media pipeline + player host): the
// fresh player per video.
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/sync/state_diff.dart';
// "Reload video" through the media pipeline: a fresh player from the
// host, the old one retired, the same media opened again and resynced,
// the ready flag untouched; leaving releases every player the host made.
const _mediaPath = '/home/foxyz/Videos/chiyo.mp4';
Future<void> _poll(
@@ -26,47 +28,46 @@ Future<void> _poll(
void main() {
test(
'reload video: new player, same media, resynced, ready untouched; '
'leave disposes the player',
'reload video: a fresh player from the host, same media, resynced, ready untouched; '
'leave releases every player',
timeout: const Timeout(Duration(minutes: 2)),
() async {
MediaKit.ensureInitialized();
final server = SyncServer(const AppConfig(
port: 0, secret: 'reload', name: 'reload', mediaPath: _mediaPath));
final server = SyncServer(const AppConfig(port: 0, secret: 'reload', name: 'reload', mediaPath: _mediaPath));
await server.start();
final c = SessionCoordinator();
final host = MediaKitHost()..init();
final c = SessionCoordinator(players: host);
try {
await c.connect(ConnectParams(
host: '127.0.0.1',
port: server.boundPort,
secret: 'reload',
name: 'solo'));
await c.playback.player.setVolume(0);
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'reload', name: 'solo'));
await c.playback.setVolume(0);
await _poll(() => c.serverState.peek() != null, 'server state');
c.toggleReady();
await _poll(
() => c.playback.player.state.playing && c.playback.positionSecs > 2,
'solo playback past 2 s');
await _poll(() => c.playback.playing && c.playback.positionSecs > 2, 'solo playback past 2 s');
final opens = c.opens;
final old = c.playback.player;
final gen = c.playback.generation.peek();
await c.reloadVideo();
expect(identical(c.playback.player, old), isFalse);
expect(identical(c.playback.player, old), isFalse, reason: 'a fresh player');
expect(old.disposed, isTrue, reason: 'the old one retired');
expect(host.live, 1, reason: 'only the new one is alive');
expect(c.playback.generation.peek(), gen + 1);
expect(c.opens, opens + 1, reason: 'the same media opened again');
expect(c.playback.volume.peek(), 0, reason: 'the volume carried over');
expect(c.selfMember.peek()?.ready, isTrue);
await _poll(() {
final r = c.serverState.peek();
if (r == null || !c.playback.player.state.playing) return false;
if (r == null || !c.playback.playing) return false;
final want = serverPositionAt(r, DateTime.now().toUtc());
return (c.playback.positionSecs - want).abs() < 1.5 &&
c.playback.positionSecs > 2;
return (c.playback.positionSecs - want).abs() < 1.5 && c.playback.positionSecs > 2;
}, 'reloaded player playing at the synced position');
expect(c.selfMember.peek()?.ready, isTrue);
expect(c.serverState.peek()?.transport, 'playing');
await c.leave();
expect(c.playback.alive.peek(), isFalse);
await host.retiring;
expect(host.live, 0, reason: 'leaving releases every player');
} finally {
await c.dispose();
await server.stop();
+1 -1
View File
@@ -27,7 +27,7 @@ void main() {
// dispose would wait on them for ever; the test process ending ends it.
setUpAll(() async {
MediaKit.ensureInitialized();
await coord.playback.init();
await coord.media.start();
});
testWidgets('wheel over the video: readout for about a second', (tester) async {
+91 -27
View File
@@ -2,8 +2,10 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:starsync/src/playback/playback_controller.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
import 'package:starsync/src/playback/yt_dlp_app.dart';
import 'package:starsync/src/session/media_pipeline.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
const _referer = 'https://www.bilibili.com/';
@@ -22,7 +24,8 @@ Future<(String, String)> _media() async {
return (v, a);
}
/// A CDN like Bilibili's: 403 without the page's Referer.
/// A CDN like Bilibili's: 403 without the page's Referer. `/video/...` is
/// the page itself (HTML: the app's probe sends it to yt-dlp).
class _Cdn {
_Cdn(this._server, this._files) {
_server.listen(_serve);
@@ -30,11 +33,27 @@ class _Cdn {
final HttpServer _server;
final Map<String, String> _files;
int ok = 0, forbidden = 0;
final cookies = <String?>[];
String url(String name) => 'http://127.0.0.1:${_server.port}/$name';
String page(String id) => 'http://127.0.0.1:${_server.port}/video/$id';
int subtitles = 0;
Future<void> _serve(HttpRequest req) async {
final res = req.response;
if (req.uri.pathSegments.firstOrNull == 'video') {
res.headers.contentType = ContentType.html;
res.write('<html><title>Fake page</title></html>');
await res.close();
return;
}
if (req.uri.pathSegments.lastOrNull == 'en.vtt') {
subtitles++;
res.write('WEBVTT\n\n00:00.000 --> 00:05.000\nHello\n');
await res.close();
return;
}
final file = _files[req.uri.pathSegments.lastOrNull];
if (file == null || req.headers.value('referer') != _referer) {
forbidden++;
@@ -43,6 +62,7 @@ class _Cdn {
return;
}
ok++;
cookies.add(req.headers.value('cookie'));
final bytes = await File(file).readAsBytes();
var start = 0, end = bytes.length - 1;
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
@@ -59,25 +79,37 @@ class _Cdn {
}
}
/// A stand-in yt-dlp printing what yt-dlp gives for a split-track page:
/// `requested_formats` with per-format `http_headers`, none at the top.
Future<String> _fakeYtDlp(_Cdn cdn, {required bool headers}) async {
final dir = await Directory('$_tmp/ytdl_headers_fake').create(recursive: true);
/// A stand-in yt-dlp (the managed copy in its own folder) printing what
/// yt-dlp gives for a split-track page: `requested_formats` with
/// per-format `http_headers` and `cookies`, subtitles with Bilibili's
/// danmaku among them. [fail]: it finds no formats instead.
Future<AppYtDlp> _fakeYtDlp(_Cdn cdn, {required bool headers, bool fail = false}) async {
final dir = Directory('$_tmp/ytdl_direct_${headers ? 'h' : 'n'}${fail ? 'f' : ''}');
if (dir.existsSync()) dir.deleteSync(recursive: true);
dir.createSync(recursive: true);
final h = headers ? {'Referer': _referer, 'User-Agent': 'fake-agent'} : <String, String>{};
final json = jsonEncode({
'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake',
'format_id': 'v+a', 'ext': 'mp4',
'requested_formats': [
{'format_id': 'v', 'url': cdn.url('v.m4s'), 'protocol': 'https', 'ext': 'mp4',
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h},
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h,
'cookies': 'SESSDATA=abc; Domain=.127.0.0.1; Path=/'},
{'format_id': 'a', 'url': cdn.url('a.m4s'), 'protocol': 'https', 'ext': 'm4a',
'vcodec': 'none', 'acodec': 'mp4a.40.2', 'http_headers': h},
],
'requested_subtitles': {
'en': {'url': cdn.url('en.vtt'), 'ext': 'vtt', 'name': 'English'},
'danmaku': {'url': cdn.url('danmaku.xml'), 'ext': 'xml'},
},
});
final f = File('${dir.path}/yt-dlp-${headers ? 'h' : 'n'}');
await f.writeAsString("#!/bin/sh\ncat <<'EOF'\n$json\nEOF\n");
final f = File('${dir.path}/yt-dlp');
await f.writeAsString('#!/bin/sh\n'
'case "\$1" in --version) echo 2099.01.01; exit 0;; esac\n'
'${fail ? 'echo "ERROR: [fake] BV1: No video formats found!" >&2; exit 1\n' : "cat <<'EOF'\n$json\nEOF\n"}');
await Process.run('chmod', ['+x', f.path]);
return f.path;
await File('${dir.path}/last-check').writeAsString(DateTime.now().toIso8601String());
return AppYtDlp(YtDlp(dir.path, updateEvery: const Duration(days: 9999)));
}
void main() {
@@ -85,32 +117,39 @@ void main() {
late HttpServer server;
setUpAll(() async {
MediaKit.ensureInitialized();
final (v, a) = await _media();
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
cdn = _Cdn(server, {'v.m4s': v, 'a.m4s': a});
});
tearDownAll(() => server.close(force: true));
// A page "everyone fetches" opens through the app's own resolve: its
// stream URLs (an EDL of video + audio) with the CDN's headers; mpv's
// yt-dlp hook is off.
test(
"mpv's yt-dlp hook sends each format's Referer to the CDN",
"a page plays from yt-dlp's stream URLs with each CDN's Referer and the cookies; subtitles but danmaku",
timeout: const Timeout(Duration(minutes: 1)),
() async {
final p = PlaybackController();
final host = MediaKitHost()..init();
final m = MediaPipeline(host: host, links: await _fakeYtDlp(cdn, headers: true));
final p = m.playback;
try {
await p.init();
expect(p.pinMismatches, isEmpty, reason: 'stream-lavf-o and the rest took effect');
await p.useYtDlp(await _fakeYtDlp(cdn, headers: true));
await p.openUrl('https://www.bilibili.com/video/BV1fake');
await m.start();
expect(host.pinMismatches, isEmpty, reason: 'stream-lavf-o, ytdl=no and the rest took effect');
expect(await p.player.getProperty('ytdl'), 'no');
expect(await m.open(cdn.page('BV1fake')), isTrue);
final end = DateTime.now().add(const Duration(seconds: 20));
while (DateTime.now().isBefore(end) && p.duration.peek() == Duration.zero) {
while (DateTime.now().isBefore(end) && (p.duration.peek() == Duration.zero || cdn.subtitles == 0)) {
await Future.delayed(const Duration(milliseconds: 100));
}
expect(p.duration.peek(), greaterThan(Duration.zero), reason: 'both tracks opened');
expect(cdn.forbidden, 0, reason: 'every request had the Referer');
expect(cdn.ok, greaterThanOrEqualTo(2));
expect(cdn.cookies, everyElement('SESSDATA=abc'));
expect(cdn.subtitles, 1, reason: 'the page\'s subtitles are added, danmaku is not');
await _until(() => p.tracks.peek().any((t) => t.isExternal), 'the subtitle track listed');
} finally {
await p.dispose();
await m.dispose();
}
},
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
@@ -120,29 +159,54 @@ void main() {
'a 403 is a load error, not a reconnect loop',
timeout: const Timeout(Duration(minutes: 1)),
() async {
final p = PlaybackController();
final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: false));
final p = m.playback;
final errors = <String>[];
p.onLoadError = errors.add;
try {
await p.init();
await m.start();
var logs = 0;
final sub = p.player.stream.log.listen((_) => logs++);
await p.useYtDlp(await _fakeYtDlp(cdn, headers: false));
final sub = p.player.logs.listen((_) => logs++);
final before = cdn.forbidden;
await p.openUrl('https://www.bilibili.com/video/BV2fake');
await m.open(cdn.page('BV2fake'));
await Future.delayed(const Duration(seconds: 8));
await sub.cancel();
// ignore: avoid_print
print('403s: ${cdn.forbidden - before}, log lines: $logs, errors: $errors');
expect(cdn.forbidden - before, lessThan(10), reason: 'no retry loop on 403');
expect(errors, hasLength(1));
// The status only when ffmpeg logs through this mpv (see _onLog).
// The status only when ffmpeg logs through this mpv (see the host).
expect(errors.single, anyOf(contains('403'), contains('could not be opened')));
expect(logs, lessThan(30), reason: 'warnings and errors only');
} finally {
await p.dispose();
await m.dispose();
}
},
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
);
// D11: yt-dlp finds no video; the error names the cookies it used.
test('a page yt-dlp finds no video on: the error says which cookies were used', () async {
final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: true, fail: true));
final got = <(String, String?)>[];
m.onError = (raw, {cookies}) => got.add((raw, cookies));
try {
await m.start();
expect(await m.open(cdn.page('BV3fake')), isFalse);
expect(got.single.$1, contains('No video formats found'));
expect(got.single.$2, 'no cookies were used');
expect(withCookieNote(playErrorText(got.single.$1), got.single.$2),
'No video found: it may need signing in (no cookies were used)');
} finally {
await m.dispose();
}
}, skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script');
}
Future<void> _until(bool Function() cond, String what) async {
final end = DateTime.now().add(const Duration(seconds: 10));
while (!cond()) {
if (DateTime.now().isAfter(end)) fail('timed out waiting for $what');
await Future.delayed(const Duration(milliseconds: 100));
}
}