diff --git a/apps/desktop/lib/src/playback/playback_controller.dart b/apps/desktop/lib/src/playback/playback_controller.dart index 73205f9..7ed965b 100644 --- a/apps/desktop/lib/src/playback/playback_controller.dart +++ b/apps/desktop/lib/src/playback/playback_controller.dart @@ -19,10 +19,11 @@ import '../sync/user_seek.dart'; import 'media_marks.dart'; import 'yt_dlp_app.dart' show AppYtDlp, ytdlFormat; -/// Owns one mpv [Player] (and its [VideoController]) per session: created +/// 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". Never reuse a player across sessions — -/// a GPU-side freeze sticks to the player, not to the widget. +/// [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. class PlaybackController { /// Read-ahead target: about two minutes rides out short outages and /// makes skipping an intro instant. @@ -202,6 +203,7 @@ class PlaybackController { Future init() async { if (_player != null) return; _player = _newPlayer(bufferSize: maxBytes, logLevel: MPVLogLevel.warn); // see [logLevel] + _held = false; _shaders = ''; renderMode.value = RenderMode.unknown; shaderNotice.value = null; @@ -438,15 +440,22 @@ class PlaybackController { /// Opens [url] paused. [headers] go with every request for it (mpv /// `http-header-fields`): a Jellyfin token travels there, not in the URL. - /// When [preload] holds the same [url] and [headers], that player - /// becomes this one instead (true: promoted, its buffer comes along). - Future openUrl(String url, {Map? headers}) async { + /// 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; @@ -635,6 +644,7 @@ class PlaybackController { _timeSubs.clear(); _player = null; _video = null; + _held = false; alive.value = false; _clearMarks(); await _settleUi(); @@ -706,11 +716,7 @@ class PlaybackController { await _pin('mute', 'yes', p); final path = ytdlPath ?? _ytdlPath; if (path != null) { - final dynamic platform = p.platform; - await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path'); - await platform?.setProperty('ytdl-format', ytdlFormat); - pre.ytdlRaw = await AppYtDlp.instance.mpvRawOptions(); - await platform?.setProperty('ytdl-raw-options', pre.ytdlRaw); + pre.ytdlRaw = await _setYtdl(p, path); pre.ytdl = true; } // With a view up, its own video output: mpv decodes (media_kit @@ -765,9 +771,8 @@ class PlaybackController { } catch (_) {} } - /// [pre] becomes the session player: the view swaps to its video - /// output ([generation]), volume, shaders, log level and the full cache - /// carry over, and the old player goes once the view let go of it. + /// [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; @@ -780,6 +785,83 @@ class PlaybackController { 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) { @@ -787,9 +869,10 @@ class PlaybackController { } _timeSubs.clear(); _player = p; - _video = pre.video; - _ytdlSetOn = pre.ytdl ? p : null; - _ytdlRaw = pre.ytdlRaw; + _video = video; + _held = true; + _ytdlSetOn = ytdl ? p : null; + _ytdlRaw = ytdlRaw; _chapterJson = ''; _httpError = null; _loadErrorSent = false; @@ -805,23 +888,22 @@ class PlaybackController { _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); - await _pin('mute', 'no', p); - await _pinBufferProps(p); } catch (e) { - debugPrint('promoted player settings: $e'); + debugPrint('new player volume: $e'); } unawaited(applyShaders()); unawaited(_refreshTracks()); - // The view lets go of the old texture first (as in [dispose]). - unawaited(() async { + _retiring = () async { await _settleUi(); try { await old.dispose(); } catch (_) {} - }()); - return true; + }(); } Future stop() async { diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index a853582..9f02cce 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -290,6 +290,10 @@ class SessionCoordinator { // Unlike [_mediaId] it survives a lost connection, so a reconnect to the // same media keeps the player, and 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; @@ -361,8 +365,9 @@ class SessionCoordinator { jellyfin = jellyfin ?? JellyfinAccounts.instance, trackPrefs = trackPrefs ?? TrackPrefs(); - /// Player lifetime = one session: created here on connect, disposed by - /// [leave]. Never reused across sessions (a GPU-side video freeze sticks + /// 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). Future initPlayer() async { if (_playerReady) return; @@ -465,6 +470,7 @@ class SessionCoordinator { diff.reset(); _mediaId = -1; _playerMediaId = -1; + _openedFor = -1; settledMediaId.value = -1; _openUrl = null; playerError.value = ''; @@ -552,9 +558,13 @@ class SessionCoordinator { }); } - /// Opens [url] (with [headers] on every request); false on failure. + /// 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 (a reconnect) keeps it. Future _openMedia(String url, {Map? headers}) async { if (url == _openUrl) return true; + final keepPlayer = _playerMediaId == _openedFor; + _openedFor = _playerMediaId; _openUrl = url; _openHeaders = headers; diff.reset(); @@ -564,7 +574,10 @@ class SessionCoordinator { try { await _prepareYtDlp(url); switchTrace.mark(mediaId, SwitchTrace.open); - final promoted = await playback.openUrl(url, headers: headers); + final promoted = await playback.openUrl(url, + headers: headers, + keepPlayer: keepPlayer, + onFreshPlayer: () => switchTrace.mark(mediaId, SwitchTrace.player)); switchTrace.mark(mediaId, promoted ? SwitchTrace.preloaded : SwitchTrace.opened); health.value = const HealthStatus(HealthSeverity.ok, 'connected'); @@ -938,6 +951,8 @@ class SessionCoordinator { 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 --git a/apps/desktop/lib/src/session/switch_trace.dart b/apps/desktop/lib/src/session/switch_trace.dart index 9f03cb2..8fee789 100644 --- a/apps/desktop/lib/src/session/switch_trace.dart +++ b/apps/desktop/lib/src/session/switch_trace.dart @@ -11,6 +11,8 @@ class SwitchTrace { static const state = 'state'; static const resolved = 'resolved'; static const open = 'open'; + /// A fresh player is up for it (no preload held it). + static const player = 'player'; static const opened = 'opened'; static const preloaded = 'preloaded'; static const buffered = 'buffered'; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ca24503..226b4f3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -282,7 +282,9 @@ 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). That, a yt-dlp hook error and a failed `openUrl` become +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 `link_probe.dart`: 412/precondition/login/cookies/sign in → "This site needs cookies — Settings → Player → yt-dlp → Cookies", 403 → "Not allowed @@ -299,15 +301,15 @@ logged and kept in `pinMismatches`. Measured against the `/m` endpoint 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) and is passed to the `VideoController`; env `SYNC_RELAY_HWDEC` (e.g. `no`) -overrides it. It is read per player, so a change applies on the next join or -Reload video. +overrides it. It is read per player, so a change applies on the next media, +join or Reload video. Shaders (per viewer, local only, no wire change): mpv `glsl-shaders`, set through the same dynamic `setProperty` and read back (`PlaybackController.applyShaders`). The value is the platform path list (`:` joined, `;` on Windows). One saved choice in `AppSettings` (`shaderPreset`, `shaderQuality`, `customShaders`): applied on every player -creation (join, Reload video) and live when changed from the session (the +creation (join, new media, Reload video) and live when changed from the session (the control bar's shader menu, Ctrl+0..6, both also save it). The Anime4K v4.0.1 files live in `apps/desktop/assets/shaders/anime4k/` (only the 14 the presets use, plus its MIT LICENSE); mpv needs real paths, so @@ -338,10 +340,29 @@ 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: the `Player` and its `VideoController` live for one -session: created on join, disposed on leave, replaced by `recreate()` for -"Reload video" (button or Ctrl+R; volume kept), or by a promoted preload -when Up next's first item goes up (see "Getting ready" under "Up next"). `openUrl` opens with `play: false`; play/pause/seek/rate come only from +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; a reconnect or joining back to +the media the player held keeps it (`_openedFor`). 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 @@ -917,11 +938,10 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in 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: - it becomes the session player (log, marks and track observers move - over, volume, unmute, the full cache pins, shaders), the view swaps to - its video output (`generation`), and the old player is disposed once - the view let go of its texture (as on leave). Its buffer comes along, + 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, 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 @@ -933,8 +953,9 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in 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. `SwitchTrace` (env `SYNC_RELAY_TRACE_SWITCH=1` prints it) times each - phase of a media change: state, resolved, open, opened or preloaded, - buffered, playing. + phase of a media change: state, resolved, open, player (a fresh + player is up; see "Player lifecycle" 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 preloading its first 4 s), filled ready, red failed (tried again every