From 25b19f2d6a775a4f0b1599183380a516f24ded19 Mon Sep 17 00:00:00 2001 From: foxyz Date: Fri, 16 Oct 2026 01:08:54 +0300 Subject: [PATCH] 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 --- .gitea/workflows/ci.yml | 11 +- README.md | 6 +- apps/desktop/lib/main.dart | 11 +- apps/desktop/lib/src/app/app_graph.dart | 4 +- .../src/controls/model/controls_model.dart | 4 +- .../lib/src/controls/player_settings.dart | 10 +- apps/desktop/lib/src/files/thumbnails.dart | 28 +- .../lib/src/platform/app_shutdown.dart | 2 + .../src/platform/desktop/desktop_ports.dart | 2 + .../src/platform/desktop/media_kit_host.dart | 502 +++++++ .../lib/src/platform/ports/link_resolver.dart | 6 +- .../lib/src/platform/ports/player_host.dart | 192 +++ .../desktop/lib/src/platform/ports/ports.dart | 5 + apps/desktop/lib/src/platform/smoke.dart | 70 + .../lib/src/playback/playback_controller.dart | 1169 +++++------------ apps/desktop/lib/src/playback/yt_dlp_app.dart | 23 +- .../lib/src/queue/queue_controller.dart | 24 +- .../lib/src/session/media_pipeline.dart | 868 ++++++++++++ apps/desktop/lib/src/session/media_plan.dart | 230 ++++ .../desktop/lib/src/session/relay_feeder.dart | 150 +++ .../lib/src/session/session_coordinator.dart | 586 ++------- apps/desktop/lib/src/session/status.dart | 14 + .../desktop/lib/src/shell/session_player.dart | 5 +- apps/desktop/lib/src/sync/user_seek.dart | 21 - apps/desktop/test/media_plan_test.dart | 152 +++ apps/desktop/test/media_switch_e2e_test.dart | 106 ++ apps/desktop/test/queue_switch_e2e_test.dart | 60 +- apps/desktop/test/readahead_e2e_test.dart | 15 +- apps/desktop/test/relay_e2e_test.dart | 228 +++- apps/desktop/test/shaders_mpv_test.dart | 22 +- apps/desktop/test/singletons_test.dart | 2 +- apps/desktop/test/sync_playback_e2e_test.dart | 3 +- apps/desktop/test/video_reload_e2e_test.dart | 45 +- apps/desktop/test/volume_readout_test.dart | 2 +- apps/desktop/test/ytdl_headers_e2e_test.dart | 118 +- docs/ARCHITECTURE.md | 374 ++++-- docs/DEVELOPMENT.md | 59 +- docs/ROADMAP.md | 53 +- packages/core/lib/src/constants.dart | 2 +- packages/core/lib/src/cookies.dart | 50 +- packages/core/lib/src/link_probe.dart | 31 +- packages/core/lib/src/yt_dlp.dart | 177 ++- packages/core/test/cookies_test.dart | 79 +- packages/core/test/yt_dlp_test.dart | 42 + packages/server/lib/src/relay_buffer.dart | 139 +- tool/smoke_linux.sh | 39 + tool/smoke_windows.ps1 | 70 + 47 files changed, 4083 insertions(+), 1728 deletions(-) create mode 100644 apps/desktop/lib/src/platform/desktop/media_kit_host.dart create mode 100644 apps/desktop/lib/src/platform/ports/player_host.dart create mode 100644 apps/desktop/lib/src/platform/smoke.dart create mode 100644 apps/desktop/lib/src/session/media_pipeline.dart create mode 100644 apps/desktop/lib/src/session/media_plan.dart create mode 100644 apps/desktop/lib/src/session/relay_feeder.dart create mode 100644 apps/desktop/test/media_plan_test.dart create mode 100644 apps/desktop/test/media_switch_e2e_test.dart create mode 100755 tool/smoke_linux.sh create mode 100644 tool/smoke_windows.ps1 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 546b027..7e77193 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -41,7 +41,8 @@ jobs: working-directory: packages/server # The updater's Windows apply path (running exe parked as .old) only - # means something on real NTFS. Runner notes: see release.yml. + # means something on real NTFS, and the close smoke run needs the real + # build. Runner notes: see release.yml. updater-windows: # Not for a "Release v…" commit: release.yml builds it on the same # (only) Windows runner, which this job would hold up. @@ -71,3 +72,11 @@ jobs: - name: Windows build shell: powershell run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1 + # The close smoke run on that build (P32): start, open a short clip in + # a player with a video output, close; it must exit within 45 s and + # leave no process. A runner that cannot show a window skips with a + # warning. Here rather than in a job of its own: one build, one runner. + - name: Smoke (start, open, close) + shell: powershell + timeout-minutes: 3 + run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/smoke_windows.ps1 -Limit 45 diff --git a/README.md b/README.md index abb8d8d..6a24ab1 100644 --- a/README.md +++ b/README.md @@ -533,7 +533,11 @@ export (picked or dropped; every site in it is kept). They stay on this computer and are used by its yt-dlp only (your own playback, "through me", and a server you host). Cookies log yt-dlp in as you on those sites: keep the file private, and for YouTube consider a throwaway -account (Google may flag accounts used with yt-dlp). +account (Google may flag accounts used with yt-dlp). When a page shows +no video, the message says which cookies were used: none, your file +(and whether it has any for that site), or your browser's (and how many +it has for that site), so you know whether to sign in there or add +cookies. YouTube works in all three too, and so do pages and links that only offer streaming playlists (HLS `.m3u8`, DASH `.mpd`, the HLS of X videos). Through me and Server fetches it never show the link to anyone; they can't pass on live diff --git a/apps/desktop/lib/main.dart b/apps/desktop/lib/main.dart index f593782..b11936b 100644 --- a/apps/desktop/lib/main.dart +++ b/apps/desktop/lib/main.dart @@ -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 main(List 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 main(List 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()); diff --git a/apps/desktop/lib/src/app/app_graph.dart b/apps/desktop/lib/src/app/app_graph.dart index 12e4ebd..c74a7b3 100644 --- a/apps/desktop/lib/src/app/app_graph.dart +++ b/apps/desktop/lib/src/app/app_graph.dart @@ -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( diff --git a/apps/desktop/lib/src/controls/model/controls_model.dart b/apps/desktop/lib/src/controls/model/controls_model.dart index 7276fe1..66e7076 100644 --- a/apps/desktop/lib/src/controls/model/controls_model.dart +++ b/apps/desktop/lib/src/controls/model/controls_model.dart @@ -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. diff --git a/apps/desktop/lib/src/controls/player_settings.dart b/apps/desktop/lib/src/controls/player_settings.dart index 6c44ccb..39ee2a4 100644 --- a/apps/desktop/lib/src/controls/player_settings.dart +++ b/apps/desktop/lib/src/controls/player_settings.dart @@ -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()?.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 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'); } diff --git a/apps/desktop/lib/src/files/thumbnails.dart b/apps/desktop/lib/src/files/thumbnails.dart index 17c0be5..5e5084e 100644 --- a/apps/desktop/lib/src/files/thumbnails.dart +++ b/apps/desktop/lib/src/files/thumbnails.dart @@ -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 Function() thumbnailCacheRoot = getApplicationCacheDirectory; @@ -33,7 +38,7 @@ const _timeout = Duration(seconds: 20); int _running = 0; final _waiting = >[]; final _inFlight = >{}; -final _players = {}; +final _players = {}; bool _closed = false; /// App close: no new thumbnails, running ones stop at their next check @@ -49,7 +54,7 @@ Future 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 _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 _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 _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); } } diff --git a/apps/desktop/lib/src/platform/app_shutdown.dart b/apps/desktop/lib/src/platform/app_shutdown.dart index 4b92f7c..eb71d0c 100644 --- a/apps/desktop/lib/src/platform/app_shutdown.dart +++ b/apps/desktop/lib/src/platform/app_shutdown.dart @@ -47,6 +47,8 @@ Future shutDownApp({Future 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. diff --git a/apps/desktop/lib/src/platform/desktop/desktop_ports.dart b/apps/desktop/lib/src/platform/desktop/desktop_ports.dart index cee9712..f084f64 100644 --- a/apps/desktop/lib/src/platform/desktop/desktop_ports.dart +++ b/apps/desktop/lib/src/platform/desktop/desktop_ports.dart @@ -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(), ); } diff --git a/apps/desktop/lib/src/platform/desktop/media_kit_host.dart b/apps/desktop/lib/src/platform/desktop/media_kit_host.dart new file mode 100644 index 0000000..df163ab --- /dev/null +++ b/apps/desktop/lib/src/platform/desktop/media_kit_host.dart @@ -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.broadcast(); + final _throttle = LogThrottle(burst: 5); + Future _retiring = Future.value(); + var _closing = false; + + @override + final List pinMismatches = []; + + @override + void init() => MediaKit.ensureInitialized(); + + @override + ReadonlySignal get renderMode => _renderMode; + + @override + Stream get httpErrors => _http.stream; + + @override + int get live => _live.length; + + @override + Future 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 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 = []; + 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 promote(HostedPlayer p) async { + final mk = p as _MkPlayer; + mk._role = PlayerRole.session; + mk.setLogLevel(sessionLogLevel); + final mismatches = []; + if (await p.pin('mute', 'no') case final m?) mismatches.add(m); + await _pinCache(mk, PlayerRole.session, mismatches); + pinMismatches + ..clear() + ..addAll(mismatches); + } + + Future _pinCache(_MkPlayer p, PlayerRole role, List 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 _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 _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 versions(HostedPlayer player) async { + try { + return '${await player.getProperty('mpv-version')}, ffmpeg ${await player.getProperty('ffmpeg-version')}'; + } catch (_) { + return null; + } + } + + // --------------------------------------------------------- tear-down + + @override + Future 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 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 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 _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 seek(Duration duration) async { + final hook = onUserSeek; + if (hook == null) return super.seek(duration); + hook(duration); + } + + Future 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? _logSub; + StreamSubscription? _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 get positionStream => mk.stream.position; + @override + Stream get durationStream => mk.stream.duration; + @override + Stream get bufferStream => mk.stream.buffer; + @override + Stream get volumeStream => mk.stream.volume; + @override + Stream get completedStream => mk.stream.completed; + @override + Stream get widthStream => mk.stream.width; + @override + Stream 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 open(String url, {Map 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 play() => mk.play(); + @override + Future pause() => mk.pause(); + @override + Future stop() => mk.stop(); + @override + Future seek(Duration position) => mk.seek(position); + @override + Future syncSeek(Duration position) => mk.syncSeek(position); + @override + Future setRate(double rate) => mk.setRate(rate); + @override + Future setVolume(double volume) => mk.setVolume(volume); + + // media_kit has no public property API: a guarded dynamic call on + // `player.platform` (its NativePlayer). + @override + Future setProperty(String name, String value) async => await _mpv?.setProperty(name, value); + + @override + Future getProperty(String name) async => '${await _mpv?.getProperty(name)}'; + + @override + Future observeProperty(String name, Future Function(String value) onChange) async => + await _mpv?.observeProperty(name, onChange); + + @override + Future command(List args) async => await _mpv?.command(args); + + @override + Future 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()); + } 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 get ready => controller.platform.future; +} diff --git a/apps/desktop/lib/src/platform/ports/link_resolver.dart b/apps/desktop/lib/src/platform/ports/link_resolver.dart index 595cb56..89185e4 100644 --- a/apps/desktop/lib/src/platform/ports/link_resolver.dart +++ b/apps/desktop/lib/src/platform/ports/link_resolver.dart @@ -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 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 probe(String url, {Duration keep, bool fresh}); diff --git a/apps/desktop/lib/src/platform/ports/player_host.dart b/apps/desktop/lib/src/platform/ports/player_host.dart new file mode 100644 index 0000000..3be6073 --- /dev/null +++ b/apps/desktop/lib/src/platform/ports/player_host.dart @@ -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 get positionStream; + Stream get durationStream; + Stream get bufferStream; + Stream get volumeStream; + Stream get completedStream; + Stream get widthStream; + + /// mpv's log lines at the player's level. + Stream 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 open(String url, {Map headers = const {}, bool play = false}); + Future play(); + Future pause(); + Future stop(); + + /// A user seek: through [onUserSeek] when set, else [syncSeek]. + Future seek(Duration position); + + /// Moves mpv (sync corrections, scrub preview). + Future syncSeek(Duration position); + Future setRate(double rate); + Future setVolume(double volume); + Future setProperty(String name, String value); + Future getProperty(String name); + Future observeProperty(String name, Future Function(String value) onChange); + Future command(List args); + + /// Sets [name] and reads it back: null when mpv took [value], else + /// `name: value -> got` (mpv ignores invalid values silently). + Future 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 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 create(PlayerRole role, {bool muted = false}); + + /// [player] (a preload) becomes a session player: unmuted, the full + /// cache pins, the session's log level. + Future 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 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 get pinMismatches; + + /// ffmpeg's HTTP errors (`HTTP error 403 Forbidden`), from whichever + /// player ffmpeg logs through (one per process). + Stream get httpErrors; + + /// `mpv-version` and `ffmpeg-version` of [player], for diagnostics. + Future 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 retire(HostedPlayer player); + + /// The last retire, until done. + Future get retiring; + + /// Disposes [player] at once (no view showed it). + Future 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 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')); +} diff --git a/apps/desktop/lib/src/platform/ports/ports.dart b/apps/desktop/lib/src/platform/ports/ports.dart index 41be3e7..8b164db 100644 --- a/apps/desktop/lib/src/platform/ports/ports.dart +++ b/apps/desktop/lib/src/platform/ports/ports.dart @@ -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, }); } diff --git a/apps/desktop/lib/src/platform/smoke.dart b/apps/desktop/lib/src/platform/smoke.dart new file mode 100644 index 0000000..3438881 --- /dev/null +++ b/apps/desktop/lib/src/platform/smoke.dart @@ -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=` 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 render=`, +// `closing`, or `skip ` / `fail `), 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 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(); +} diff --git a/apps/desktop/lib/src/playback/playback_controller.dart b/apps/desktop/lib/src/playback/playback_controller.dart index fe7f0f4..550163a 100644 --- a/apps/desktop/lib/src/playback/playback_controller.dart +++ b/apps/desktop/lib/src/playback/playback_controller.dart @@ -1,49 +1,49 @@ import 'dart:async'; -import 'dart:ffi' show Int8; -import 'dart:io'; -import 'package:ffi/ffi.dart' show StringUtf8Pointer, malloc; -import 'package:flutter/foundation.dart' show listEquals, mapEquals; -import 'package:flutter/widgets.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:media_kit_video/media_kit_video.dart'; +import 'package:flutter/foundation.dart' show debugPrint, listEquals, visibleForTesting; import 'package:signals/signals_flutter.dart'; -import 'package:sync_relay_core/core.dart' show JellyfinStreamType, LogThrottle, createLogger; +import 'package:sync_relay_core/core.dart' show JellyfinStreamType, createLogger; import '../jellyfin/track_prefs.dart' show MpvTrack; +import '../platform/ports/player_host.dart'; import '../shaders/shader_assets.dart'; import '../shaders/shader_presets.dart'; import '../state/settings.dart'; import '../sync/state_diff.dart'; -import '../sync/user_seek.dart'; import 'media_marks.dart'; -import 'yt_dlp_app.dart' show AppYtDlp, ytdlFormat; -/// Owns the session's mpv [Player] (and its [VideoController]): created -/// by [init] on join, released by [dispose] on leave, swapped by -/// [recreate] for "Reload video" and by [openUrl] for every new media (a -/// promoted preload or a fresh player). Never reuse a player for other -/// media — a GPU-side freeze sticks to the player, not to the widget. +export '../platform/ports/player_host.dart' show RenderMode, HostedPlayer, HostedVideo; + +final _log = createLogger('mpv'); + +/// The session player as everyone else sees it: whichever player the media +/// pipeline made current ([attach]), its marks as signals (position, +/// chapters, tracks, ...), the commands sent to it and the shaders on it. +/// It never creates or disposes a player (the pipeline does, through the +/// [PlayerHost]). Commands that come while a switch is under way (no +/// player, or its media not loaded yet) wait and go to the new player once +/// ([apply]): a seek into a player still opening is what made mpv log +/// "error running command seek". class PlaybackController { - /// Read-ahead target: about two minutes rides out short outages and - /// makes skipping an intro instant. - static const readAheadSecs = 150; - /// Forward demuxer cache. 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 but no cache-dir, so mpv - /// fell back to RAM anyway, warning "Failed to create file cache." per - /// open). - static const maxBytes = 256 * 1024 * 1024; - /// Back buffer (media_kit sets it to bufferSize too): enough for a short - /// seek back without holding another 256 MiB. - static const maxBackBytes = 64 * 1024 * 1024; - /// mpv's `volume-max` (its default is 130): the volume controls go - /// this far, past 100 % amplified. + final PlayerHost host; + PlaybackController(this.host) { + _httpSub = host.httpErrors.listen((e) => _httpError = e); + // The probe found software rendering while shaders are on: say so. + _renderSub = host.renderMode.subscribe((mode) { + if (mode == RenderMode.software && _shaders.isNotEmpty) { + _notice('Shaders have no effect: video uses software rendering'); + } + }); + } + + /// mpv's `volume-max`: the volume controls go this far. static const maxVolume = 150; - SyncPlayer? _player; - VideoController? _video; - StreamSubscription? _logSub; + HostedPlayer? _player; + HostedVideo? _video; + StreamSubscription? _logSub; + StreamSubscription? _httpSub; + void Function()? _renderSub; final List> _timeSubs = []; String _chapterJson = ''; // `glsl-shaders` as last set on the current player ('' = none). @@ -52,244 +52,266 @@ class PlaybackController { int _noticeId = 0; /// Bumped for every new player; the video view keys itself on it so a - /// recreated player gets a fresh `Video` widget. + /// new player gets a fresh `Video` widget. final generation = signal(0); - /// True while a live player exists. The video view shows plain black - /// otherwise, so no texture outlives its player. + /// True while a live player is attached. The video view shows plain + /// black otherwise, so no texture outlives its player. final alive = signal(false); - /// mpv's yt-dlp hook could not open the current link (its error text). - void Function(String error)? onYtdlError; - /// The current media failed to load (the HTTP error when there was one, - /// e.g. a CDN's 403), at most once per [openUrl]. + /// e.g. a CDN's 403), at most once per [beginOpen]. void Function(String error)? onLoadError; + /// Seeks from the controls (seek bar, the video widget's own) go here + /// instead of to mpv. + void Function(Duration position)? onUserSeek; + // Per opened media: the last HTTP error ffmpeg logged, and whether a // load error was reported. String? _httpError; bool _loadErrorSent = false; - /// mpv's log level for this app. 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 while the video output starts ([video]), when - /// the render-mode probe needs mpv's verbose GL init lines. - static const logLevel = 'warn'; + /// The current player. Only while [alive]. + HostedPlayer get player => _player!; - /// Points mpv's yt-dlp hook at [path] (the app's managed copy) for web - /// page links, at most 1080p, once per player; its cookies - /// (`ytdl-raw-options`) whenever they changed. - Future useYtDlp(String path) async { - final p = _player; - if (p == null) return; - try { - _ytdlPath = path; - final dynamic platform = p.platform; - if (!identical(p, _ytdlSetOn)) { - await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path'); - await platform?.setProperty('ytdl-format', ytdlFormat); - _ytdlSetOn = p; - _ytdlRaw = ''; - } - final raw = await AppYtDlp.instance.mpvRawOptions(); - if (raw != _ytdlRaw && identical(p, _player)) { - await platform?.setProperty('ytdl-raw-options', raw); - _ytdlRaw = raw; - } - } catch (e) { - debugPrint('yt-dlp path not set: $e'); - } - } - - Player? _ytdlSetOn; - String _ytdlRaw = ''; - String? _ytdlPath; - - Player get player => _player!; - - /// Seeks from the video controls (seek bar) go here instead of to mpv. - void Function(Duration position)? onUserSeek; + /// The current player, null between sessions. + HostedPlayer? get current => _player; /// Latest shader message for the user (the session player toasts it). final shaderNotice = signal(null); - /// How media_kit renders the current player's video (shaders need - /// [RenderMode.gpu]); [lastRenderMode] keeps it after leaving. - final renderMode = signal(RenderMode.unknown); - static final lastRenderMode = signal(RenderMode.unknown); + /// How video renders (the host's last probe; shaders need gpu). + ReadonlySignal get renderMode => host.renderMode; // ------------------------------------------------- media marks (signals) // - // What the open file has, for any controls UI (the session's seek bar, - // chapter / track menus and skip pills read these; nothing here knows - // about widgets). Reset when the player goes away; [segments] is owned - // by the coordinator (it survives Reload video, the item is the same). + // What the open file has, for any controls UI. Reset when the player goes + // away; [segments] is owned by the coordinator (it survives Reload video, + // the item is the same). /// mpv `chapter-list`: title and start; end = next start, or the /// duration for the last one. Empty without chapters. final chapters = signal>(const []); - /// Jellyfin MediaSegments of the current item (Intro / Outro / Recap / - /// Preview / Commercial). Only viewers with their own account fetch - /// them (relay viewers cannot call Jellyfin): empty otherwise. + /// Jellyfin MediaSegments of the current item. Only viewers with their + /// own account fetch them: empty otherwise. final segments = signal>(const []); - /// Audio and subtitle tracks from mpv's `track-list` (id, language, - /// title, codec, channels, default / forced flags, [MpvTrack.selected]). + /// Audio and subtitle tracks from mpv's `track-list`. final tracks = signal>(const []); - /// Playback position, in 250 ms steps (enough for pills and menus; a - /// seek bar should listen to `player.stream.position`). + /// Playback position, in 250 ms steps. final position = signal(Duration.zero); /// Duration of the open file (zero until known). final duration = signal(Duration.zero); - /// End of the cached range ahead of the position (mpv - /// `demuxer-cache-time`, media_kit's `buffer`), in 250 ms steps. + /// End of the cached range ahead of the position, in 250 ms steps. final buffered = signal(Duration.zero); - /// The player's volume, 0..[maxVolume] (media_kit's `volume`). + /// The player's volume, 0..[maxVolume]; kept across players. final volume = signal(100.0); - /// The opening ([openingRange]: Intro segment, else an OP-titled - /// chapter) and the ending ([endingRange]: Outro, else ED-titled). - late final Computed opening = - computed(() => openingRange(segments.value, chapters.value)); - late final Computed ending = - computed(() => endingRange(segments.value, chapters.value)); + late final Computed opening = computed(() => openingRange(segments.value, chapters.value)); + late final Computed ending = computed(() => endingRange(segments.value, chapters.value)); /// Index into [chapters] of the chapter playing now. - late final Computed currentChapter = - computed(() => chapterIndexAt(chapters.value, position.value)); + late final Computed currentChapter = computed(() => chapterIndexAt(chapters.value, position.value)); - /// Pinned mpv properties whose read-back differs from what was set - /// (mpv ignores invalid values silently), as `name: wanted -> got`. - final List pinMismatches = []; - bool get _ready => _player != null; + /// The [HostedVideo] of the current player. Created by the video view + /// when the pipeline did not make one ahead (it needs a running Flutter + /// binding; headless tests never touch it). The first one of an app + /// start probes the render mode. + HostedVideo get video => + _video ??= host.video(player, probe: host.renderMode.peek() == RenderMode.unknown); - /// The [VideoController] of the current player. Created lazily by the - /// video view (it needs a running Flutter binding; headless tests never - /// touch it) and released with the player. - VideoController get video { - final existing = _video; - if (existing != null) return existing; - final hwdec = _hwdecOverride; - _logRenderEnv(); - _requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode - // media_kit's video output sets hwdec itself on attach ('auto' unless - // configured), which used to override SYNC_RELAY_HWDEC. - final video = _video = VideoController( - player, - configuration: VideoControllerConfiguration(hwdec: hwdec), - ); - unawaited(_detectRenderMode(player, video)); - return video; - } + /// The current player has a video output (the view is up). + bool get hasVideo => _video != null; - // SYNC_RELAY_HWDEC wins over the Settings choice; read per player, so a - // changed setting applies on the next join or Reload video. - static String? get _hwdecOverride { - final hwdec = Platform.environment['SYNC_RELAY_HWDEC']; - if (hwdec != null && hwdec.isNotEmpty) return hwdec; - return AppSettings.instance.hwdec.peek(); - } + // ------------------------------------------------------------- attach - SyncPlayer _newPlayer({required int bufferSize, required MPVLogLevel logLevel}) => SyncPlayer( - configuration: PlayerConfiguration( - bufferSize: bufferSize, - libass: true, - vo: Platform.environment['SYNC_RELAY_VO'], - logLevel: logLevel, - )) - ..onUserSeek = (d) => onUserSeek?.call(d); - - Future init() async { - if (_player != null) return; - _player = _newPlayer(bufferSize: maxBytes, logLevel: MPVLogLevel.warn); // see [logLevel] - _held = false; + /// [p] becomes the session player (the first one, a fresh one per media, + /// a promoted preload, Reload video's): log, marks and track observers + /// move over, the view swaps to [video] ([generation]), volume and + /// shaders carry over. Returns the previous player for the pipeline to + /// retire. [loaded]: [p] already holds its media (a promoted preload). + HostedPlayer? attach(HostedPlayer p, {HostedVideo? video, bool loaded = false}) { + final old = _player; + _unwatch(); + _player = p; + _video = video; + _chapterJson = ''; + _httpError = null; + _loadErrorSent = false; _shaders = ''; - renderMode.value = RenderMode.unknown; - shaderNotice.value = null; - _logSub = _player!.stream.log.listen(_onLog); - _watchMarks(_player!); - await _pinBufferProps(); - await _pin('volume-max', '$maxVolume'); - await applyShaders(); + _loaded = loaded && p.state.duration > Duration.zero; + _opened = loaded; + p.onUserSeek = (d) => onUserSeek?.call(d); + final st = p.state; + position.value = _step(st.position); + duration.value = st.duration; + buffered.value = _step(st.buffer); + chapters.value = const []; + tracks.value = const []; + _logSub = p.logs.listen(_onLog); + _watchMarks(p); generation.value++; alive.value = true; + unawaited(p.setVolume(volume.peek()).catchError((Object e) => debugPrint('new player volume: $e'))); + unawaited(applyShaders()); + unawaited(_refreshTracks()); + if (_loaded) unawaited(_flush()); + return old; } - /// Dispose the current player and start a fresh one (no media loaded). - /// Keeps volume. The caller reopens media and resyncs. - Future recreate() async { - final volume = _player?.state.volume; - await dispose(); - await init(); - if (volume != null) { - try { - await player.setVolume(volume); - } catch (_) {} + /// The player leaves (leave, Reload video): the view drops its texture + /// ([alive] false) and the marks clear. Returns it for the pipeline to + /// dispose. + HostedPlayer? detach() { + final p = _player; + _unwatch(); + _player = null; + _video = null; + _loaded = false; + _pending.clear(); + alive.value = false; + _clearMarks(); + return p; + } + + void _unwatch() { + unawaited(_logSub?.cancel()); + _logSub = null; + for (final sub in _timeSubs) { + unawaited(sub.cancel()); } + _timeSubs.clear(); } - Future _pin(String property, String value, [Player? on]) async { - try { - final dynamic platform = (on ?? player).platform; - await platform?.setProperty(property, value); - final got = '${await platform?.getProperty(property)}'; - if (!pinMatches(value, got)) { - pinMismatches.add('$property: $value -> $got'); - debugPrint('pin $property=$value not applied, mpv has "$got"'); + /// A media is about to be opened in the current player: its marks and + /// errors start over, and commands wait until it is loaded. + void beginOpen() { + _opened = true; + _chapterJson = ''; + _httpError = null; + _loadErrorSent = false; + _loaded = false; + chapters.value = const []; + tracks.value = const []; + buffered.value = Duration.zero; + } + + static Duration _step(Duration d) { + final ms = d.inMilliseconds; + return Duration(milliseconds: ms - ms % 250); + } + + // Position, duration and volume from the player's streams; chapter-list + // and track-list observed in mpv (read back as JSON). + void _watchMarks(HostedPlayer p) { + _timeSubs.add(p.volumeStream.listen((v) { + if (identical(p, _player)) volume.value = v; + })); + _timeSubs.add(p.positionStream.listen((d) { + // At the end the bar shows full: mpv's last position is the last + // frame's start (and the 250 ms steps round down), short of duration. + if (p.state.completed && duration.value > Duration.zero) { + position.value = duration.value; + return; } - } catch (e, st) { - pinMismatches.add('$property: $value -> error $e'); - debugPrint('pin $property=$value failed: $e\n$st'); - } - } - - Future _pinBufferProps([Player? on, int ahead = maxBytes, int back = maxBackBytes]) async { - if (on == null) pinMismatches.clear(); - await _pin('demuxer-readahead-secs', '$readAheadSecs', on); - await _pin('cache-secs', '$readAheadSecs', on); - await _pin('demuxer-max-bytes', '$ahead', on); - await _pin('demuxer-max-back-bytes', '$back', on); - await _pin('cache-on-disk', 'no', on); - final hwdec = _hwdecOverride; - if (hwdec != null) { - await _pin('hwdec', hwdec, on); - } - await _pin('stream-lavf-o', streamLavfOptions, on); - } - - /// 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. - static const streamLavfOptions = 'reconnect_streamed=1,reconnect_on_http_error=5xx,' - 'reconnect_delay_max=4,reconnect_max_retries=3'; - - // mpv_request_log_messages on [p]'s handle (media_kit has no API for it). - static void _requestLog(Player p, String level) { - try { - final dynamic platform = p.platform; - final name = level.toNativeUtf8(); - try { - platform.mpv.mpv_request_log_messages(platform.ctx, name.cast()); - } finally { - malloc.free(name); + position.value = _step(d); + // Media without a known length (a live link) counts as loaded once + // it plays. + if (d > Duration.zero && !_loaded && _opened && identical(p, _player)) { + _loaded = true; + unawaited(_flush()); } - } catch (e) { - debugPrint('mpv log level $level not set: $e'); + })); + _timeSubs.add(p.completedStream.listen((done) { + if (done && duration.value > Duration.zero) position.value = duration.value; + })); + _timeSubs.add(p.bufferStream.listen((d) => buffered.value = _step(d))); + _timeSubs.add(p.durationStream.listen((d) { + duration.value = d; + // The last chapter ends at the duration. + if (_chapterJson.isNotEmpty) chapters.value = parseChapterList(_chapterJson, d); + if (d > Duration.zero && !_loaded && identical(p, _player)) { + _loaded = true; + unawaited(_flush()); + } + })); + () async { + try { + await p.observeProperty('chapter-list', (String v) async { + if (!identical(p, _player)) return; + _chapterJson = v; + chapters.value = parseChapterList(v, duration.peek()); + }); + await p.observeProperty('track-list', (String v) async { + if (!identical(p, _player)) return; + _setTracks(parseTrackList(v)); + }); + } catch (e) { + debugPrint('observe chapters / tracks failed: $e'); + } + }(); + } + + void _setTracks(List list) { + if (!listEquals(tracks.peek(), list)) tracks.value = list; + } + + Future _refreshTracks() async { + final p = _player; + if (p == null) return; + try { + final v = await p.getProperty('track-list'); + if (identical(p, _player)) _setTracks(parseTrackList(v)); + } catch (_) {} + } + + void _clearMarks() { + _chapterJson = ''; + chapters.value = const []; + tracks.value = const []; + position.value = Duration.zero; + duration.value = Duration.zero; + buffered.value = Duration.zero; + } + + // -------------------------------------------------------------- log + + // The current player's log: load failures become player errors; a render + // error while shaders are on is a shader that failed to load or compile + // (mpv then renders without it). + void _onLog(PlayerLogLine log) { + if (log.prefix == 'ffmpeg') return; + if (!_loadErrorSent && mpvLoadFailed(log, duration.peek())) { + _loadErrorSent = true; + final http = _httpError; + _log.warning('cannot open the media: [${log.prefix}] ${log.text.trim()}' + '${http == null ? ' (no HTTP error logged; a request that timed out after 5 s looks like this)' : ' ($http)'}'); + onLoadError?.call(http == null ? 'the video stream could not be opened' : 'the video server answered $http'); + return; + } + if (log.prefix == 'libmpv_render' && log.isError && _shaders.isNotEmpty) { + debugPrint('shader error: ${log.text}'); + final p = _player; + _shaders = ''; + _shaderOp = _shaderOp.then((_) async { + if (!identical(p, _player)) return; + await setShaderPaths(const []); + _turnShadersOff('Shaders failed in mpv (${log.text}) — turned off'); + }); } } - /// Puts the saved shader choice (Settings, Ctrl+0..6, shader button) on - /// the current player, live. A missing file or a value mpv won't take - /// turns shaders off and says why in [shaderNotice]; [announce] also - /// reports success ("Shaders: Mode A (HQ)"). + // ---------------------------------------------------------- shaders + + /// Puts the saved shader choice on the current player, live. A missing + /// file or a value mpv won't take turns shaders off and says why in + /// [shaderNotice]; [announce] also reports success. Future applyShaders({bool announce = false}) => _shaderOp = _shaderOp.then((_) => _applyShaders(announce)); @@ -302,8 +324,7 @@ class PlaybackController { String? problem; var paths = const []; try { - paths = await shaderPathsFor( - preset, s.shaderQuality.peek(), s.customShaders.peek()); + paths = await shaderPathsFor(preset, s.shaderQuality.peek(), s.customShaders.peek()); if (preset == ShaderPreset.custom && paths.isEmpty) { problem = 'no custom shader files picked'; } else { @@ -334,11 +355,12 @@ class PlaybackController { /// joined with ','). @visibleForTesting Future setShaderPaths(List paths) async { + final p = _player; + if (p == null) return false; final value = joinShaderPaths(paths); try { - final dynamic platform = player.platform; - await platform?.setProperty('glsl-shaders', value); - final got = '${await platform?.getProperty('glsl-shaders')}'; + await p.setProperty('glsl-shaders', value); + final got = await p.getProperty('glsl-shaders'); final took = shaderListMatches(paths, got); _shaders = took ? value : got; if (!took) debugPrint('glsl-shaders=$value not applied: "$got"'); @@ -354,9 +376,7 @@ class PlaybackController { _notice(message); } - void _notice(String text) { - shaderNotice.value = ShaderNotice(++_noticeId, text); - } + void _notice(String text) => shaderNotice.value = ShaderNotice(++_noticeId, text); String _softwareHint(List paths) => paths.isEmpty ? '' @@ -366,281 +386,16 @@ class PlaybackController { _ => '', }; - // mpv logs from the render context: GL init lines mean GPU rendering; - // an error there while shaders are on is a shader that failed to load - // or compile (mpv then renders without it). - void _onLog(PlayerLog log) { - _toAppLog(log); - if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) { - final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), ''); - if (text.isNotEmpty) { - _loadErrorSent = true; - onYtdlError?.call(text); - } - return; - } - // ffmpeg logs through one mpv instance per process (the first one - // alive), so the HTTP status is a bonus, not a given. - if (log.prefix == 'ffmpeg') { - final http = RegExp(r'HTTP error \d{3}.*').firstMatch(log.text); - if (http != null) _httpError = http[0]!.trim(); - return; - } - // Nothing playable opened (a track of mpv's yt-dlp EDL refused, ...). - if (!_loadErrorSent && _loadFailed(log, duration.peek())) { - _loadErrorSent = true; - final http = _httpError; - _mpvLog.warning('cannot open the media: [${log.prefix}] ${log.text.trim()}' - '${http == null ? ' (no HTTP error logged; a request that timed out after 5 s looks like this)' : ' ($http)'}'); - onLoadError?.call(http == null ? 'the video stream could not be opened' : 'the video server answered $http'); - return; - } - if (log.prefix != 'libmpv_render') return; - // Which GL the video output got (verbose, heard only during the - // render-mode probe): kept in the app log. - if (_glInfo.hasMatch(log.text)) _mpvLog.info('render: [libmpv_render] ${log.text.trim()}'); - if (log.text.contains('suspected software renderer')) { - _setRenderMode(RenderMode.softwareGl); - } else if (log.text.startsWith('GL_VERSION') && - renderMode.peek() == RenderMode.unknown) { - _setRenderMode(RenderMode.gpu); - } else if ((log.level == 'error' || log.level == 'fatal') && - _shaders.isNotEmpty) { - debugPrint('shader error: ${log.text}'); - final p = _player; - _shaders = ''; - _shaderOp = _shaderOp.then((_) async { - if (!identical(p, _player)) return; - await setShaderPaths(const []); - _turnShadersOff('Shaders failed in mpv (${log.text}) — turned off'); - }); - } - } - - // mpv's warnings and errors (the session player's; ffmpeg's through - // whichever player logs them) go to the app log, a few per kind at a - // time: a refused request makes ffmpeg retry and warn on every try. - static final _mpvLog = createLogger('mpv'); - static final _mpvThrottle = LogThrottle(burst: 5); - - // 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(' ')}'); - } - - // 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; - Player? _frameLoggedFor; - Future _logFirstFrame(Player p, int? width) async { - if (width == null || width <= 0 || identical(p, _frameLoggedFor)) return; - _frameLoggedFor = p; - try { - final dynamic platform = p.platform; - final current = await platform?.getProperty('hwdec-current'); - final wanted = await platform?.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 platform?.getProperty('mpv-version')}, ' - 'ffmpeg ${await platform?.getProperty('ffmpeg-version')}'); - } - } catch (e) { - debugPrint('first frame info: $e'); - } - } - - static void _toAppLog(PlayerLog log, {String player = ''}) { - final level = log.level; - if (level != 'warn' && level != 'error' && level != 'fatal') return; - final text = log.text.trim(); - if (text.isEmpty) return; - final pass = _mpvThrottle.allow('$player${log.prefix} $level ${text.length > 40 ? text.substring(0, 40) : text}'); - if (pass == null) return; - final line = '$pass$player[${log.prefix}] $text'; - level == 'warn' ? _mpvLog.info(line) : _mpvLog.warning(line); - } - - /// [log] says the media could not be opened: before a [duration] is - /// known, mpv's fatal "nothing to play" or a demuxer error, or a yt-dlp - /// hook error. - static bool _loadFailed(PlayerLog log, Duration duration) { - if (log.prefix == 'ytdl_hook') return log.level == 'error' || log.level == 'fatal'; - return duration == Duration.zero && - ((log.prefix == 'cplayer' && log.level == 'fatal') || (log.prefix == 'demux' && log.level == 'error')); - } - - void _setRenderMode(RenderMode mode) { - renderMode.value = mode; - lastRenderMode.value = mode; - } - - // 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). - Future _detectRenderMode(Player p, VideoController video) async { - try { - await video.platform.future; - await Future.delayed(const Duration(milliseconds: 1500)); - } catch (_) { - return; - } finally { - if (identical(p, _player)) _requestLog(p, logLevel); - } - if (!identical(p, _player)) return; - final mode = renderMode.peek(); - if (mode != RenderMode.unknown) { - _mpvLog.info('render: ${mode.name} (${mode == RenderMode.gpu ? 'OpenGL on the GPU' : 'OpenGL on the CPU'})'); - return; - } - _setRenderMode(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)'); - if (_shaders.isNotEmpty) { - _notice('Shaders have no effect: video uses software rendering'); - } - } - - /// `mpv-version` and `ffmpeg-version` of the session player, for - /// diagnostics; null when there is no player. + /// `mpv-version` and `ffmpeg-version` of the session player. Future mpvVersion() async { final p = _player; - if (p == null) return null; - try { - final dynamic platform = p.platform; - return '${await platform?.getProperty('mpv-version')}, ffmpeg ${await platform?.getProperty('ffmpeg-version')}'; - } catch (_) { - return null; - } + return p == null ? null : host.versions(p); } - /// Opens [url] paused. [headers] go with every request for it (mpv - /// `http-header-fields`): a Jellyfin token travels there, not in the URL. - /// Each media gets its own player: when [preload] holds the same [url] - /// and [headers], that player becomes this one (true: promoted, its - /// buffer comes along); else a player that already held media is - /// replaced by a fresh one first ([onFreshPlayer] once it is up), unless - /// [keepPlayer] (the same media again, e.g. after a reconnect). - Future openUrl(String url, - {Map? headers, bool keepPlayer = false, void Function()? onFreshPlayer}) async { - if (!_ready) return false; - final pre = _pre; - if (pre != null && pre.matches(url, headers)) { - if (await _promote(pre)) return true; - } - if (_held && !keepPlayer && await _freshPlayer()) onFreshPlayer?.call(); - if (!_ready) return false; - try { - _held = true; - _chapterJson = ''; - _httpError = null; - _loadErrorSent = false; - chapters.value = const []; - tracks.value = const []; - buffered.value = Duration.zero; - // An empty map, not null: media_kit caches headers per URI. - await player.open(Media(url, httpHeaders: headers ?? const {}), play: false); - return false; - } catch (e, st) { - debugPrint('open ${Uri.tryParse(url)?.replace(query: '') ?? 'media'} failed: $e\n$st'); - rethrow; - } - } - - // Position, duration and volume from media_kit's streams; chapter-list and - // track-list observed in mpv (read back as JSON). - void _watchMarks(SyncPlayer p) { - volume.value = p.state.volume; - _timeSubs.add(p.stream.volume.listen((v) => volume.value = v)); - _timeSubs.add(p.stream.position.listen((d) { - // At the end the bar shows full: mpv's last position is the last - // frame's start (and the 250 ms steps round down), short of duration. - if (p.state.completed && duration.value > Duration.zero) { - position.value = duration.value; - return; - } - final ms = d.inMilliseconds; - position.value = Duration(milliseconds: ms - ms % 250); - })); - _timeSubs.add(p.stream.completed.listen((done) { - if (done && duration.value > Duration.zero) position.value = duration.value; - })); - _timeSubs.add(p.stream.width.listen((w) => _logFirstFrame(p, w))); - unawaited(_logFirstFrame(p, p.state.width)); // a promoted preload has one already - _timeSubs.add(p.stream.buffer.listen((d) { - final ms = d.inMilliseconds; - buffered.value = Duration(milliseconds: ms - ms % 250); - })); - _timeSubs.add(p.stream.duration.listen((d) { - duration.value = d; - // The last chapter ends at the duration. - if (_chapterJson.isNotEmpty) chapters.value = parseChapterList(_chapterJson, d); - })); - () async { - try { - final dynamic platform = p.platform; - await platform?.observeProperty('chapter-list', (String v) async { - if (!identical(p, _player)) return; - _chapterJson = v; - chapters.value = parseChapterList(v, duration.peek()); - }); - await platform?.observeProperty('track-list', (String v) async { - if (!identical(p, _player)) return; - _setTracks(parseTrackList(v)); - }); - } catch (e) { - debugPrint('observe chapters / tracks failed: $e'); - } - }(); - } - - void _setTracks(List list) { - if (!listEquals(tracks.peek(), list)) tracks.value = list; - } - - /// Reads `track-list` now (after a selection: [tracks] then shows it at - /// once instead of on mpv's next change notice). - Future _refreshTracks() async { - final p = _player; - if (p == null) return; - try { - final dynamic platform = p.platform; - final v = '${await platform?.getProperty('track-list')}'; - if (identical(p, _player)) _setTracks(parseTrackList(v)); - } catch (_) {} - } - - void _clearMarks() { - _chapterJson = ''; - chapters.value = const []; - tracks.value = const []; - position.value = Duration.zero; - duration.value = Duration.zero; - buffered.value = Duration.zero; - } + // ------------------------------------------------------------ tracks /// Waits (bounded) until the open file's tracks are listed and mpv has - /// selected its audio track, so a report or a carried-over choice sees - /// the real selection. + /// selected its audio track. Future> waitForTracks({Duration timeout = const Duration(seconds: 5)}) async { final end = DateTime.now().add(timeout); while (_player != null && DateTime.now().isBefore(end)) { @@ -657,9 +412,8 @@ class PlaybackController { final p = _player; if (p == null) return (aid: null, sid: null); try { - final dynamic platform = p.platform; - final aid = int.tryParse('${await platform?.getProperty('current-tracks/audio/id')}'); - final sid = int.tryParse('${await platform?.getProperty('current-tracks/sub/id')}'); + final aid = int.tryParse(await p.getProperty('current-tracks/audio/id')); + final sid = int.tryParse(await p.getProperty('current-tracks/sub/id')); return (aid: aid, sid: sid); } catch (_) { return (aid: null, sid: null); @@ -672,12 +426,11 @@ class PlaybackController { final p = _player; if (p == null) return; try { - final dynamic platform = p.platform; - if (aid != null) await platform?.setProperty('aid', '$aid'); + if (aid != null) await p.setProperty('aid', '$aid'); if (subtitlesOff) { - await platform?.setProperty('sid', 'no'); + await p.setProperty('sid', 'no'); } else if (sid != null) { - await platform?.setProperty('sid', '$sid'); + await p.setProperty('sid', '$sid'); } } catch (e) { debugPrint('select tracks failed: $e'); @@ -685,6 +438,8 @@ class PlaybackController { await _refreshTracks(); } + // ---------------------------------------------------------- position + double get positionSecs { final p = _player; if (p == null) return 0; @@ -694,7 +449,8 @@ class PlaybackController { double get bufferedSecsAhead { final p = _player; if (p == null) return 0; - final ahead = (p.state.buffer - p.state.position).inMilliseconds / 1000.0; + final st = p.state; + final ahead = (st.buffer - st.position).inMilliseconds / 1000.0; if (ahead.isNaN || ahead < 0) return 0; return ahead; } @@ -705,23 +461,62 @@ class PlaybackController { return p.state.duration.inMilliseconds / 1000.0; } + /// The player is playing (not paused, not waiting for a switch). + bool get playing => _player?.state.playing ?? false; + + // ---------------------------------------------------------- commands + + // The current player holds its media (a duration is known since + // [beginOpen]); until then commands wait in [_pending]. + bool _loaded = false; + // An open was started on the current player since it was attached. + bool _opened = false; + final _pending = []; + + /// Commands applied to mpv, per kind (tests: a switch applies queued + /// ones once). + @visibleForTesting + int seeksSent = 0; + + /// A switch is under way: no player, or its media is not loaded yet. + bool get switching => _player == null || !_loaded; + + /// Applies [actions] (from the sync diff, a user seek, the play gate): + /// at once to a loaded player, else queued for the new one, where the + /// latest seek, transport and rate apply once it is loaded. Future apply(List actions) async { - if (!_ready) return; + if (switching) { + for (final a in actions) { + _pending + ..removeWhere((p) => p.runtimeType == a.runtimeType) + ..add(a); + } + return; + } + await _run(actions); + } + + Future _flush() async { + if (_pending.isEmpty) return; + final list = [..._pending]; + _pending.clear(); + await _run(list); + } + + Future _run(List actions) async { + final p = _player; + if (p == null) return; for (final action in actions) { try { switch (action) { case SeekAction(:final position): if ((positionSecs - position).abs() < 0.5) break; - await _player! - .syncSeek(Duration(milliseconds: (position * 1000).round())); + seeksSent++; + await p.syncSeek(Duration(milliseconds: (position * 1000).round())); case TransportAction(:final playing): - if (playing) { - await player.play(); - } else { - await player.pause(); - } + playing ? await p.play() : await p.pause(); case RateAction(:final rate): - await player.setRate(rate); + await p.setRate(rate); } } catch (e, st) { debugPrint('player action $action failed: $e\n$st'); @@ -729,319 +524,36 @@ class PlaybackController { } } - /// Release the player. The view drops its texture first ([alive] goes - /// false, one frame passes), then mpv is torn down. - Future dispose() async { - await dropPreload(); + /// The controls' volume: on the player now, and on every next one. + Future setVolume(double v) async { + final clamped = v.clamp(0.0, maxVolume * 1.0); + volume.value = clamped; + final p = _player; + if (p != null) await p.setVolume(clamped); + } + + /// A seek from the controls (seek bar, keys): the sync layer's user + /// seek ([onUserSeek]). + void userSeek(Duration to) => onUserSeek?.call(to); + + /// Stops what plays (the player stays for the next media). + Future stop() async { final p = _player; if (p == null) return; - await _logSub?.cancel(); - _logSub = null; - for (final sub in _timeSubs) { - await sub.cancel(); - } - _timeSubs.clear(); - _player = null; - _video = null; - _held = false; - alive.value = false; - _clearMarks(); - await _settleUi(); + _pending.clear(); + _loaded = false; try { - await p.dispose(); - } catch (_) {} - } - - /// App close: every mpv this controller holds goes before the engine - /// does (a live one calls back into a dead isolate): the preload, the - /// session player and the one still retiring, each bounded by [each]. - /// A player an open in flight brought up meanwhile goes too. - Future disposeAll({Duration each = const Duration(milliseconds: 1500)}) async { - Future bounded(Future f) => f.timeout(each).catchError((Object _) {}); - for (var round = 0; round < 2; round++) { - final pre = _pre; - _pre = null; - await Future.wait([ - if (pre != null) bounded(_disposePreload(pre)), - bounded(_retiring), - bounded(dispose()), - ]); - if (_player == null && _pre == null) return; - } - } - - // Two frames: one may already be mid-build when [alive] flips. 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 _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 (_) {} - } - - // ------------------------------------------------------------ preload - // - // A second, hidden player holding what plays next (Up next's first - // item), open paused at its start and muted, with a small cache: its - // first seconds are in before anyone asks. [openUrl] for the same URL - // promotes it to the session player (see ARCHITECTURE.md "Getting - // ready"). One at a time. - - /// Preloading on (env `SYNC_RELAY_PRELOAD=0` turns it off; tests set it). - bool preloadEnabled = Platform.environment['SYNC_RELAY_PRELOAD'] != '0'; - - /// The preload's forward cache: its first seconds and the container's - /// index, at little memory. Raised to [maxBytes] when promoted. - static const preloadBytes = 48 * 1024 * 1024; - static const _preloadBackBytes = 8 * 1024 * 1024; - - _Preload? _pre; - - /// The URL preloaded now (null: none). - String? get preloadUrl => _pre?.url; - - /// Opens [url] in the hidden preload player (replacing any other), and - /// completes once [need] seconds are buffered or the whole media is: - /// true, false when it failed (or was dropped or replaced meanwhile). - /// The same [url] and [headers] again is the same preload unless - /// [fresh]. [ytdlPath]: mpv's yt-dlp hook for web pages. - Future preload(String url, - {Map? headers, String? ytdlPath, double need = 4, bool fresh = false}) async { - if (!preloadEnabled || !_ready) return false; - final had = _pre; - if (had != null && had.matches(url, headers) && !had.failed && !fresh) return had.ready.future; - // Set before any await: a second call for the same URL finds it. - _pre = null; - if (had != null) unawaited(_disposePreload(had)); - final p = _newPlayer(bufferSize: preloadBytes, logLevel: MPVLogLevel.error); - final pre = _pre = _Preload(p, url, headers ?? const {}); - pre.logSub = p.stream.log.listen((log) { - // ffmpeg logs through one mpv per process: the first one, and once - // that is gone (a promotion disposes it) the next one created, which - // can be this preload. Its HTTP errors still name the session's. - if (log.prefix == 'ffmpeg') return _onLog(log); - _toAppLog(log, player: 'preload '); - if (_loadFailed(log, p.state.duration)) pre.fail(); - }); - try { - await _pinBufferProps(p, preloadBytes, _preloadBackBytes); - await _pin('volume-max', '$maxVolume', p); - await _pin('mute', 'yes', p); - final path = ytdlPath ?? _ytdlPath; - if (path != null) { - pre.ytdlRaw = await _setYtdl(p, path); - pre.ytdl = true; - } - // With a view up, its own video output: mpv decodes (media_kit - // starts players with vid=no) and the first frame is there. - if (_video != null) { - pre.video = VideoController(p, configuration: VideoControllerConfiguration(hwdec: _hwdecOverride)); - await pre.video!.platform.future; - } - if (!identical(_pre, pre)) { - pre.fail(); - return false; - } - await p.open(Media(url, httpHeaders: headers ?? const {}), play: false); - if (!pre.opened.isCompleted) pre.opened.complete(); + await p.stop(); } catch (e) { - debugPrint('preload failed: $e'); - pre.fail(); - return false; - } - unawaited(_watchPreload(pre, need)); - return pre.ready.future; - } - - Future _watchPreload(_Preload pre, double need) async { - final end = DateTime.now().add(const Duration(seconds: 60)); - while (identical(_pre, pre) && !pre.failed && DateTime.now().isBefore(end)) { - final st = pre.player.state; - final secs = st.duration.inMilliseconds / 1000; - final buffered = (st.buffer - st.position).inMilliseconds / 1000; - if (secs > 0 && (buffered >= need || st.buffer.inMilliseconds / 1000 >= secs - 0.5)) { - if (!pre.ready.isCompleted) pre.ready.complete(true); - return; - } - await Future.delayed(const Duration(milliseconds: 200)); - } - if (!pre.ready.isCompleted) pre.ready.complete(false); - } - - /// Drops the preload (nothing to get ready, or something else is). - Future dropPreload() async { - final pre = _pre; - if (pre == null) return; - _pre = null; - await _disposePreload(pre); - } - - Future _disposePreload(_Preload pre) async { - pre.fail(); - await pre.logSub?.cancel(); - try { - await pre.player.dispose(); - } catch (_) {} - } - - /// [pre] becomes the session player ([_adopt]); the full cache pins - /// replace its small ones. - Future _promote(_Preload pre) async { - await pre.opened.future; - final old = _player; - if (!identical(_pre, pre) || old == null) return false; - _pre = null; - if (pre.failed) { - unawaited(_disposePreload(pre)); - return false; - } - await pre.logSub?.cancel(); - if (!pre.ready.isCompleted) pre.ready.complete(true); - final p = pre.player; - await _adopt(p, pre.video, ytdl: pre.ytdl, ytdlRaw: pre.ytdlRaw); - try { - await _pin('mute', 'no', p); - await _pinBufferProps(p); - } catch (e) { - debugPrint('promoted player settings: $e'); - } - return true; - } - - // The current player has had media opened in it (one fresh from [init] - // has not, so the first open after join or Reload video uses it). - bool _held = false; - - // The last replaced player until it is disposed: a fresh player waits - // for it, so at most one old player lingers. - Future _retiring = Future.value(); - - /// Replaces the current player with a fresh one set up like [init]'s - /// (pins, hwdec, volume, yt-dlp hook, shaders, observers), its video - /// output ready before the view swaps to it when the view is up. True - /// once it is the session player. - Future _freshPlayer() async { - await _retiring; - final old = _player; - if (old == null) return false; - final p = _newPlayer(bufferSize: maxBytes, logLevel: MPVLogLevel.warn); // see [logLevel] - VideoController? video; - var ytdl = false, ytdlRaw = ''; - try { - pinMismatches.clear(); - await _pinBufferProps(p); - await _pin('volume-max', '$maxVolume', p); - final path = _ytdlPath; - if (path != null) { - ytdlRaw = await _setYtdl(p, path); - ytdl = true; - } - // The view is up: the new output is created (and its texture - // registered) before the view swaps, so the old picture stays until - // then. Created by the view instead while the render mode is not - // known yet, so the probe hears its GL init ([video]). - if (_video != null && renderMode.peek() != RenderMode.unknown) { - video = VideoController(p, configuration: VideoControllerConfiguration(hwdec: _hwdecOverride)); - await video.platform.future; - } - } catch (e) { - debugPrint('fresh player setup: $e'); - } - // Disposed or reloaded meanwhile: that player stays. - if (!identical(old, _player)) { - try { - await p.dispose(); - } catch (_) {} - return false; - } - await _adopt(p, video, ytdl: ytdl, ytdlRaw: ytdlRaw); - return true; - } - - /// mpv's yt-dlp hook on [p] (see [useYtDlp]); returns the raw options set. - static Future _setYtdl(Player p, String path) async { - final dynamic platform = p.platform; - await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path'); - await platform?.setProperty('ytdl-format', ytdlFormat); - final raw = await AppYtDlp.instance.mpvRawOptions(); - await platform?.setProperty('ytdl-raw-options', raw); - return raw; - } - - /// [p] becomes the session player (a promoted preload or a fresh - /// player): log, marks and track observers move over, the view swaps to - /// its video output ([generation]), volume and shaders carry over, and - /// the old player is paused and disposed once the view let go of its - /// texture (as in [dispose]). - Future _adopt(SyncPlayer p, VideoController? video, {required bool ytdl, String ytdlRaw = ''}) async { - final old = _player!; - final volume = old.state.volume; - await _logSub?.cancel(); - for (final sub in _timeSubs) { - await sub.cancel(); - } - _timeSubs.clear(); - _player = p; - _video = video; - _held = true; - _ytdlSetOn = ytdl ? p : null; - _ytdlRaw = ytdlRaw; - _chapterJson = ''; - _httpError = null; - _loadErrorSent = false; - _shaders = ''; - final at = p.state.position.inMilliseconds; - position.value = Duration(milliseconds: at - at % 250); - duration.value = p.state.duration; - final ms = p.state.buffer.inMilliseconds; - buffered.value = Duration(milliseconds: ms - ms % 250); - chapters.value = const []; - tracks.value = const []; - _logSub = p.stream.log.listen(_onLog); - _requestLog(p, logLevel); - _watchMarks(p); - generation.value++; - // Normally stopped already (the coordinator leaves the previous - // media first): never two sounds at once. - unawaited(old.pause().catchError((_) {})); - try { - await p.setVolume(volume); - } catch (e) { - debugPrint('new player volume: $e'); - } - unawaited(applyShaders()); - unawaited(_refreshTracks()); - _retiring = () async { - await _settleUi(); - try { - await old.dispose(); - } catch (_) {} - }(); - } - - Future stop() async { - if (!_ready) return; - try { - await player.stop(); - } catch (e, st) { - debugPrint('player stop failed: $e\n$st'); + debugPrint('player stop failed: $e'); } } -} -enum RenderMode { - unknown, - /// OpenGL on a real GPU: shaders work. - gpu, - /// OpenGL on a software rasterizer (llvmpipe): shaders crawl. - softwareGl, - /// media_kit's software fallback: shaders are ignored. - software, + Future dispose() async { + _unwatch(); + _renderSub?.call(); + await _httpSub?.cancel(); + } } class ShaderNotice { @@ -1049,34 +561,3 @@ class ShaderNotice { final String text; const ShaderNotice(this.id, this.text); } - -/// 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; -} - -class _Preload { - final SyncPlayer player; - final String url; - final Map headers; - VideoController? video; - StreamSubscription? logSub; - /// Completes once the media is handed to mpv (or the preload failed). - final opened = Completer(); - bool ytdl = false; - String ytdlRaw = ''; - bool failed = false; - final ready = Completer(); - _Preload(this.player, this.url, this.headers); - - bool matches(String url, Map? headers) => - url == this.url && mapEquals(headers ?? const {}, this.headers); - - void fail() { - failed = true; - if (!ready.isCompleted) ready.complete(false); - if (!opened.isCompleted) opened.complete(); - } -} diff --git a/apps/desktop/lib/src/playback/yt_dlp_app.dart b/apps/desktop/lib/src/playback/yt_dlp_app.dart index fecdc84..ae9ec08 100644 --- a/apps/desktop/lib/src/playback/yt_dlp_app.dart +++ b/apps/desktop/lib/src/playback/yt_dlp_app.dart @@ -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 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 = f})>{}; static const _probeTtl = Duration(minutes: 10); diff --git a/apps/desktop/lib/src/queue/queue_controller.dart b/apps/desktop/lib/src/queue/queue_controller.dart index f3cdb0b..c962441 100644 --- a/apps/desktop/lib/src/queue/queue_controller.dart +++ b/apps/desktop/lib/src/queue/queue_controller.dart @@ -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; diff --git a/apps/desktop/lib/src/session/media_pipeline.dart b/apps/desktop/lib/src/session/media_pipeline.dart new file mode 100644 index 0000000..0d033b4 --- /dev/null +++ b/apps/desktop/lib/src/session/media_pipeline.dart @@ -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 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 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 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 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 _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? 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 _addSubtitles(HostedPlayer p, List 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? _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 open(String url, {Map? 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 _openInPlayer(String url, Map? 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? 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 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 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 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 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 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 recover(StateBroadcast r, List 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 = []; + 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 prepare(String url, {Map? 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 _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 dropPrepared() async { + final pre = _pre; + if (pre == null) return; + _pre = null; + await _disposePrepared(pre); + } + + Future _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 _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 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 headers; + HostedPlayer? player; + HostedVideo? video; + StreamSubscription? logSub; + List subtitles = const []; + + /// Completes once the media is handed to mpv (or it failed). + final opened = Completer(); + bool failed = false; + final ready = Completer(); + _Prepared(this.url, this.headers); + + bool matches(String url, Map? 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 headers; + final List 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; +} diff --git a/apps/desktop/lib/src/session/media_plan.dart b/apps/desktop/lib/src/session/media_plan.dart new file mode 100644 index 0000000..3ca6001 --- /dev/null +++ b/apps/desktop/lib/src/session/media_plan.dart @@ -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 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 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(); +} diff --git a/apps/desktop/lib/src/session/relay_feeder.dart b/apps/desktop/lib/src/session/relay_feeder.dart new file mode 100644 index 0000000..4a4e140 --- /dev/null +++ b/apps/desktop/lib/src/session/relay_feeder.dart @@ -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=`), 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 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; +} diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index 854244e..13f571c 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -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(false); + Signal 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? _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 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 _openMedia(String url, {Map? 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? 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 _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 _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 preloadMedia(String url, {Map? 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 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 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 _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 _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 [] : this.media.feeder.warmTracks(queueId); + final fed = warm.isNotEmpty; final check = fed ? null : LinkRelayMedia(uri, ytDlp: y, probe: probe); var size = 0; final List 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 _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 _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(); } } diff --git a/apps/desktop/lib/src/session/status.dart b/apps/desktop/lib/src/session/status.dart index 3411f7a..9f7fd4d 100644 --- a/apps/desktop/lib/src/session/status.dart +++ b/apps/desktop/lib/src/session/status.dart @@ -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) { diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index 0d03014..39c2805 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -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 { 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 { ], ); }), - _MiniSeekBar(onSeek: (d) => playback.player.seek(d)), + _MiniSeekBar(onSeek: playback.userSeek), ], ), ), diff --git a/apps/desktop/lib/src/sync/user_seek.dart b/apps/desktop/lib/src/sync/user_seek.dart index db099fc..1696ff7 100644 --- a/apps/desktop/lib/src/sync/user_seek.dart +++ b/apps/desktop/lib/src/sync/user_seek.dart @@ -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 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 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: diff --git a/apps/desktop/test/media_plan_test.dart b/apps/desktop/test/media_plan_test.dart new file mode 100644 index 0000000..a5bf76e --- /dev/null +++ b/apps/desktop/test/media_plan_test.dart @@ -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 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? media, + {List? 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 _relay(MediaRelay r) => r.toJson(); + +SourcePlan _plan(StateBroadcast r, + {String self = 'm1', MediaOrigin? origin, bool resolve = true, Set 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)>[ + ('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? ?? const {}); + expect(_describe(got), want); + }); + } + }); + + test('a Jellyfin relay channel only when someone present needs it', () { + StateBroadcast with_(List 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}', + }; diff --git a/apps/desktop/test/media_switch_e2e_test.dart b/apps/desktop/test/media_switch_e2e_test.dart new file mode 100644 index 0000000..3df04d8 --- /dev/null +++ b/apps/desktop/test/media_switch_e2e_test.dart @@ -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 _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 _poll(FutureOr 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 = []; + 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); + } + }); +} diff --git a/apps/desktop/test/queue_switch_e2e_test.dart b/apps/desktop/test/queue_switch_e2e_test.dart index c5eda18..0721c0b 100644 --- a/apps/desktop/test/queue_switch_e2e_test.dart +++ b/apps/desktop/test/queue_switch_e2e_test.dart @@ -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 = []; + // (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 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(); } diff --git a/apps/desktop/test/readahead_e2e_test.dart b/apps/desktop/test/readahead_e2e_test.dart index 2ed0136..4a9c30c 100644 --- a/apps/desktop/test/readahead_e2e_test.dart +++ b/apps/desktop/test/readahead_e2e_test.dart @@ -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(); } }, diff --git a/apps/desktop/test/relay_e2e_test.dart b/apps/desktop/test/relay_e2e_test.dart index 2e19c85..e5e5cca 100644 --- a/apps/desktop/test/relay_e2e_test.dart +++ b/apps/desktop/test/relay_e2e_test.dart @@ -35,15 +35,130 @@ Future _media() async { } Future _poll(FutureOr 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 _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 size() => file.length(); + + @override + Stream> 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.delayed(Duration(milliseconds: due)); + yield part; + } + } + } + + @override + Future 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 join() async { + final w = ws = await WebSocket.connect('ws://127.0.0.1:$port/sync'); + final welcome = Completer(); + 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 swap() async { + ws!.add(jsonEncode({ + 't': 'swap', + 'media': {'name': name, 'size': await file.length(), 'duration': 0, 'relay': {}}, + })); + } + + Future 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 drop() async { + _alive?.cancel(); + await src?.close(); + await ws?.close(); + } + + Future back(int bytesPerSec) async { + await join(); + await feed(bytesPerSec); + } + + Future close() async { + _alive?.cancel(); + await src?.close(); + await ws?.close(); + } +} + /// Serves one file with ranges; [requests] counts hits. Future<(HttpServer, List)> _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 = []; + 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 = []; + final subs = >[]; + 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(); + } + }); } diff --git a/apps/desktop/test/shaders_mpv_test.dart b/apps/desktop/test/shaders_mpv_test.dart index 8a40507..62c3f97 100644 --- a/apps/desktop/test/shaders_mpv_test.dart +++ b/apps/desktop/test/shaders_mpv_test.dart @@ -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(); } }); } diff --git a/apps/desktop/test/singletons_test.dart b/apps/desktop/test/singletons_test.dart index b0fc4ef..9798285 100644 --- a/apps/desktop/test/singletons_test.dart +++ b/apps/desktop/test/singletons_test.dart @@ -12,7 +12,7 @@ const baseline = { 'UpdateController': 8, 'Profile': 8, 'FolderStore': 6, - 'AppYtDlp': 4, + 'AppYtDlp': 2, 'RecentLinks': 3, }; diff --git a/apps/desktop/test/sync_playback_e2e_test.dart b/apps/desktop/test/sync_playback_e2e_test.dart index 335488e..8aa0718 100644 --- a/apps/desktop/test/sync_playback_e2e_test.dart +++ b/apps/desktop/test/sync_playback_e2e_test.dart @@ -26,8 +26,7 @@ Future _poll( Future _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 (_) {} } diff --git a/apps/desktop/test/video_reload_e2e_test.dart b/apps/desktop/test/video_reload_e2e_test.dart index 211a22a..cbcb07d 100644 --- a/apps/desktop/test/video_reload_e2e_test.dart +++ b/apps/desktop/test/video_reload_e2e_test.dart @@ -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 _poll( @@ -26,47 +28,46 @@ Future _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(); diff --git a/apps/desktop/test/volume_readout_test.dart b/apps/desktop/test/volume_readout_test.dart index 3b2b130..30232e7 100644 --- a/apps/desktop/test/volume_readout_test.dart +++ b/apps/desktop/test/volume_readout_test.dart @@ -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 { diff --git a/apps/desktop/test/ytdl_headers_e2e_test.dart b/apps/desktop/test/ytdl_headers_e2e_test.dart index 0155cea..5f05f6b 100644 --- a/apps/desktop/test/ytdl_headers_e2e_test.dart +++ b/apps/desktop/test/ytdl_headers_e2e_test.dart @@ -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 _files; int ok = 0, forbidden = 0; + final cookies = []; 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 _serve(HttpRequest req) async { final res = req.response; + if (req.uri.pathSegments.firstOrNull == 'video') { + res.headers.contentType = ContentType.html; + res.write('Fake page'); + 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 _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 _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'} : {}; 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 = []; 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 _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)); + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eb33f5e..c3f6883 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -9,7 +9,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format: |---|---|---| | `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`), the library's L1 (`library/`: release and folder names, `groupLocal`); `testing.dart` (test support for all three packages, not in `core.dart`: `FlakyProxy`) | pure Dart, no Flutter | | `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter | -| `apps/desktop` | Flutter app: UI, `AppGraph` (composition root), `SyncClient`, `Connection`, `SessionCoordinator`, `Navigation`, `YourServer` (`ServerIsolate`), `PlaybackController` (media_kit/mpv), update UI (`UpdateController`) | ships with `starsync-server` | +| `apps/desktop` | Flutter app: UI, `AppGraph` (composition root), `SyncClient`, `Connection`, `SessionCoordinator`, `Navigation`, `YourServer` (`ServerIsolate`), `MediaPipeline` (opening media), `PlaybackController` (the session player's marks and commands), the player host (`PlayerHost`, `MediaKitHost`: the only media_kit user besides the `Video` widget glue), update UI (`UpdateController`) | ships with `starsync-server` | | `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only | | `deploy` | systemd unit for the dedicated server | | @@ -132,7 +132,7 @@ only stopping sharing sends guests out. `AppGraph` (`app/app_graph.dart`) is built once in `main.dart` (`AppGraph.build`) and constructs and disposes the app's systems: the -`Ports`, the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the +`Ports` (the player host among them, handed to the coordinator), the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the `SessionCoordinator`, the connect form and recents (`ConnectStore`, `state/connect_prefs.dart`), `YourServer`, `Navigation` and the library's matches (`ShowMatcher`, "Library", whose `refForPath` it hands the @@ -264,8 +264,8 @@ Loosely modeled on Syncplay, with a different readiness design. pause after a newer play. Between events: drift over 5 s for more than 2 s seeks, ahead by more than 1.5 s sets rate 0.95 until level, otherwise nothing. -- **User seeks**: the player is a `SyncPlayer` whose public `seek` is the - user's: the session's seek bar (`ChapterSeekBar`, one `seek` per pointer +- **User seeks**: a hosted player's public `seek` is the user's (its + `syncSeek` moves mpv): the session's seek bar (`ChapterSeekBar`, one `seek` per pointer move while dragging and one on release, like media_kit's), the arrow keys, skips, chapter jumps and `seekTo` all become explicit events. The player moves at once (scrub preview); `UserSeekGate` sends one `seek` intent with a fresh @@ -274,8 +274,9 @@ Loosely modeled on Syncplay, with a different readiness design. most), server seeks never touch the player; the echo of our own seek is not applied either (the player is already there). A server seek that has not landed after 1.5 s is re-applied once (`_checkSeekLanded`), to the server - position recomputed now, and only while its event is still current and the - user is not seeking. + position recomputed now, and only while its event is still current, the + user is not seeking and the relay it reads has its source ("Viewer + recovery" under "Media pipeline"). - **Liveness**: WS ping every 5 s; a socket without any frame for 15 s (`silence`) is closed (4015) and its member goes `gone`; the grace, the `gone` notice, the joiner bound and the joining timeout follow. The @@ -406,8 +407,8 @@ Three sources, same sync. Any of them can also go through the relay - **Link**: the media field holds an http(s) URL. It travels as `media.url` in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it - directly (a web page through mpv's yt-dlp hook, see "yt-dlp"); the - server is only the clock. A pasted link that + directly (a web page through its own yt-dlp, see "yt-dlp"); the server + is only the clock. A pasted link that `parseJellyfinLink` recognises goes up as a Jellyfin ref instead when the ServerId is known (in the link, else `/System/Info/Public`), so its `api_key` is never shared (`mediaForLink`, host and swap alike). @@ -452,7 +453,8 @@ open-ended), then every seek is a fresh `bytes=N-` connection. A server outage of a few seconds is ridden out from mpv's cache with no error. Longer outages exhaust the cache and rely on reconnect (not chaos-tested yet). -mpv settings (`PlaybackController`): `Player` with `bufferSize` 256 MiB +mpv settings (the player host, `platform/desktop/media_kit_host.dart`, +set on every player before its first open): `Player` with `bufferSize` 256 MiB (media_kit's `bufferSize` sets both `demuxer-max-bytes` and `demuxer-max-back-bytes`), `libass` on; pinned via runtime property: `demuxer-readahead-secs=150`, `cache-secs=150` (about two minutes of @@ -463,21 +465,25 @@ file is ~8), `demuxer-max-back-bytes=67108864`, (comma form; this mpv build rejects colon-joined): a dropped connection or a 5xx is retried, at most 3 times per read and 4 s apart at most; a 4xx (a CDN refusing a request) fails at once instead of being retried -(older ffmpeg ignores `reconnect_max_retries`). A load that fails (mpv's +(older ffmpeg ignores `reconnect_max_retries`), `ytdl=no` (pages are +resolved by the app, never by mpv's Lua hook). A load that fails (mpv's fatal "No video or audio streams selected", or an EDL track that cannot be opened) before a duration is known is reported once per open (`onLoadError`), with ffmpeg's HTTP status when this player gets ffmpeg's log (ffmpeg logs through one mpv instance per process: the -first one, and once that is disposed the next one created; the preload -player passes the HTTP errors it gets on to the session player). Since -every media gets its own player, the session player is often not that -instance: the status is a bonus, load errors are detected either way. That, a yt-dlp hook error and a failed `openUrl` become -the coordinator's `playerError` in plain words (`playErrorText`, core +first one, and once that is disposed the next one created; the host +passes the HTTP errors any player gets on as `httpErrors`). Since every +media gets its own player, the session player is often not that +instance: the status is a bonus, load errors are detected either way. +That, a page's resolve error (yt-dlp's words and the cookies it used, +D11, see "yt-dlp") and a failed open become the coordinator's +`playerError` in plain words (`playErrorText`, core `link_probe.dart`: 412/precondition/login/cookies/sign in → "This site needs cookies — Settings → Player → yt-dlp → Cookies", 403 → "Not allowed (403)", 404/410 → "Not found (404)", "Unsupported URL" → "Not a video page yt-dlp knows", network errors → "Couldn't reach the site", anything -else as is). It rides in `report.error` (see "Player error" in the sync +else as is; `withCookieNote` adds the cookies to the two sign-in texts). +It rides in `report.error` (see "Player error" in the sync model) until the media opens (a new duration) or the server's `mediaId` moves on; the pill shows "Can't play this: ..." with Retry (Reload video). The cache is in RAM: `cache-on-disk=no` is pinned (media_kit @@ -486,8 +492,8 @@ sets it on without a `cache-dir`, so mpv fell back to RAM anyway, warning ignores invalid values silently, so every pin is read back and a mismatch is logged and kept in `pinMismatches`. Measured against the `/m` endpoint (`readahead_e2e_test`): a paused player buffers ~147 s ahead (10 s before). -media_kit has no public `setProperty`/`getProperty`; the code calls them -through a guarded dynamic call on `player.platform`. Hardware decoding comes from Settings (auto, off, custom) +media_kit has no public `setProperty`/`getProperty`; the host calls them +through a guarded dynamic call on `player.platform` (`HostedPlayer`). Hardware decoding comes from Settings (auto, off, custom) and is passed to the `VideoController`; env `SYNC_RELAY_HWDEC` (e.g. `no`) overrides it. Auto is mpv `auto` on Linux and `auto-copy` on Windows (`autoHwdec`, also for a blank custom value): media_kit renders through @@ -518,8 +524,9 @@ turns shaders off (saved) with a toast. Render mode: media_kit's Linux/Windows video output creates mpv's render context (OpenGL, else its software renderer) when the `VideoController` is -created, and nothing in its API says which. The player therefore requests -mpv logs at `v` while the controller is created: OpenGL init logs +created, and nothing in its API says which. The host therefore requests +mpv logs at `v` while the first output of an app start is created +(`PlayerHost.video(probe: true)`): OpenGL init logs `GL_VERSION` under `libmpv_render` (`RenderMode.gpu`; "suspected software renderer" means llvmpipe, `softwareGl`); none within 1.5 s of the controller being ready means the @@ -534,48 +541,121 @@ unset); from that `v` window only mpv's GL init lines (`GL_VENDOR`, frame, `hwdec-current` and the `hwdec` asked for (the first one per app start also `mpv-version` and `ffmpeg-version`). Why media_kit fell back to software it only prints on stderr (`media_kit: VideoOutput: ...`), which -the app does not capture. Otherwise the session player logs at `warn` -(`PlaybackController.logLevel`, switched with `mpv_request_log_messages` -on media_kit's handle): media_kit drains mpv's log on the UI thread, one -message at a time, so a chatty level, or ffmpeg logging every retry of a -refused request, could starve the UI. Shader and yt-dlp hook errors are -`error` lines and still arrive. The thumbnail players keep -media_kit's default (`error`) and nothing listens to their log. The hardware path is +the app does not capture. Otherwise a session player logs at `warn` +(`sessionLogLevel`; the host switches levels with +`mpv_request_log_messages` on media_kit's handle, the only raw mpv call +in the app): media_kit drains mpv's log on the UI thread, one message at +a time, so a chatty level, or ffmpeg logging every retry of a refused +request, could starve the UI. Shader errors are `error` lines and still +arrive. The host writes every session and preload player's warnings and +errors to the app log (`mpv`, a few per kind at a time, `preload ` +before the prepared player's). Thumbnail players log nothing. The hardware path is fragile: media_kit borrows whatever EGL context GDK left current on the main thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change can silently fall back to software. -Player lifecycle: a `Player` and its `VideoController` live for one -media: created on join, disposed on leave, replaced by `recreate()` for -"Reload video" (button or Ctrl+R; volume kept), and replaced on every -media change, so nothing a player went through (a stuck video output, a -frozen frame) carries over to the next media. `openUrl` promotes the -preload when it holds the new media (see "Getting ready" under "Up -next"); otherwise, when the player already held media, it creates a fresh -one first, set up as on join (pins, hwdec, yt-dlp hook, shaders, log -level, observers) and, with the view up and the render mode known, its -`VideoController` created and ready before the view swaps to it (else the -view creates it and the render-mode probe runs on it). Both go through -one hand-over (`_adopt`): observers move over, the view swaps to the new -output (`generation`), volume (and so mute) and shaders carry over, and -the old player is paused and disposed once the view let go of its -texture (as on leave). A fresh player waits for the previous old one to -be gone, so at most one old player lingers. The first open after join or -Reload video uses the player just created; joining back to the media the -player held keeps it (`_openedFor`); a dropped connection never touches -the player. Cost (`SwitchTrace` -`player`, headless, so without the `VideoController`): 45 to 65 ms per -swap for one app in the process (mpv created and initialised, then -pinned), about 150 ms with three in one process -(`queue_switch_e2e_test` with `WT_SWITCH_PRELOAD=0`), against a cold -switch of about 1.5 s. `openUrl` opens with `play: false`; play/pause/seek/rate come only from -`StateDiff` actions, the play gate (`localPlayAllowed`: self in-set and -ready) or the user's own seeks (see Sync model). A server seek within 0.5 s of the current position is skipped. `leave()` -stops the player (otherwise audio keeps playing). On media change -(`mediaId` bump) the coordinator stops the previous media at once (no old -picture or sound while the new one resolves, is fetched or opens), then opens the new URL -(or promotes the preload holding it) and `StateDiff` seeks to 0, holds -and resets rate. +**Player host** (`PlayerHost`, `platform/ports/player_host.dart`; the +desktop's `MediaKitHost`, one per app in `Ports.players`, tests make +their own): creates players by role (`session`: the full cache, `warn`; +`preload`: the prepared one, 48 MiB ahead, muted, `error`; `thumbnail`: +no outputs, no cache, no log) with every pin set before the caller's +first command (`pinMismatches` lists the read-backs that differ), gives +a player its video output (`HostedVideo`, with the hwdec policy: +`SYNC_RELAY_HWDEC`, else the setting, `auto-copy` on Windows), runs the +render-mode probe and its diagnostics (`renderMode`, also after leaving), +routes mpv's log, and owns the order players go in: `retire` (pause, let +the view drop the texture: two frames and a route pop, then dispose; a +new session player waits for the last one, so at most one old player +lingers), `dispose` (one no view showed) and `disposeAll` (app close: +every live player, each bounded). A `HostedPlayer` is one mpv: state and +streams (position, duration, buffer, volume, completed, width, log), +open, play/pause/stop, `seek` (the user's, see "User seeks") and +`syncSeek`, rate, volume, properties, commands, `pin` (set and read +back), its log level. Nothing else in the app imports media_kit except +the `Video` widget glue (`session_player.dart`, `videoControllerOf`). + +**Media pipeline** (`MediaPipeline`, `session/media_pipeline.dart`, owned +by the coordinator): the one way to open media, plan → resolve → open → +ready. +- Plan (`planMedia`, `session/media_plan.dart`, pure; `media_plan_test` + holds the table): from `state.media`, what this member serves (its + `MediaOrigin`: a file or a link it passes on, a Jellyfin item it put + up; set at its swap, claimed by the state that names it the relay's + source), its own capabilities and who is present, where this app reads + the media from (`SourcePlan`): your server's file (`/m`), a link + everyone opens (`DirectLink`; a page is resolved here), another + member's relay or the server's fetch (`/r`, `waiting` until it is fed: + `relayWaitsForSource`), its own file or link (it plays its own copy and + feeds the relay), a Jellyfin item (`JellyfinSource`: own account, the + server's login `/j`, the member passing it on `/r`), or `Unresolvable` + (a page on a device that cannot resolve: `LinkResolver.resolvesLinks` + false; who can is read from `members[].caps.resolve`; desktop always + can, so this is Android's plug-in point). +- Resolve: a Jellyfin item through this app's account (or the plan + resolved ahead for Up next, `warmJellyfin`), else the server's login, + else the member passing it on, else blocked (`openJellyfin`); a web + page through the app's own yt-dlp (`LinkResolver.probe`: yt-dlp's + stream URLs, an EDL of video and audio, the CDN's headers with its + cookies, the subtitles; see "yt-dlp"), failing in plain words with the + cookies that were used (`ResolveFailure`, D11); a relay not fed yet is + opened once `relay.live`. +- Open: a player per media through the host. The prepared player + (Up next's, see "Getting ready") is promoted when it holds the same URL + and headers; else, when the session player already held media, a fresh + one takes over (pins, volume, shaders; with the view up and the render + mode known, its video output ready before the view swaps to it, else + the view makes it and the probe runs on it); the same media again (a + reconnect, joining back) keeps the player. Every hand-over is + `PlaybackController.attach`: log, marks and track observers move over, + the view swaps to the new output (`generation`), volume and shaders + carry over, the old player is retired. The first open after join or + Reload video uses the player just created; a dropped connection never + touches the player. "Reload video" (button or Ctrl+R) retires the + player, starts a fresh one and opens the media again (a page resolved + afresh: its stream URLs may have expired). Cost (`SwitchTrace` `player`, + headless, without the video output): 45 to 65 ms per swap for one app + in the process, about 150 ms with three in one process + (`queue_switch_e2e_test` with `WT_SWITCH_PRELOAD=0`), against a cold + switch of about 1.6 s. +- Ready: the coordinator reports at once after an open (`onOpened`) and + when the buffer reaches the gate's need while held, not on the next + tick. +- Commands: play/pause/seek/rate come only from `StateDiff` actions, the + play gate (`localPlayAllowed`: self in-set and ready) or the user's + own seeks (see Sync model); a server seek within 0.5 s of the current + position is skipped. They go through `PlaybackController.apply`: while + a switch is under way (no player, or its media has no duration yet) + they wait, the latest seek, transport and rate apply once to the new + player when its media is in; mpv never gets a seek it cannot run + (media_kit logged "error running command seek" / `event:5` for each). + The volume is the controller's (`setVolume`), on every next player. + Opens are paused (`play: false`). On media change (`mediaId` bump) the + previous media stops at once (no old picture or sound while the new + one resolves, is fetched or opens) and `StateDiff` seeks to 0, holds + and resets rate. Leaving stops and releases the players. +- Being a relay source (`RelayFeeder`, `session/relay_feeder.dart`): the + current media's channel and Up next's warm relay (see "Relay"), with + the step-1 re-dial (a source rides through this app's own drop). A + Jellyfin item this member put up is passed on only once someone + present needs it (`jellyfinRelayNeeded`: the server's login does not + cover it and a present member does not play that server with its own + account; one whose `caps` say Jellyfin but whose `jf` is not reported + yet is asked again with the next state), so a session where everyone + has an account opens no relay channel. +- Viewer recovery (D6): while the relay this app reads has no source + (`sourceGone`, or not live with the source away), it plays what is + buffered. A seek past that (a server seek, a drift correction) is + tried once; more wait, the seek check does not re-apply, and the + report says nothing is buffered once this player cannot be where the + server is (`starved`), so the server holds everyone with the reason + `source` ("Waiting for Bob"; see "App events and platform ports"). + When the source is back, one seek to the server position resumes the + player, and a player that never loaded meanwhile (it opened while the + source was gone and its first bytes never came) opens again in a fresh + player. Measured (`relay_e2e`, a source just above the bitrate that + drops, a seek past what the server got, a member joining meanwhile): + at most one seek per viewer while it is gone, nobody's player broken, + all play from the seek once it is back. Chapters, segments, tracks (`PlaybackController` signals, values from `playback/media_marks.dart`, no widgets; any controls UI reads them): @@ -716,7 +796,7 @@ and URL, played together by viewers as an mpv EDL (it writes it to a playlist and `loadlist`s it) and mpv keeps both streams in sync across seeks; one URL keeps the app's "one media URL" path (sync, swap, reopen) as is. The source member plays the page -itself (yt-dlp hook) like any other link. +itself (its own resolve, see "yt-dlp") like any other link. A page with only segmented streams (HLS `m3u8_native`, DASH `http_dash_segments`, e.g. X's HLS, generic `.m3u8`/`.mpd`) is relayed as @@ -757,11 +837,11 @@ How bytes move: 6.6 s unchunked) and even beats mpv reading YouTube directly, since the buffer reads ahead at full speed. - The server keeps one buffer per track of a relayed media (one for a - file), a `RelayBuffer` for bytes: 1 MiB slices in - memory, filled by one sequential pull at a time. Kept: the first and + file), a `RelayBuffer` for bytes: 256 KiB slices in + memory (`relaySliceBytes`), filled by one sequential pull at a time. Kept: the first and last 2 MiB (container indexes; mpv reads both on open and on seeks), 64 MiB behind the slowest viewer and 256 MiB past the furthest, the - same as each viewer's mpv buffer (`PlaybackController`), so a skip + same as each viewer's mpv buffer (the player host's pins), so a skip inside the viewers' buffer window is served from memory. Evicted by distance from the viewers, never LRU. With 2+ viewers the source sends each byte about once. Several tracks split the ahead and behind budgets @@ -789,12 +869,21 @@ How bytes move: opened in 0.02 s from the buffer. - Filling a byte buffer: a viewer blocked on a slice first (oldest wait first, handing the source over at slice boundaries), then the pinned - slices, then read-ahead from the slowest viewer. Nothing is fetched while nobody - watches through the relay (a Jellyfin source with only account holders - uploads nothing). + slices, then read-ahead from the slowest viewer. A viewer reads the + slice being filled as its bytes arrive, not once it is whole (mpv's + demuxer, blocked in a read, takes a seek only once that read returns). + Nothing is fetched while nobody watches through the relay (a Jellyfin + source with only account holders uploads nothing). - mpv leaves superseded requests open but unread after a seek: only a viewer's newest request moves its position or asks the source for - anything; older ones still get bytes that are already buffered. + anything; older ones still get bytes that are already buffered, and a + request a newer one superseded stops holding the pull at once, so a + seek's pull starts without waiting for the old slice to finish. + Measured (`relay_e2e` "a seek past what a slow source has sent", a + source at about 3x a 3 Mbit/s mp4, the gate's 4 s of buffer): a seek + into what the source has not sent plays there after 2.8 to 2.9 s (4.0 + to 4.3 s with 1 MiB slices served whole and the superseded request + holding the pull). - Flow control on the source (`RelayWindow`): one in-flight budget per connection, 2 x delivery rate x min RTT (rate max-filtered over 2 s), clamped 256 KiB to 8 MiB; the server acks bytes as they arrive. A @@ -834,20 +923,21 @@ How bytes move: media_kit's mpv gives up on a silent request after 5 s (`network-timeout`) with "could not be opened". So viewers open a member relay's `/r/` URL only once `relay.live` is set, as for a - server fetch (`_waitForSource`; "waiting for to pass the video - on…" meanwhile), unless the source already left (then they open what - the server has); Up next preloads wait the same way. The putter starts + server fetch (`relayWaitsForSource` in the media plan; "waiting for + to pass the video on…" meanwhile), unless the source already + left (then they open what the server has); Up next preloads wait the + same way. The putter starts its source before opening its own player, and the source reads its tracks' sizes at once. - Source away (D6): viewers keep playing from the buffer; a request waits up to 60 s for missing bytes. The source member going `gone` does not end the relay: it lives through the source's grace (`media.relay.sourceGone`, `ServerHub.refreshRelaySource`). The source - (`RelaySource`, kept by the coordinator through the app's own drop) - reconnects its channel with backoff (its token lasts through the - grace; a half-open channel is caught by its 5 s WebSocket ping) and - sends its `info` again, the same on every dial (the coordinator passes - the media's `name` and `duration`): the same as its first (sizes, piece + (`RelaySource`, kept by the media pipeline's `RelayFeeder` through the + app's own drop) reconnects its channel with backoff (its token lasts + through the grace; a half-open channel is caught by its 5 s WebSocket + ping) and sends its `info` again, the same on every dial (the feeder + passes the media's `name` and `duration`): the same as its first (sizes, piece shapes, kinds, and name and duration when given) carries on, the buffer filler pulling what is missing; another one is closed with 4002 and ends the relay (`ServerHub.relayOtherMedia`); a second channel @@ -860,6 +950,10 @@ How bytes move: a viewer opening then opens the buffer instead of waiting) and " left — playing what the server has" once it left the server; the source gone for 5 s (`sourceDropNotice`) or leaving is also a toast, once each. + A viewer's buffer running dry holds everyone (`state.hold.reason` + `source`: "Waiting for "); the viewer's side, never seeking past + what it has and resuming when the source is back, is "Viewer recovery" + under "Media pipeline". - Stats (`relay_stats.dart`, `ServerHub.sampleRates` on the 1 s heartbeat; wire in [PROTOCOL.md](PROTOCOL.md)): the source's rate is its bytes over the time a pull is in flight (`FeedClock`, one per media @@ -942,18 +1036,32 @@ all three link modes. Nothing is bundled or installed by hand. → Player → yt-dlp → Cookies (a browser and profile, or a file picked or dropped, kept whole: every site in it; "Remove cookies"; the shell's own drop target is off on Settings so only this box takes the file); your server shares the folder, - so it uses them too. mpv's hook gets the same as `ytdl-raw-options` - (`cookies=%N%` / `cookies-from-browser=%N%`, length-quoted), - re-set before each external link when it changed, after - `sub-langs=all,-danmaku` (Bilibili's danmaku XML is served raw-deflate: - ffmpeg warns on every read and the video waits on the track). The dedicated + so it uses them too. A player gets them through the app's resolve + (the format's cookies as its Cookie header), never on its own. The dedicated server: `--yt-dlp-cookies ` / `ytDlpCookies` converted into its folder at start; without it the copy and browser setting are removed. Values are never logged and never leave the machine. + A resolve that fails in a way that may be about signing in (cookies, + log in, "No video formats found") says which cookies it used (D11, + `YtDlp.cookieNote`, `YtDlpException.cookies`, `LinkProbe.cookies`, + `withCookieNote`): none; the file and how many of its cookies are for + the site (`cookieSite`); a browser and how many cookies for the site + yt-dlp reads from it (one run without a URL, `--cookies-from-browser + --cookies `, which writes the browser's jar to a + 0600 file in the yt-dlp folder, counted and deleted at once), or that + it could not read them. "No video found: it may need signing in (used + Firefox's cookies, which have none for bilibili.com: sign in to + bilibili.com there)". - **Resolve** (`YtDlp.resolve`, `resolveArgs`): `yt-dlp -J --no-playlist --no-warnings - --ignore-config` plus the cookies, 25 s timeout: title, uploader, duration, thumbnail, - live or not, and what a relay reads (`pickTracks`), each format with - its URL, `http_headers` and `http_chunk_size`. Only plain http(s) + --ignore-config -f bv*[height<=1080]+ba/b --write-subs --sub-langs + all,-danmaku` plus the cookies, 25 s timeout: title, uploader, duration, + thumbnail, live or not; what a player opens (`YtDlpPlay`: yt-dlp's + `requested_formats`, else the top-level format; http(s), HLS, or a DASH + manifest; one URL, or an mpv EDL of the video and the audio; the first + format's `http_headers` plus its `cookies` as a Cookie header; the + requested subtitles but Bilibili's danmaku, an XML comment feed); and + what a relay reads (`pickTracks`), each format with its URL, + `http_headers` and `http_chunk_size`. For a relay only plain http(s) formats without DRM count: - the best single file (`pickSingleFile`: video and audio not `none`, a video container, tallest up to 1080p, else the shortest above), unless @@ -986,18 +1094,19 @@ all three link modes. Nothing is bundled or installed by hand. under the button) and offers "Put it up anyway" (`LinkMedia.anyway`), for when the server or a friend with cookies may still open it; `swapLink` refuses it the same way for any other caller. -- **Everyone fetches it**: the swap sends the page URL. Before opening an - external link every app points mpv's hook at its managed copy - (`script-opts=ytdl_hook-ytdl_path=...`, `ytdl-format=bv*[height<=1080]+ba/b`, - once per player), waiting for the download unless the URL names a media - file. A hook error is a player error (see the mpv settings under "Media"). The hook (mpv - 0.41) gives mpv the first requested format's `http_headers` (User-Agent - as `user-agent`; Referer, Cookie, X-Forwarded-For as file-local - `http-header-fields`, which win over the empty list media_kit sets on - load) and its `cookies` (`stream-lavf-o`), for every track of its EDL: - Bilibili's CDN answers 403 without the Referer. The app never opens a - resolved format URL in mpv itself (the putter of "Through me" plays the - page through the hook too); relays read them with yt-dlp's headers. +- **Everyone fetches it**: the swap sends the page URL. Every app + resolves it with its own yt-dlp (the media pipeline's resolve step, + `LinkResolver.probe`, waiting for the download on first use) and opens + what yt-dlp picked (`YtDlpPlay.mpvUrl`) with its headers as mpv's + `http-header-fields` (media_kit's `Media.httpHeaders`), for every + track of the EDL: Bilibili's CDN answers 403 without the Referer. Its + subtitles are added once the media is in (`sub-add auto`). mpv's + own Lua yt-dlp hook is off (`ytdl=no`): one resolve path for every mode, + cookies and headers always the app's, and a libmpv without Lua plays + links the same. A resolve that fails is a player error in plain words, + with the cookies it used. Stream URLs are bound to the resolving + machine, so each app resolves its own; a page is resolved again at + Reload video and for Up next's 30-minute refresh (they expire). - **Through me**: the putter's probe must give tracks, and a pieces track's playlist must be readable, else the swap is refused ("Live streams can't be passed on", DRM, or nothing a relay can read) with a @@ -1174,8 +1283,9 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in of a link this app puts up (its swap reads it). Per kind: a Jellyfin item is resolved with this app's account (`resolveJellyfinRef`: PlaybackInfo and the item) into - `SessionCoordinator.warmJellyfin`, which `_playJellyfin` takes instead - of resolving when the same ref goes up within 35 minutes and the + `SessionCoordinator.warmJellyfin`, which the media pipeline's resolve + (`openJellyfin`) takes instead of asking again when the same ref goes + up within 35 minutes and the account is unchanged; without an account it is the server's `/j` URL when `state.relayNext` says the server's login passes it on (see "Jellyfin relay"). A link everyone opens is that link; the app that @@ -1184,35 +1294,36 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in read it from there). A link passed on "through me" and a file on their adder's app (the link, the path; not the host's own file, which plays from `/m`), which also feeds the server's warm relay of it - (`QueueController._feedWarm`, `SessionCoordinator.feedWarm`; a + (`QueueController._feedWarm`, `RelayFeeder.feedWarm`; a Jellyfin item's from the member who puts it up, with its resolved stream, when the server asks for one); everyone else, and everyone for a server-fetched link, preloads that warm relay's URL - (`SessionCoordinator.warmRelayUrl`) once `queue[0].media.relay.live` + (`MediaPipeline.warmRelayUrl`) once `queue[0].media.relay.live` says bytes can flow (see "Relay"). At the swap the adder's app skips re-reading the link's sizes (its warm source has them) and that source carries on as the media's. It is resolved as soon as it is first there, again every 30 minutes, and dropped when it stops being first. - **Preload**: once resolved, the stream is opened in a second, hidden - mpv player (`PlaybackController.preload`, through - `SessionCoordinator.preloadMedia`): paused at the start, muted, with - its own video output when the session view is up (media_kit starts a - player with `vid=no`; the output decodes the first frame), the same - pins, hwdec and yt-dlp hook as the session player but a 48 MiB forward - and 8 MiB back cache (`preloadBytes`), mpv log at `error`. One at a - time; it waits until the media playing has started here - (`SessionCoordinator.settledMediaId`: opened, failed or nothing to + **Preload**: once resolved, the stream is opened early by the media + pipeline (`MediaPipeline.prepare`, a page through the same resolve as + any open) in a second, hidden player (the host's `preload` role): + paused at the start, muted, with its own video output when the + session view is up (media_kit starts a player with `vid=no`; the + output decodes the first frame), the same pins and hwdec as the + session player but a 48 MiB forward and 8 MiB back cache + (`preloadAheadBytes`), mpv log at `error`. One at a time; it waits + until the media playing has started here + (`MediaPipeline.settledMediaId`: opened, failed or nothing to open), so it never competes with that open and a preload a swap is about to take is never replaced first; a removed, reordered or replaced first item swaps or drops it then, a manual swap to something else keeps it (still next). A link is opened again at the 30-minute refresh (stream URLs expire); a failed preload is not retried until then and - changes nothing (the usual open follows). When the coordinator opens - the same URL and headers (`openUrl`), the preload is promoted instead - of a fresh player: it becomes the session player through the same - hand-over (see "Player lifecycle" under "Media"), unmuted and with the - full cache pins. Its buffer comes along, + changes nothing (the usual open follows). When the pipeline opens the + same URL and headers, the preload is promoted instead of a fresh + player: it becomes the session player through the same hand-over (see + "Media pipeline" under "Media"), unmuted and with the full cache pins + (`PlayerHost.promote`), its subtitles added. Its buffer comes along, so the report sent right after the open already passes the gate: the start no longer waits for anyone's open. A promoted player is no longer the first mpv of the process, so ffmpeg's HTTP status reaches @@ -1220,15 +1331,15 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in errors themselves are still detected; see "Media"). Off with env `SYNC_RELAY_PRELOAD=0`. Measured (`queue_switch_e2e_test`, a fake Jellyfin and link host answering after 120 ms at 24 Mbit/s, two - account holders and a `/j` guest): ⏭ to the server playing 59 to - 86 ms for the next episode and 35 ms for a direct link, against about - 1.5 s cold; at 300 ms and 12 Mbit/s 52 and 103 ms against about 2.9 s. - Relayed, from the warm relay (same setup, loopback server): a link - passed on 21 to 22 ms, a server-fetched link 35 to 37 ms, against + account holders and a `/j` guest): ⏭ to the server playing 74 to + 87 ms for the next episode and 35 ms for a direct link, against about + 1.6 to 1.8 s cold; at 300 ms and 12 Mbit/s 52 and 103 ms against about + 2.9 s. Relayed, from the warm relay (same setup, loopback server): a + link passed on 36 to 38 ms, a server-fetched link 46 to 53 ms, against about 1.6 s cold. `SwitchTrace` (env `SYNC_RELAY_TRACE_SWITCH=1` prints it) times each phase of a media change: state, resolved, open, player (a fresh - player is up; see "Player lifecycle" under "Media"), opened or + player is up; see "Media pipeline" under "Media"), opened or preloaded, buffered, playing. The row of the item shows a dot on its picture's corner (a tooltip says which): hollow waiting, pulsing getting ready (resolving, then @@ -1369,8 +1480,10 @@ Jellyfin item with its own account, so the relay is not its concern), that moves by the minute. `Status.line` is the player's pill (most pressing first: reconnecting with Cancel, unstable, refused or replaced with Retry, connecting, your player's fault or error with Reload, the -server getting the video, the relay's source dropped / left / not -passing it on yet, finished, someone loading, someone who cannot play +server getting the video, everyone held for the relay's source +("Waiting for ": `state.hold.reason` `source`, a viewer's buffer +ran dry while it is gone; for everyone, own-account viewers too), the +relay's source dropped / left / not passing it on yet, finished, someone loading, someone who cannot play it, you not ready, who everyone waits for; nothing while it plays and everyone is fine, and only connection lines while you sit out), each with a `StatusLevel` (ok and info never colour anything, warn amber, @@ -1466,8 +1579,12 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and - `FolderAccess` (`DesktopFolders`): folder and file dialogs (file_picker), the Files scan, folder exists, file stat, showing a folder in the file manager. Paths for now. -- `LinkResolver` (`AppYtDlp`): the app's yt-dlp, link probes, mpv's raw - options, warm-up. +- `LinkResolver` (`AppYtDlp`): the app's yt-dlp, link probes (what a + page plays, see "yt-dlp"), whether this device resolves pages + (`resolvesLinks`, the media plan's capability), warm-up. +- `PlayerHost` (`MediaKitHost`): the media players ("Player host" under + "Media"); Files' thumbnails make theirs with it too + (`thumbnailPlayers`, set in `main`). - `SystemNotifications` (`DesktopNotifications`, `platform/desktop/desktop_notifications.dart`): `show(title, body, tag, payload)` and `onClick(payload)`, through flutter_local_notifications' @@ -1481,8 +1598,7 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and (`AppDirs`, "Desktop UI"), and the copy of the data from before the rename; resolved in `main` before the composition root exists, so not a `Ports` field. -Still direct: the player (`PlaybackController`, media_kit, and its -`AppYtDlp.instance.mpvRawOptions`), the updater's restart +Still direct: the updater's restart (`Process.start`), the session's own file reads for what it plays or relays (`QueueController`, thumbnails, cookies import), app-private caches. @@ -2016,8 +2132,9 @@ Player controls (`lib/src/controls/`): one controls model, two skins. `shown`) or the mouse is on it (which holds the controls up too), and Skip OP / Skip 1:30 also for 5 s when an offer of a new kind appears (Skip ED and Next episode never without the controls). -- Volume: mpv's `volume-max` is pinned to `PlaybackController.maxVolume` - (150) per player; `VolumeBar` runs 0..150 with a tick at 100 and the +- Volume: mpv's `volume-max` is pinned to 150 per player by the host + (`PlaybackController.maxVolume`); the volume is the controller's and + goes to every next player; `VolumeBar` runs 0..150 with a tick at 100 and the part above in amber; the wheel and ↑ / ↓ step 5 % (`volumeStep`), clamped. The wheel works anywhere on the full player (`VolumeWheel` around the player's stack): `WheelSteps` makes a mouse notch one step @@ -2040,10 +2157,11 @@ Alt+F4, and Update and restart): the window position is saved and the window hides at once. Then every mpv player goes, before the engine does (a live one calls its wakeup callback into a dead isolate): the session player, the preload and the player still retiring -(`PlaybackController.disposeAll`, 1.5 s each, via +(`MediaPipeline.disposeAll` → `PlayerHost.disposeAll`, 1.5 s each, via `SessionCoordinator.releasePlayers`, which keeps the position for the Jellyfin stop report), the Files thumbnail jobs (`cancelAllThumbnails`) -and the sound engine (500 ms), side by side; on Windows 300 ms more, +and the sound engine (500 ms), side by side, then anything the host +still holds (the host owns the disposal order); on Windows 300 ms more, since media_kit_video frees a video output on a thread of its own after dispose returns, and an output still up when the engine stops blocks the plugin's teardown (`~VideoOutput` waits on a texture callback that never diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index af8b890..796699f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -78,6 +78,8 @@ and `e2e_check` (simulated viewers against a live server). Three suites, run from each package's folder: - `cd packages/core && dart test` (a few seconds): protocol round-trips, + what a player opens for a page (`YtDlpPlay`) and the cookie notes of a + failed resolve against a stand-in yt-dlp (`cookies_test`, D11), the sync engine (clock, gate, presence and grace, correction, end of file), the `PresencePolicy` table, queue model, the library's L1 (release and folder names, `groupLocal` over a real anime folder's @@ -95,7 +97,8 @@ Three suites, run from each package's folder: controls, settings, schedule), pure app logic (`StateDiff`, the library's L2 matching against a fake AniList (`show_matcher_test`), `media.ref` producers, shader list, sound engine with a fake backend, invite addresses, the - version gate), `singletons_test` (the `.instance` count, see + version gate, the media plan's table with the Jellyfin relay rule and + the "Waiting for" status (`media_plan_test`)), `singletons_test` (the `.instance` count, see ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e` tests plus `coord_presence`. The whole suite takes a couple of minutes; run it only before a restructure step lands, otherwise only the files @@ -108,21 +111,34 @@ else is offline: Jellyfin and link hosts are local fakes. Real-mpv tests: `sync_playback_e2e`, `coord_presence` (drops through a `FlakyProxy`: a half-open link and return, 10 flaps, `replaced`, `bye`, -a dead or restarted server), `video_reload_e2e`, +a dead or restarted server), `video_reload_e2e` (a fresh player from the +host, the old one retired, leave releases them all), `seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `chapters_mpv_e2e`, -`ytdl_headers_e2e`, `relay_e2e` (a dedicated server with no file of its +`ytdl_headers_e2e` (a page through the app's own resolve against a +stand-in yt-dlp and a Referer-checking CDN: the EDL, the headers and +cookies, subtitles without danmaku, a 403, the D11 error), +`media_switch_e2e` (commands during a switch wait and apply once, no mpv +errors), `relay_e2e` (a dedicated server with no file of its own: a guest's file, links passed on or fetched by the server, a late -source, a source that drops and resumes), `jellyfin_e2e`, -`queue_switch_e2e` (times Up next switches against a delayed, rate-capped -fake Jellyfin and link host: an episode, a direct link, a link passed on -and a server-fetched one; `WT_SWITCH_PRELOAD=0` for the cold path, +source, a source that drops and resumes; a seek past what a slow source +has sent, timed; a source that drops while a viewer seeks past +the buffer and another joins: no seek loop, "Waiting for", all resume +(D6)), `jellyfin_e2e`, `queue_switch_e2e` (times Up next switches +through the media pipeline against a delayed, rate-capped fake Jellyfin +and link host: an episode, a direct link, a link passed on and a +server-fetched one, each prepared and promoted; no mpv "error running +command" or "file cache" lines; `WT_SWITCH_PRELOAD=0` for the cold path, `WT_SWITCH_DELAY_MS` / `WT_SWITCH_RATE_KBS` for the link). The sync, reload and end-of-file ones expect `~/Videos/chiyo.mp4`; the others generate media with ffmpeg into `WT_TEST_TMP` (default: system temp; all desktop tests that write files use it). Headless mpv (no video output attached) won't open video-only files, so test media needs an audio track. Tests always log media_kit "S/W rendering" because they have no -display; that says nothing about the app. `sound_device_e2e` opens the +display; that says nothing about the app. A headless player has no video +track selected (`vid=no`), and an mkv's cues list only the video, so +seek tests over a relay or HTTP use an mp4 with its index first +(`+faststart`); setting `vid=auto` on a headless player crashes mpv when +it is disposed. `sound_device_e2e` opens the real audio device (flutter_soloud) and plays every sound of every preset quietly (`WT_SFX_VOLUME`, default 0.1). @@ -163,9 +179,9 @@ in [ROADMAP.md](ROADMAP.md)): | Drop and reconnect while sharing a relay | server `flaky_link_test` (blackhole: `gone` with ready, queue item and relay kept, the same member back; reset), `sync_wave_test` (takeover by client id, `replaced`, `stale`), `presence_test` (grace on a fake clock, restart), `relay_test` "the source drops (D6)" (`sourceGone`, buffer served, same `info` continues, another 4002, a second channel 4003, leave ends it); desktop `relay_e2e` "the source drops uncleanly and comes back" (gone past the server's silence, the relay kept, the source re-dials and continues), `coord_presence` (a half-open drop while playing: same member, no reopen, the state kept; 10 flaps: at most one lost/back sound; `replaced` is terminal; a restarted server gets the ready again), `connection_test` (the hello's id, `seq`, caps, `feedAfter`) | | | Join mid-playback | core `sync_engine_test` "D2: joiners" (never changes the transport while playing, at most 15 s while held, joining ends on error or timeout), `presence_test` (the table); server `relay_test` "a late joiner gets its own /r/ URL"; desktop `coord_presence` (the returning member plays on in sync) | | | Sit out / Away and back | core `sync_engine_test` "away" (only oneself comes back, through joining), "return" (away survives a drop), `presence_test` (`isAlone`); server `hardening_test` (anyone sets anyone away, `forbidden`); desktop `server_isolate_test` (the host sat out through `presence` on your shared server), `notice_table_test` (the feed actions one-to-one, matched by id; sitting out gets only chat, nudges, "starting now", Up next and the chosen joins and leaves; alone no social sounds), `session_logic_test` (auto sit-out on a fake clock: never alone, a manual away stays), `player_status_test` / `shell_test` (the ready counter leaves away and gone out) | step 6: not tracked as watched (P7) | -| Swap, also while someone is Away or gone | core `sync_engine_test` "swap" (a gone member stays out of the gate, comes back joining), "away" (a swap leaves it away), `queue_test` (a swap takes its item, drops the `auto` one); server `queue_test` (a gone adder's item waits), `relay_test` (a swap drops the buffer); desktop `sync_playback_e2e` | step 5: no commands reach a player mid-switch (P15) | -| Up next advance | core `queue_test` (skip, play request timeout, `auto` and `cancel`); server `queue_test`, `relay_test` "warm relay"; desktop `jellyfin_session_test` (auto-advance swapper, end card countdown), `queue_switch_e2e` | step 5: preparing it through the media pipeline; step 6: the countdown as server state (`state.advance`) | -| Close the app cleanly, incl. `bye` | server `lifecycle_test` (a close keeps the member `gone`, `bye` leaves at once, server stop closes sockets); desktop `server_isolate_test` (stop frees the port; Stop sharing tells guests with 4016 and keeps you and Up next; a friend's server and back keeps yours), `coord_presence` (Stop sharing and your server's stop release their ports; leave sends `bye` and others see "left" at once, leaving for a restart keeps the member `gone`) | step 5: the Windows close smoke job (P32) | +| Swap, also while someone is Away or gone | core `sync_engine_test` "swap" (a gone member stays out of the gate, comes back joining), "away" (a swap leaves it away), `queue_test` (a swap takes its item, drops the `auto` one); server `queue_test` (a gone adder's item waits), `relay_test` (a swap drops the buffer); desktop `sync_playback_e2e`, `media_switch_e2e` (no command reaches a player mid-switch) | | +| Up next advance | core `queue_test` (skip, play request timeout, `auto` and `cancel`); server `queue_test`, `relay_test` "warm relay"; desktop `jellyfin_session_test` (auto-advance swapper, end card countdown), `queue_switch_e2e` (prepared and promoted through the media pipeline) | step 6: the countdown as server state (`state.advance`) | +| Close the app cleanly, incl. `bye` | server `lifecycle_test` (a close keeps the member `gone`, `bye` leaves at once, server stop closes sockets); desktop `server_isolate_test` (stop frees the port; Stop sharing tells guests with 4016 and keeps you and Up next; a friend's server and back keeps yours), `coord_presence` (Stop sharing and your server's stop release their ports; leave sends `bye` and others see "left" at once, leaving for a restart keeps the member `gone`), `video_reload_e2e` (leave releases every player); the close smoke run on the built app (`tool/smoke_windows.ps1` in CI, `tool/smoke_linux.sh` by hand) | | A test file whose first line is `// restructure: rewrite in step N` pins behaviour that step of the restructure ([ROADMAP.md](ROADMAP.md)) changes; @@ -174,7 +190,20 @@ before. CI (`.gitea/workflows/ci.yml`): analyze + test for core and server, plus `dart compile exe` of the dedicated server (fails if Flutter creeps into -`packages/server`). Desktop tests are not in CI. +`packages/server`). Desktop tests are not in CI. The Windows job runs the +updater tests, the release build and the close smoke run on that build +(`tool/smoke_windows.ps1`, P32): the app starts with +`STARSYNC_SMOKE=` (a 5 s clip from ffmpeg, else a WAV the script +writes) under its own profile (`STARSYNC_PROFILE=smoke`), opens the clip +in a player with a video output through the media pipeline +(`platform/smoke.dart`), plays a second and closes the normal way; the +steps go to `STARSYNC_SMOKE_OUT` (`start`, `opened render=`, +`closing`, or `skip` / `fail` with why). It fails when the app does not +exit within 45 s or leaves a `starsync` process, skips with a warning +when the runner cannot bring the app or its video output up, and prints +the `smoke:`, `close:` and `mpv: render` lines of the app log. +`tool/smoke_linux.sh` is the same on Linux (needs a display; after +`flutter build linux --release`). Traps: - Never pump media_kit's `Video` in `flutter test`: the runner hangs (kill @@ -302,8 +331,10 @@ is off), not software rendering. - `web_socket_channel` 3.x: `channel.ready` errors are unhandled on refused connections; swallow them and report through the stream's `onError`. Streams are single-subscription: one listener per socket. -- media_kit has no public `Player.setProperty`; the code reaches mpv through a - dynamic call on `player.platform`. Cache size goes through +- media_kit has no public `Player.setProperty`; the player host + (`MediaKitHost`) reaches mpv through a dynamic call on + `player.platform`, and nothing outside it imports media_kit except the + `Video` widget glue. Cache size goes through `PlayerConfiguration(bufferSize:)`. - Hot restart with any media_kit `Player` alive (session, thumbnails) used to SIGSEGV on mpv's `core` thread: each mpv handle's wakeup callback diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7edf6b5..a0c5e19 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -35,19 +35,9 @@ Problems are numbered; delete a line when its fix ships. - D11 Login-only sites: error texts say which cookies were used. **Problems:** -- P2 Viewers of a relay whose source is gone: mpv loops on failed seeks - past what the server buffered (viewer-side recovery, step 5). - P7 Sat-out people get tracked as watched on Jellyfin. - P9 The watched rule (position coverage) misses real watches (D3). -- P15 Commands reach a player mid-switch (`seek 90.0`, `event:5` errors). -- P17 Preload, warm relay and the fresh player per video are layered on - the player instead of being one media pipeline. -- P18 Bilibili "no video formats" with browser cookies set; the hint - wording misleads (D11). - P25 ssh to Gitea blocked through the proxy (pushes go over https). -- P28 Log noise: "Failed to create file cache"; an idle relay channel for - Jellyfin items everyone has an account for. -- P29 Relay start delay after a seek into an unbuffered part. - P30 Shaders don't work on Windows. Fix in 1.2.1-dev.3 (list read-back check; Windows hwdec `auto-copy`): a Windows user must confirm shaders stay on and the EGL-surface error is gone. @@ -56,8 +46,11 @@ Problems are numbered; delete a line when its fix ships. `watch-together 2>&1 | grep VideoOutput` from a terminal if software. - P32 The app freezes on exit, mostly reported on Windows. 1.2.1-dev.3: orderly close, Linux `_exit` (0 crashes in 41 closes vs 10/10 before), - Windows normal close + TerminateProcess + watchdogs, untested on - Windows; if it still hangs: Event Viewer 1000/1002, a Task Manager dump, + Windows normal close + TerminateProcess + watchdogs; the player host + now owns the disposal order. CI's Windows job runs a close smoke run on + every build (`tool/smoke_windows.ps1`); its first runs on the runner + must show whether it passes or skips there (no interactive desktop). If + a real close still hangs: Event Viewer 1000/1002, a Task Manager dump, the `close:` log lines. - P33 No volume readout on wheel: likely fixed by the controls rework (1.2.1-dev.1; a test covers both skins); confirm in real use. @@ -68,12 +61,6 @@ the automatic continuation and countdown, relay state), the device (client id, library and folders, match corrections, watch history, settings, notification prefs, what this app is sourcing) and the integrations (Jellyfin played/resume, AniList list). -5. Media pipeline + player host port: plan → resolve → open → ready, - commands queued during a switch, viewer-side recovery when a source - drops (no mpv seek loops, play what's buffered), resolver and source - chosen by capability; a Windows CI smoke job (start, open, close - within N s, no orphan process). Fixes P2 P15 P17 P18 P28 P29, P30–P32; - D11. 6. What's next (decides which/when, asks the pipeline to prepare; continuation and countdown as server state, shared cancel) + Watch tracking (time In from the presence history, device history, Jellyfin @@ -85,7 +72,7 @@ Composition root (in place, ARCHITECTURE "Composition root"): the remaining `.instance` singletons move into `AppGraph` as the steps touch them; `singletons_test` holds the count (today: AppSettings 55, JellyfinAccounts 10, UpdateController 8, Profile 8, FolderStore 6, -AppYtDlp 4, RecentLinks 3). +AppYtDlp 2, RecentLinks 3). Not in the restructure: P25 (ops). @@ -126,6 +113,24 @@ your server leaves them there without you, unwarned; a join refused after dialling (wrong password) costs your own member there (`bye`, back as a new member). Your server writes its session log even when you are alone. +Left of step 5 (media pipeline): a human check, on Linux now and on +Windows at 1.3.0, of a page link in "Everyone fetches it" through the +app's own resolve (YouTube's 1080p video + audio EDL, Bilibili with its +Referer and cookies, X's HLS) and of its subtitles, and of the D11 text +with browser cookies (Bilibili without a login there); a relay viewer +during a real source drop (the bytes run out, "Waiting for ", the +return); the first Windows CI runs of the close smoke step. Not built: +a viewer cannot tell what the server's relay buffer holds, so a seek +past its own mpv buffer while the source is away is tried once and then +waits for the source even when the server had it; `Unresolvable` (a +device without yt-dlp) only says who could pass the link on, nothing +routes it there by itself (Android); a page's stream URLs come from the +probe cache (10 minutes, 35 for Up next's first item) and are resolved +again only at Reload video and the 30-minute refresh; a format whose +CDN wants per-track headers that differ gets the first track's (as +mpv's hook did); Bilibili's danmaku is dropped, not shown. The Jellyfin +item this member put up re-feeds its relay after an app restart (the +state still names it the source). Left of step 4 (library): a human check of the Files library on the real folder (`/mnt/torrents/Torrents/Binux/`: Bleach over three folders with 66–107 missing, SAO S3 Alicization with OP/ED, Re.Zero S04E17, the @@ -186,7 +191,8 @@ core and app. Still open on the wire, frozen at the 1.3.0 release: behind upstream) with native D3D11 on Windows (no ANGLE: may fix P30 and the dxva2-egl error), a bundled libmpv on Linux, triple-buffered Linux rendering, patched FFmpeg. Check: Windows shaders, whether its - libmpv keeps Lua/ytdl_hook (link mode "everyone fetches it"), offline + it plays `edl://` URLs with `http-header-fields` (pages resolve in + the app since step 5, so mpv's Lua and ytdl_hook are not needed), offline CI builds (it downloads binaries in a build hook), and the upstream fixes it lacks. Until then we vendor upstream 2.0.1 with the X11/EGL patch in `third_party/media_kit_video`. @@ -207,10 +213,9 @@ core and app. Still open on the wire, frozen at the 1.3.0 release: step 6. 3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the - bundled Windows libmpv has the ytdl hook (Lua) and plays `edl://` URLs - through media_kit; if not, resolve in the app and open the relay - tracks' formats. YouTube: yt-dlp wants a JS runtime (deno) for full - extraction. Formats that need yt-dlp's cookies. + bundled Windows libmpv plays the app's `edl://` URLs (video + audio + with headers) through media_kit. YouTube: yt-dlp wants a JS runtime + (deno) for full extraction. - Live streams through the relay (today refused: the sync model needs a fixed duration and seeking). - `tool/relay_e2e.dart`: a two-track case (bytes and pieces) over a diff --git a/packages/core/lib/src/constants.dart b/packages/core/lib/src/constants.dart index 4d76a85..f4a6bf2 100644 --- a/packages/core/lib/src/constants.dart +++ b/packages/core/lib/src/constants.dart @@ -9,7 +9,7 @@ abstract class RelayConstants { static const int protoMax = 16; // Relay (`/r/...`): a member's bytes through the server's buffer. - static const int relaySliceBytes = 1 << 20; + static const int relaySliceBytes = 256 * 1024; static const int relayFrameBytes = 64 * 1024; /// Defaults for the buffer; they mirror a viewer's mpv buffer (150 s / /// 256 MiB ahead, 64 MiB back), so whatever a viewer may seek to soon is diff --git a/packages/core/lib/src/cookies.dart b/packages/core/lib/src/cookies.dart index 578045f..c556454 100644 --- a/packages/core/lib/src/cookies.dart +++ b/packages/core/lib/src/cookies.dart @@ -164,6 +164,45 @@ Map> cookiesBySite(Iterable cookies return {for (final k in m.keys.toList()..sort()) k: m[k]!}; } +/// [parseCookies], or none for a file without any. +List parseCookiesOrEmpty(String text) { + try { + return parseCookies(text); + } on FormatException { + return const []; + } +} + +/// "Firefox", "Chrome", "Firefox (work)": a browser and its profile. +String browserLabel(CookieBrowser b) { + final name = b.browser.isEmpty ? b.browser : '${b.browser[0].toUpperCase()}${b.browser.substring(1)}'; + return b.profile.isEmpty ? name : '$name (${b.profile})'; +} + +/// The cookies part of a sign-in error (D11): which cookies were used and +/// how many of them are for [site] ([count] -1: unknown; [read]: what +/// reading a browser's gave). +String cookieNoteText(CookieSource? source, String site, int count, {({int count, String? error})? read}) { + switch (source) { + case null: + return 'no cookies were used'; + case CookieFile(): + if (count < 0) return 'used the cookies file'; + return count == 0 + ? 'used the cookies file, which has none for $site' + : 'used the cookies file ($count for $site: signed out or expired?)'; + case CookieBrowser(): + final who = "${browserLabel(source)}'s cookies"; + final error = read?.error; + if (error != null) return "used $who, but yt-dlp couldn't read them: $error"; + final n = read?.count ?? count; + if (n < 0) return 'used $who'; + return n == 0 + ? 'used $who, which have none for $site: sign in to $site there' + : 'used $who ($n for $site: that account may not see it)'; + } +} + /// Sites ticked by default after an import: those yt-dlp is usually given /// cookies for. const suggestedCookieSites = {'bilibili.com', 'youtube.com'}; @@ -233,17 +272,6 @@ class YtDlpCookies { null => const [], }; - /// The same as mpv's `ytdl-raw-options` value ('' for none); each value - /// length-quoted (`%N%`) so commas and '=' in paths survive. - String mpvRawOptions([CookieSource? source]) { - String q(String v) => '%${utf8.encode(v).length}%$v'; - return switch (source ?? read(count: false)) { - CookieFile(:final path) => 'cookies=${q(path)}', - CookieBrowser b => 'cookies-from-browser=${q(b.spec)}', - null => '', - }; - } - /// Keeps [cookies] as the private copy, replacing any browser setting. Future saveFile(List cookies) async { await Directory(dir).create(recursive: true); diff --git a/packages/core/lib/src/link_probe.dart b/packages/core/lib/src/link_probe.dart index 6d4c3d6..402e220 100644 --- a/packages/core/lib/src/link_probe.dart +++ b/packages/core/lib/src/link_probe.dart @@ -95,6 +95,22 @@ final _unreachable = RegExp( r'network is unreachable|no route to host|name or service not known|getaddrinfo|unable to download webpage', caseSensitive: false); +/// yt-dlp's [error] (plain words or raw) may be about signing in: worth +/// saying which cookies were used (D11). +bool mayNeedSignIn(String error) { + final t = playErrorText(error); + return t == needsCookiesText || t == noFormatsText; +} + +/// [plain] ([playErrorText]) with the cookies [note] (D11), for the two +/// texts about signing in; anything else as is. +String withCookieNote(String plain, String? note) { + if (note == null || note.isEmpty) return plain; + if (plain == needsCookiesText) return 'This site needs cookies ($note) — Settings → Player → yt-dlp → Cookies'; + if (plain == noFormatsText) return 'No video found: it may need signing in ($note)'; + return plain; +} + /// Why a link or media cannot be played, in plain words, from the raw /// error of yt-dlp, ffmpeg/mpv or the link probe; anything it does not /// know comes back as is (one line, at most 100 characters). @@ -131,6 +147,10 @@ class LinkProbe { final String? pageTitle; /// Why yt-dlp could not read the page. final String? ytDlpError; + + /// Which cookies that run used ([YtDlp.cookieNote]), when it may be about + /// signing in. + final String? cookies; /// The HTTP status of the probe's GET; 0 when unknown. final int status; const LinkProbe.file(this.url, this.size) @@ -138,8 +158,9 @@ class LinkProbe { info = null, pageTitle = null, ytDlpError = null, + cookies = null, status = 0; - const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError, this.status = 0}) + const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError, this.cookies, this.status = 0}) : isPage = true, size = -1; @@ -152,7 +173,7 @@ class LinkProbe { // A 4xx says more than yt-dlp's words it has no plain text for. final known = err != null && _plainTexts.contains(err); if (!known && status >= 400 && status < 500) return playErrorText('HTTP Error $status'); - return err; + return err == null ? null : withCookieNote(err, cookies); } /// The name to show: yt-dlp's title, the page's, else the host; null @@ -219,12 +240,13 @@ Future _probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async } catch (e) { getError = e; } - String? ytErr; + String? ytErr, cookies; if (ytDlp != null) { try { return LinkProbe.page(url, info: await ytDlp.resolve(url.toString()), status: status); } catch (e) { ytErr = '$e'; + if (e is YtDlpException) cookies = e.cookies; } } if (getError != null) { @@ -238,7 +260,8 @@ Future _probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async body = await _readText(await req.close().timeout(const Duration(seconds: 15))); } catch (_) {} } - return LinkProbe.page(url, pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr, status: status); + return LinkProbe.page(url, + pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr, cookies: cookies, status: status); } finally { if (client == null) c.close(force: true); } diff --git a/packages/core/lib/src/yt_dlp.dart b/packages/core/lib/src/yt_dlp.dart index 502b821..f019299 100644 --- a/packages/core/lib/src/yt_dlp.dart +++ b/packages/core/lib/src/yt_dlp.dart @@ -8,7 +8,9 @@ import 'package:crypto/crypto.dart'; import 'package:path/path.dart' as p; import 'cookies.dart'; +import 'link_probe.dart' show mayNeedSignIn; import 'logging.dart'; +import 'relay.dart' show mpvEdl; // yt-dlp: turns a web page (YouTube, Twitter/X, ...) into media URLs. The // app and the server each keep their own copy, the official standalone @@ -19,7 +21,11 @@ final _log = createLogger('yt-dlp'); class YtDlpException implements Exception { final String message; - const YtDlpException(this.message); + + /// Which cookies the run used and what they hold for the site, when the + /// failure may be about signing in ([YtDlp.cookieNote], D11). + final String? cookies; + const YtDlpException(this.message, {this.cookies}); @override String toString() => message; } @@ -121,6 +127,10 @@ class YtDlpInfo { /// Every relayable format by id, to find [tracks] again after a /// re-resolve. final Map formats; + + /// What a player opens for this page ([playFormat]); null when yt-dlp + /// picked nothing a player can open. + final YtDlpPlay? play; const YtDlpInfo({ required this.title, this.uploader = '', @@ -131,6 +141,7 @@ class YtDlpInfo { this.isLive = false, this.tracks = const [], this.formats = const {}, + this.play, }); /// One line for the log: extractor, duration, live, and each picked @@ -163,6 +174,7 @@ class YtDlpInfo { duration: j['duration'] is num ? (j['duration'] as num).toDouble() : null, thumbnail: str('thumbnail').isEmpty ? null : str('thumbnail'), isLive: isLive, + play: YtDlpPlay.parse(j), tracks: isLive ? const [] : pickTracks(formats), formats: { for (final f in formats) @@ -341,6 +353,100 @@ Map parseSha256Sums(String text) => { m.group(2)!: m.group(1)!.toLowerCase(), }; +/// The formats a player opens for a page: the best video up to 1080p with +/// the best audio, else the best single file (yt-dlp's `-f`). +const playFormat = 'bv*[height<=1080]+ba/b'; + +/// The subtitles a player gets: every language but Bilibili's danmaku. +const playSubLangs = 'all,-danmaku'; + +/// A page's subtitle track for a player (`requested_subtitles`). +class YtDlpSubtitle { + final String lang; + final String name; + final String url; + final String ext; + const YtDlpSubtitle(this.lang, this.url, {this.name = '', this.ext = ''}); +} + +/// What a player opens for a page, as yt-dlp resolved it ([playFormat]): +/// one file, or a video and an audio stream played together, with the +/// headers its CDN wants (the first format's `http_headers`, plus its +/// `cookies` as a Cookie header) and the subtitles ([playSubLangs]). +/// URLs are bound to the resolving machine (its address, its cookies). +class YtDlpPlay { + final List urls; + final Map headers; + final List subtitles; + const YtDlpPlay(this.urls, {this.headers = const {}, this.subtitles = const []}); + + /// The URL mpv opens: the file, or an EDL of the streams. + String get mpvUrl => urls.length == 1 ? urls.single : mpvEdl(urls); + + /// From `yt-dlp -J`: its `requested_formats`, else the top-level format; + /// null when none of them is something a player can open. + static YtDlpPlay? parse(Map j) { + final picked = j['requested_formats'] is List + ? [for (final f in j['requested_formats'] as List) if (f is Map) Map.from(f)] + : [if (j['url'] is String) j]; + if (picked.isEmpty) return null; + final urls = []; + for (final f in picked) { + final url = _playUrl(f); + if (url == null) return null; + urls.add(url); + } + final first = picked.first; + final headers = { + if (first['http_headers'] is Map) + for (final e in (first['http_headers'] as Map).entries) + if (e.key is String && e.value is String) e.key as String: e.value as String, + }; + final cookie = cookieHeaderOf(first['cookies']); + if (cookie.isNotEmpty) headers['Cookie'] = cookie; + final subs = []; + final requested = j['requested_subtitles']; + if (requested is Map) { + for (final e in requested.entries) { + final lang = '${e.key}', v = e.value; + if (lang == 'danmaku' || v is! Map || v['url'] is! String) continue; + final ext = v['ext'] is String ? v['ext'] as String : ''; + if (ext == 'xml' || ext == 'json') continue; + subs.add(YtDlpSubtitle(lang, v['url'] as String, + name: v['name'] is String ? v['name'] as String : '', ext: ext)); + } + } + return YtDlpPlay(urls, headers: headers, subtitles: subs); + } + + static String? _playUrl(Map f) { + final protocol = f['protocol'] is String ? f['protocol'] as String : 'https'; + if (protocol.startsWith('http_dash')) { + final manifest = f['manifest_url']; + return manifest is String && manifest.startsWith('http') ? manifest : null; + } + final url = f['url']; + if (url is! String || !(url.startsWith('http://') || url.startsWith('https://'))) return null; + return const {'http', 'https', 'm3u8', 'm3u8_native'}.contains(protocol) ? url : null; + } +} + +/// A Cookie header from yt-dlp's `cookies` field (`name=value; Domain=...; +/// Path=/; name2=value2; ...`): the name=value pairs, attributes dropped. +String cookieHeaderOf(Object? cookies) { + if (cookies is! String || cookies.trim().isEmpty) return ''; + const attributes = {'domain', 'path', 'expires', 'max-age', 'secure', 'httponly', 'samesite', 'comment', 'version'}; + final pairs = []; + for (final part in cookies.split(';')) { + final t = part.trim(); + final i = t.indexOf('='); + final name = (i < 0 ? t : t.substring(0, i)).trim(); + if (i <= 0 || attributes.contains(name.toLowerCase())) continue; + pairs.add('$name=${t.substring(i + 1).trim()}'); + } + return pairs.join('; '); +} + /// The last `ERROR:` line of yt-dlp's stderr without its prefixes /// (`ERROR: [twitter] 123: No video` -> `No video`), else the last line. String ytDlpErrorText(String stderr) { @@ -602,7 +708,11 @@ class YtDlp { final e = await err; _log.warning('resolve ${logUrl(url)}: exit $code after ${sw.elapsedMilliseconds} ms; stderr tail:\n' '${_tail(e)}'); - throw YtDlpException(ytDlpErrorText(e)); + final text = ytDlpErrorText(e); + // A site that hides the video from guests: say which cookies went. + final note = mayNeedSignIn(text) ? await cookieNote(url) : null; + if (note != null) _log.info('resolve ${logUrl(url)}: $note'); + throw YtDlpException(text, cookies: note); } try { final j = jsonDecode(await out); @@ -616,6 +726,59 @@ class YtDlp { } } + /// Which cookies a run for [url] uses and what they hold for its site + /// ([cookieSite]), in plain words (D11): none; the private file and how + /// many of its cookies are for the site; a browser, and how many cookies + /// for the site yt-dlp could read from it (one run of yt-dlp that writes + /// the browser's cookies to a private file in [dir], counted and deleted + /// at once; values are never logged). + Future cookieNote(String url) async { + final site = cookieSite(Uri.tryParse(url)?.host ?? ''); + final source = cookies.read(count: false); + switch (source) { + case null: + return 'no cookies were used'; + case CookieFile(:final path): + try { + final n = parseCookies(await File(path).readAsString()).where((c) => cookieSite(c.host) == site).length; + return cookieNoteText(source, site, n); + } catch (_) { + return cookieNoteText(source, site, -1); + } + case CookieBrowser(): + return cookieNoteText(source, site, -1, read: await _browserCookies(source, site)); + } + } + + /// How many cookies for [site] yt-dlp reads from [browser], or why it + /// cannot. + Future<({int count, String? error})> _browserCookies(CookieBrowser browser, String site) async { + final exe = await ensure(); + if (exe == null) return (count: -1, error: lastError ?? 'yt-dlp is not available'); + final jar = File(p.join(dir, '.cookie-check-$pid.txt')); + try { + // Private before yt-dlp writes into it (it keeps the mode). + await jar.writeAsString('# Netscape HTTP Cookie File\n'); + if (!Platform.isWindows) await Process.run('chmod', ['600', jar.path]); + // No URL: yt-dlp loads the browser's cookies, saves the jar and stops. + final r = await Process.run( + exe, ['--ignore-config', '--cookies-from-browser', browser.spec, '--cookies', jar.path]) + .timeout(const Duration(seconds: 30)); + final cookies = parseCookiesOrEmpty(await jar.readAsString()); + final err = '${r.stderr}'; + if (cookies.isEmpty && err.contains('ERROR:') && !err.contains('provide at least one URL')) { + return (count: -1, error: ytDlpErrorText(err)); + } + return (count: cookies.where((c) => cookieSite(c.host) == site).length, error: null); + } on TimeoutException { + return (count: -1, error: 'yt-dlp took longer than 30 s reading them'); + } catch (e) { + return (count: -1, error: '$e'); + } finally { + if (await jar.exists()) await jar.delete().catchError((_) => jar); + } + } + /// [args] for the log: the cookie source named, not its path or spec. static String _argsForLog(List args) { final out = []; @@ -640,8 +803,14 @@ class YtDlp { } /// The command line [resolve] runs. - List resolveArgs(String url) => - ['-J', '--no-playlist', '--no-warnings', '--ignore-config', ...cookies.args(), '--', url]; + List resolveArgs(String url) => [ + '-J', '--no-playlist', '--no-warnings', '--ignore-config', + // What a player opens ([YtDlpPlay]): yt-dlp's own pick at most + // 1080p, and the page's subtitles but Bilibili's danmaku (an XML + // comment feed, not subtitles). + '-f', playFormat, '--write-subs', '--sub-langs', playSubLangs, + ...cookies.args(), '--', url, + ]; Future close() => _changes.close(); } diff --git a/packages/core/test/cookies_test.dart b/packages/core/test/cookies_test.dart index 8ed65a4..5274f30 100644 --- a/packages/core/test/cookies_test.dart +++ b/packages/core/test/cookies_test.dart @@ -30,26 +30,93 @@ void main() { expect(again.map((c) => c.toLine()), cookies.map((c) => c.toLine())); }); - test('kept sites become a private file yt-dlp and mpv are pointed at', () async { + test('kept sites become a private file yt-dlp is pointed at', () async { final dir = await Directory.systemTemp.createTemp('cookies_test'); addTearDown(() => dir.delete(recursive: true)); final src = File('${dir.path}/export.json')..writeAsStringSync(_json); final y = YtDlp('${dir.path}/yt-dlp, a=b'); - expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '--', 'U']); + expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '-f', playFormat, '--write-subs', '--sub-langs', playSubLangs, '--', 'U']); final kept = await y.cookies.import(src.path, sites: {'bilibili.com'}); expect([kept.count, ...kept.sites], [1, 'bilibili.com']); final path = y.cookies.filePath; - expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '--cookies', path, '--', 'U']); - expect(y.cookies.mpvRawOptions(), 'cookies=%${path.length}%$path'); + expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '-f', playFormat, '--write-subs', '--sub-langs', playSubLangs, '--cookies', path, '--', 'U']); if (!Platform.isWindows) expect(File(path).statSync().modeString(), 'rw-------'); await y.cookies.saveBrowser('firefox', 'work'); - expect(y.resolveArgs('U').sublist(4, 6), ['--cookies-from-browser', 'firefox:work']); + expect(y.resolveArgs('U').sublist(9, 11), ['--cookies-from-browser', 'firefox:work']); expect(File(path).existsSync(), isFalse); - expect(y.cookies.mpvRawOptions(), 'cookies-from-browser=%12%firefox:work'); await y.cookies.remove(); expect(y.cookies.read(), isNull); }); + + // D11: a sign-in error says which cookies were used and what they hold + // for the site. + group('cookie notes', () { + test('the texts', () { + expect(cookieNoteText(null, 'bilibili.com', -1), 'no cookies were used'); + const file = CookieFile('/x', 3, ['youtube.com']); + expect(cookieNoteText(file, 'bilibili.com', 0), 'used the cookies file, which has none for bilibili.com'); + expect(cookieNoteText(file, 'bilibili.com', 4), + 'used the cookies file (4 for bilibili.com: signed out or expired?)'); + const ff = CookieBrowser('firefox', 'work'); + expect(cookieNoteText(ff, 'bilibili.com', -1, read: (count: 0, error: null)), + "used Firefox (work)'s cookies, which have none for bilibili.com: sign in to bilibili.com there"); + expect(cookieNoteText(ff, 'bilibili.com', -1, read: (count: 12, error: null)), + "used Firefox (work)'s cookies (12 for bilibili.com: that account may not see it)"); + expect(cookieNoteText(const CookieBrowser('chrome'), 'x.com', -1, read: (count: -1, error: 'locked')), + "used Chrome's cookies, but yt-dlp couldn't read them: locked"); + expect(withCookieNote(noFormatsText, 'no cookies were used'), + 'No video found: it may need signing in (no cookies were used)'); + expect(withCookieNote(needsCookiesText, 'x'), 'This site needs cookies (x) — Settings → Player → yt-dlp → Cookies'); + expect(withCookieNote(notFoundText, 'x'), notFoundText); + expect(mayNeedSignIn('No video formats found!'), isTrue); + expect(mayNeedSignIn('HTTP Error 404'), isFalse); + }); + + test("a failed resolve names the file's cookies for the site; a browser's are counted by yt-dlp", + () async { + final dir = await Directory.systemTemp.createTemp('cookie_note'); + addTearDown(() => dir.delete(recursive: true)); + // A stand-in yt-dlp: a version, a resolve that finds no formats, and + // the browser read (no URL) that writes the jar it is given. + final fake = File('${dir.path}/yt-dlp'); + await fake.writeAsString(r'''#!/bin/sh +case "$1" in --version) echo 2099.01.01; exit 0;; esac +jar=""; prev="" +for a in "$@"; do [ "$prev" = "--cookies" ] && jar="$a"; prev="$a"; done +case "$*" in *-J*) echo "ERROR: [BiliBili] BV1: No video formats found!" >&2; exit 1;; esac +printf '# Netscape HTTP Cookie File\n.bilibili.com\tTRUE\t/\tTRUE\t2000000000\tSESSDATA\tx\n.bilibili.com\tTRUE\t/\tFALSE\t2000000000\tbuvid3\ty\n.other.org\tTRUE\t/\tFALSE\t0\ta\tb\n' > "$jar" +echo "Extracted 3 cookies from firefox" >&2 +echo "yt-dlp: error: You must provide at least one URL." >&2; exit 2 +'''); + await Process.run('chmod', ['+x', fake.path]); + final y = YtDlp(dir.path, updateEvery: const Duration(days: 9999)); + await File('${dir.path}/last-check').writeAsString(DateTime.now().toIso8601String()); + const page = 'https://www.bilibili.com/video/BV1'; + + Future fail() async { + try { + await y.resolve(page); + } on YtDlpException catch (e) { + return e; + } + throw StateError('resolved'); + } + + expect((await fail()).cookies, 'no cookies were used'); + await y.cookies.saveFile(parseCookies('.youtube.com\tTRUE\t/\tFALSE\t0\tSID\tv\n')); + expect((await fail()).cookies, 'used the cookies file, which has none for bilibili.com'); + await y.cookies.saveBrowser('firefox'); + final e = await fail(); + expect(e.message, contains('No video formats found')); + expect(e.cookies, "used Firefox's cookies (2 for bilibili.com: that account may not see it)"); + expect(dir.listSync().map((f) => f.path).where((p) => p.contains('cookie-check')), isEmpty, + reason: 'the browser jar is deleted at once'); + // The probe carries it into the plain words. + expect(LinkProbe.page(Uri.parse(page), ytDlpError: e.message, cookies: e.cookies).problem, + "No video found: it may need signing in (used Firefox's cookies (2 for bilibili.com: that account may not see it))"); + }, skip: Platform.isWindows ? 'the stand-in yt-dlp is a shell script' : false); + }); } diff --git a/packages/core/test/yt_dlp_test.dart b/packages/core/test/yt_dlp_test.dart index 07c90e4..3c64f33 100644 --- a/packages/core/test/yt_dlp_test.dart +++ b/packages/core/test/yt_dlp_test.dart @@ -261,4 +261,46 @@ void main() { expect(pick(['avc1.640033', 'vp09.00.40.08', 'av01.0.08M.08']), 'v2'); expect(pick(['avc1.640033', 'vp09.00.40.08']), 'v1'); }); + + test('what a player opens: yt-dlp\'s pick, an EDL for video + audio, headers with the cookies, subtitles but danmaku', () { + final split = YtDlpInfo.parse({ + 'title': 'Page', + 'formats': [], + 'requested_formats': [ + { + 'format_id': 'v', 'url': 'https://cdn/v.m4s', 'protocol': 'https', + 'http_headers': {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA'}, + 'cookies': 'SESSDATA=abc; Domain=.bilibili.com; Path=/; Secure; Expires=1999999999; buvid3=xyz; Domain=.bilibili.com', + }, + {'format_id': 'a', 'url': 'https://cdn/a.m4s', 'protocol': 'https', 'http_headers': {'Referer': 'other'}}, + ], + 'requested_subtitles': { + 'zh-CN': {'url': 'https://subs/zh.vtt', 'ext': 'vtt', 'name': 'Chinese'}, + 'danmaku': {'url': 'https://comment/1.xml', 'ext': 'xml'}, + }, + }).play!; + expect(split.urls, ['https://cdn/v.m4s', 'https://cdn/a.m4s']); + expect(split.mpvUrl, mpvEdl(['https://cdn/v.m4s', 'https://cdn/a.m4s'])); + expect(split.headers, {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA', 'Cookie': 'SESSDATA=abc; buvid3=xyz'}); + expect([for (final s in split.subtitles) '${s.lang} ${s.name} ${s.url}'], ['zh-CN Chinese https://subs/zh.vtt']); + + final single = YtDlpInfo.parse({ + 'title': 'Tweet', 'url': 'https://video.twimg.com/v.mp4', 'protocol': 'https', 'ext': 'mp4', + 'http_headers': {'User-Agent': 'UA'}, + }).play!; + expect(single.mpvUrl, 'https://video.twimg.com/v.mp4'); + expect(single.headers, {'User-Agent': 'UA'}); + + final hls = YtDlpInfo.parse({ + 'title': 'Live', 'is_live': true, + 'requested_formats': [{'url': 'https://x/live.m3u8', 'protocol': 'm3u8_native'}], + }).play!; + expect(hls.mpvUrl, 'https://x/live.m3u8', reason: 'a live stream plays, it is only not relayed'); + expect(YtDlpInfo.parse({'title': 'DASH', 'requested_formats': [{'url': 'https://x/f', 'protocol': 'http_dash_segments', 'manifest_url': 'https://x/m.mpd'}]}).play?.mpvUrl, + 'https://x/m.mpd'); + expect(YtDlpInfo.parse({'title': 'none', 'formats': []}).play, isNull); + expect(YtDlpInfo.parse({'title': 'rtmp', 'url': 'rtmp://x/y', 'protocol': 'rtmp'}).play, isNull); + expect(cookieHeaderOf(null), ''); + expect(cookieHeaderOf('a=1; Path=/; HttpOnly; b=2=3'), 'a=1; b=2=3'); + }); } diff --git a/packages/server/lib/src/relay_buffer.dart b/packages/server/lib/src/relay_buffer.dart index 47cc3bc..0b99689 100644 --- a/packages/server/lib/src/relay_buffer.dart +++ b/packages/server/lib/src/relay_buffer.dart @@ -112,14 +112,41 @@ class ChannelRelayFeed extends RelayFeed { class _Pull { final int id, start, end; int pos; - final buf = BytesBuilder(copy: false); + // The slice being filled: its chunks so far (readers take them as they + // come, see [RelayBuffer.read]). + final parts = []; + int filled = 0; _Pull(this.id, this.start, this.end) : pos = start; + + /// Bytes of the slice being filled from [from] (offset in the slice). + Uint8List? partFrom(int from) { + if (from >= filled) return null; + final out = BytesBuilder(copy: false); + var at = 0; + for (final p in parts) { + final end = at + p.length; + if (end > from) out.add(at >= from ? p : Uint8List.sublistView(p, from - at)); + at = end; + } + return out.takeBytes(); + } + + Uint8List takeSlice() { + final out = BytesBuilder(copy: false); + for (final p in parts) { + out.add(p); + } + parts.clear(); + filled = 0; + return out.takeBytes(); + } } class _Viewer { int pos = 0; int open = 0; // requests in flight int newest = 0; // sequence of its newest request + (int slice, int req)? blocking; // where its newest request waits DateTime lastActive = DateTime.now(); } @@ -134,9 +161,11 @@ class _Viewer { /// slowest and [ahead] past the furthest viewer. Evicted by distance from /// the viewers, never LRU. /// - Filled only while someone watches: a viewer blocked on a slice comes -/// first (oldest wait first); then read-ahead. +/// first (oldest wait first); then read-ahead. A blocked viewer reads the +/// slice being filled as its bytes arrive, not once it is whole. /// - Only a viewer's newest request moves its position or asks the feed -/// for anything; older ones still get bytes once they are buffered. +/// for anything; older ones still get bytes once they are buffered, and +/// stop holding the feed once superseded (a seek elsewhere). /// Everything lives in memory: dropping the buffer is the cleanup. class RelayBuffer implements TrackBuffer { /// Set by the relay when a media's tracks share the budget. @@ -166,6 +195,8 @@ class RelayBuffer implements TrackBuffer { final _slices = {}; final _waiters = >>{}; final _blocked = {}; // slice -> wait start (us) + // The driving requests waiting on each blocked slice. + final _blockedBy = >{}; final _viewers = {}; @override FeedClock clock = FeedClock(); @@ -256,18 +287,19 @@ class RelayBuffer implements TrackBuffer { _endPull(pl); return; } - pl.buf.add(Uint8List.sublistView(data, off, off + take)); + pl.parts.add(Uint8List.sublistView(data, off, off + take)); + pl.filled += take; off += take; pl.pos += take; - if (pl.pos == _sliceEnd(s) + 1) { - _slices[s] = pl.buf.takeBytes(); - for (final c in _waiters.remove(s) ?? const >[]) { - c.complete(); - } - if (_shouldStop(pl, s)) { - _endPull(pl); - return; - } + final done = pl.pos == _sliceEnd(s) + 1; + if (done) _slices[s] = pl.takeSlice(); + // Whole, or more of it: whoever waits on it reads on. + for (final c in _waiters.remove(s) ?? const >[]) { + c.complete(); + } + if (done && _shouldStop(pl, s)) { + _endPull(pl); + return; } } } @@ -405,25 +437,54 @@ class RelayBuffer implements TrackBuffer { await _kick.future.timeout(const Duration(seconds: 5), onTimeout: () {}); } - Future _waitSlice(int s, bool drives) async { + /// The bytes of slice [s] from [from] (offset in the slice) as soon as + /// there are any: the whole slice's rest, or what the pull filling it has + /// so far. Null when they did not come within [waitLimit]. [drives]: the + /// request is its viewer's newest ([driving] says while it still is), so + /// it may move the feed. + Future _waitBytes(_Viewer v, int s, int from, int req, bool Function() driving) async { final deadline = DateTime.now().add(waitLimit); - while (!_slices.containsKey(s)) { - if (_disposed || DateTime.now().isAfter(deadline)) return false; - if (drives) { - _blocked.putIfAbsent(s, () => DateTime.now().microsecondsSinceEpoch); - // A pull nobody waits on gives way at once; one serving another - // blocked viewer hands over at its next slice boundary. + try { + while (true) { + final whole = _slices[s]; + if (whole != null) return from == 0 ? whole : Uint8List.sublistView(whole, from); final pl = _pull; - final at = pl == null ? -1 : pl.pos ~/ slice; - if (pl != null && at != s && !_blocked.containsKey(at)) _endPull(pl); - _wake(); + if (pl != null && pl.pos ~/ slice == s && pl.start <= s * slice) { + final part = pl.partFrom(from); + if (part != null) return part; + } + if (_disposed || DateTime.now().isAfter(deadline)) return null; + if (driving()) { + _blocked.putIfAbsent(s, () => DateTime.now().microsecondsSinceEpoch); + _blockedBy.putIfAbsent(s, () => {}).add(req); + v.blocking = (s, req); + // A pull nobody waits on gives way at once; one serving another + // blocked viewer hands over at its next slice boundary. + final at = pl == null ? -1 : pl.pos ~/ slice; + if (pl != null && at != s && !_blocked.containsKey(at)) _endPull(pl); + _wake(); + } else { + _unblock(s, req); + } + final c = Completer(); + _waiters.putIfAbsent(s, () => []).add(c); + await c.future.timeout(const Duration(seconds: 2), onTimeout: () {}); } - final c = Completer(); - _waiters.putIfAbsent(s, () => []).add(c); - await c.future.timeout(const Duration(seconds: 2), onTimeout: () {}); + } finally { + _unblock(s, req); + } + } + + // [req] no longer waits on slice [s]; the slice stops counting as + // blocked once nobody driving does. + void _unblock(int s, int req) { + final by = _blockedBy[s]; + if (by == null || !by.remove(req)) return; + if (by.isEmpty) { + _blockedBy.remove(s); + _blocked.remove(s); + _wake(); } - _blocked.remove(s); - return true; } // ---------------------------------------------------------------- viewers @@ -444,6 +505,10 @@ class RelayBuffer implements TrackBuffer { final tail = start >= size - pin; final me = ++_reqSeq; if (!tail) { + // A seek: the request it supersedes stops holding the feed at once. + final was = v.blocking; + if (was != null) _unblock(was.$1, was.$2); + v.blocking = null; v.newest = me; v.pos = start; } @@ -454,16 +519,14 @@ class RelayBuffer implements TrackBuffer { var off = start; while (off <= end) { final s = off ~/ slice; - if (!await _waitSlice(s, tail || v.newest == me)) { - throw StateError('relay: bytes at $off did not arrive'); - } - final data = _slices[s]; - if (data == null) continue; // evicted between wake and read - final from = off - s * slice, to = min(data.length, end - s * slice + 1); - yield Uint8List.sublistView(data, from, to); - bytesToViewers += to - from; - onSent?.call(to - from); - off += to - from; + final data = await _waitBytes(v, s, off - s * slice, me, () => tail || v.newest == me); + if (data == null) throw StateError('relay: bytes at $off did not arrive'); + final n = min(data.length, end - off + 1); + if (n <= 0) continue; + yield n == data.length ? data : Uint8List.sublistView(data, 0, n); + bytesToViewers += n; + onSent?.call(n); + off += n; v.lastActive = DateTime.now(); if (!tail && v.newest == me) v.pos = off; _wake(); diff --git a/tool/smoke_linux.sh b/tool/smoke_linux.sh new file mode 100755 index 0000000..266df61 --- /dev/null +++ b/tool/smoke_linux.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# The close smoke run of tool/smoke_windows.ps1 on Linux (the same app +# mode, apps/desktop/lib/src/platform/smoke.dart): start the release +# bundle with a short clip, open it, play, close; check it exited within +# LIMIT seconds and left no process. Needs a display (skips without one). +# Usage (after `flutter build linux --release`): tool/smoke_linux.sh [LIMIT] +set -euo pipefail +LIMIT="${1:-45}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +EXE="$ROOT/apps/desktop/build/linux/x64/release/bundle/starsync" +[[ -x "$EXE" ]] || { echo "build first: $EXE missing" >&2; exit 1; } +if [[ -z "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]]; then echo "skipped: no display"; exit 0; fi +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT +MEDIA="$WORK/smoke.mp4" +ffmpeg -y -v error -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440 \ + -t 5 -c:v libx264 -preset ultrafast -pix_fmt yuv420p -c:a aac "$MEDIA" +OUT="$WORK/smoke.txt" +start=$(date +%s) +STARSYNC_PROFILE=smoke STARSYNC_SMOKE="$MEDIA" STARSYNC_SMOKE_OUT="$OUT" "$EXE" >"$WORK/stdout.txt" 2>&1 & +pid=$! +exited=0 +for _ in $(seq $((LIMIT * 10))); do + if ! kill -0 "$pid" 2>/dev/null; then exited=1; break; fi + sleep 0.1 +done +secs=$(( $(date +%s) - start )) +steps="$(tr '\n' '|' < "$OUT" 2>/dev/null || true)" +echo "smoke steps: $steps (after $secs s)" +grep -E 'smoke:|close:|mpv: render' "$WORK/stdout.txt" | tail -20 || true +if [[ $exited == 0 ]]; then kill -9 "$pid" 2>/dev/null || true; fi +left="$(pgrep -f "$EXE" || true)" +[[ -n "$left" ]] && kill -9 $left 2>/dev/null || true +grep -qx start "$OUT" 2>/dev/null || { [[ $exited == 1 ]] && { echo "skipped: the app never came up"; exit 0; }; echo "did not come up nor exit" >&2; exit 1; } +grep -q '^skip ' "$OUT" && { echo "skipped: $(grep '^skip ' "$OUT")"; exit 0; } +grep -q '^fail ' "$OUT" && { echo "smoke failed: $(grep '^fail ' "$OUT")" >&2; exit 1; } +grep -q '^opened ' "$OUT" || { echo "the media never opened" >&2; exit 1; } +[[ $exited == 1 ]] || { echo "the app did not close within $LIMIT s (P32)" >&2; exit 1; } +[[ -z "$left" ]] || { echo "a starsync process was left behind (P32)" >&2; exit 1; } +echo "smoke ok: opened and closed in $secs s, no process left" diff --git a/tool/smoke_windows.ps1 b/tool/smoke_windows.ps1 new file mode 100644 index 0000000..b774fdb --- /dev/null +++ b/tool/smoke_windows.ps1 @@ -0,0 +1,70 @@ +# Close smoke run (P32) on the built Windows app: start it with a short +# media file, let it open the file in a player with a video output, play a +# second and close the normal way (`apps/desktop/lib/src/platform/smoke.dart`), +# then check it exited within $Limit seconds and left no process behind. +# A runner that cannot bring up a window or a video output skips (warning). +# Usage (after a release build): powershell -File tool/smoke_windows.ps1 [-Limit 45] +param([int]$Limit = 45) +$ErrorActionPreference = 'Stop' +$Root = Split-Path -Parent $PSScriptRoot +$Exe = Join-Path $Root 'apps/desktop/build/windows/x64/runner/Release/starsync.exe' +if (-not (Test-Path $Exe)) { throw "build first: $Exe missing" } +$Exe = (Resolve-Path $Exe).Path + +$Work = Join-Path ([IO.Path]::GetTempPath()) "starsync-smoke-$PID" +New-Item -ItemType Directory -Force $Work | Out-Null +$Out = Join-Path $Work 'smoke.txt' + +# Media: a 5 s clip with ffmpeg when the runner has it, else a 5 s WAV +# written here (mpv opens either; headless mpv needs an audio track). +$Media = Join-Path $Work 'smoke.mp4' +$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue +if ($ffmpeg) { + & $ffmpeg.Source -y -v error -f lavfi -i 'testsrc2=size=320x180:rate=24' -f lavfi -i 'sine=frequency=440' ` + -t 5 -c:v libx264 -preset ultrafast -pix_fmt yuv420p -c:a aac $Media +} +if (-not $ffmpeg -or $LASTEXITCODE -or -not (Test-Path $Media)) { + $Media = Join-Path $Work 'smoke.wav' + $rate = 48000; $n = $rate * 5 + $data = New-Object byte[] ($n * 2) + for ($i = 0; $i -lt $n; $i++) { + $v = [int16](3000 * [Math]::Sin(2 * [Math]::PI * 440 * $i / $rate)) + $data[2 * $i] = [byte]($v -band 0xFF); $data[2 * $i + 1] = [byte](($v -shr 8) -band 0xFF) + } + $f = [IO.File]::Create($Media); $w = New-Object IO.BinaryWriter($f) + $w.Write([Text.Encoding]::ASCII.GetBytes('RIFF')); $w.Write([int](36 + $data.Length)) + $w.Write([Text.Encoding]::ASCII.GetBytes('WAVEfmt ')); $w.Write([int]16); $w.Write([int16]1); $w.Write([int16]1) + $w.Write([int]$rate); $w.Write([int]($rate * 2)); $w.Write([int16]2); $w.Write([int16]16) + $w.Write([Text.Encoding]::ASCII.GetBytes('data')); $w.Write([int]$data.Length); $w.Write($data); $w.Close() +} + +# Its own profile: no settings, sign-ins or client id of a real install. +$env:STARSYNC_PROFILE = 'smoke' +$env:STARSYNC_SMOKE = $Media +$env:STARSYNC_SMOKE_OUT = $Out +$clock = [Diagnostics.Stopwatch]::StartNew() +$p = Start-Process -FilePath $Exe -PassThru +$exited = $p.WaitForExit($Limit * 1000) +$secs = [int]$clock.Elapsed.TotalSeconds +$lines = if (Test-Path $Out) { Get-Content $Out } else { @() } +Write-Host "smoke steps: $($lines -join ' | ') (after $secs s)" +$logs = Join-Path $env:APPDATA 'foxyz\Starsync\profiles\smoke\logs' +if (Test-Path $logs) { + Get-ChildItem $logs -Filter 'app-*.log' | Sort-Object LastWriteTime | Select-Object -Last 1 | + ForEach-Object { Select-String -Path $_.FullName -Pattern 'smoke:|close:|mpv: render' | ForEach-Object { $_.Line } } +} +$left = Get-Process -Name starsync -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $Exe } +if (-not $exited) { $p | Stop-Process -Force -ErrorAction SilentlyContinue } +$left | Stop-Process -Force -ErrorAction SilentlyContinue + +if (-not ($lines -contains 'start')) { + if ($exited) { Write-Warning "skipped: the app never came up on this runner (exit $($p.ExitCode))"; exit 0 } + Write-Error "the app did not come up and did not exit within $Limit s"; exit 1 +} +if ($lines | Where-Object { $_ -like 'skip *' }) { Write-Warning "skipped: $($lines -like 'skip *')"; exit 0 } +if ($lines | Where-Object { $_ -like 'fail *' }) { Write-Error "smoke failed: $($lines -like 'fail *')"; exit 1 } +if (-not ($lines | Where-Object { $_ -like 'opened *' })) { Write-Error 'the media never opened'; exit 1 } +if (-not $exited) { Write-Error "the app did not close within $Limit s (P32)"; exit 1 } +if ($left) { Write-Error "a starsync process was left behind (P32)"; exit 1 } +Write-Host "smoke ok: opened and closed in $secs s, no process left" +Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue