A fresh player for every media change (the frozen frame after a swap needed Ctrl+R); promotion and fresh players share one hand-over

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-11 17:08:34 +03:00
1 parent c3fe8b682d
commit e52a7633a0
4 files changed
+163 -43

No files matched your search

@@ -19,10 +19,11 @@ import '../sync/user_seek.dart';
import 'media_marks.dart'; import 'media_marks.dart';
import 'yt_dlp_app.dart' show AppYtDlp, ytdlFormat; 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 /// by [init] on join, released by [dispose] on leave, swapped by
/// [recreate] for "Reload video". Never reuse a player across sessions — /// [recreate] for "Reload video" and by [openUrl] for every new media (a
/// a GPU-side freeze sticks to the player, not to the widget. /// 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 { class PlaybackController {
/// Read-ahead target: about two minutes rides out short outages and /// Read-ahead target: about two minutes rides out short outages and
/// makes skipping an intro instant. /// makes skipping an intro instant.
@@ -202,6 +203,7 @@ class PlaybackController {
Future<void> init() async { Future<void> init() async {
if (_player != null) return; if (_player != null) return;
_player = _newPlayer(bufferSize: maxBytes, logLevel: MPVLogLevel.warn); // see [logLevel] _player = _newPlayer(bufferSize: maxBytes, logLevel: MPVLogLevel.warn); // see [logLevel]
_held = false;
_shaders = ''; _shaders = '';
renderMode.value = RenderMode.unknown; renderMode.value = RenderMode.unknown;
shaderNotice.value = null; shaderNotice.value = null;
@@ -438,15 +440,22 @@ class PlaybackController {
/// Opens [url] paused. [headers] go with every request for it (mpv /// Opens [url] paused. [headers] go with every request for it (mpv
/// `http-header-fields`): a Jellyfin token travels there, not in the URL. /// `http-header-fields`): a Jellyfin token travels there, not in the URL.
/// When [preload] holds the same [url] and [headers], that player /// Each media gets its own player: when [preload] holds the same [url]
/// becomes this one instead (true: promoted, its buffer comes along). /// and [headers], that player becomes this one (true: promoted, its
Future<bool> openUrl(String url, {Map<String, String>? headers}) async { /// 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<bool> openUrl(String url,
{Map<String, String>? headers, bool keepPlayer = false, void Function()? onFreshPlayer}) async {
if (!_ready) return false; if (!_ready) return false;
final pre = _pre; final pre = _pre;
if (pre != null && pre.matches(url, headers)) { if (pre != null && pre.matches(url, headers)) {
if (await _promote(pre)) return true; if (await _promote(pre)) return true;
} }
if (_held && !keepPlayer && await _freshPlayer()) onFreshPlayer?.call();
if (!_ready) return false;
try { try {
_held = true;
_chapterJson = ''; _chapterJson = '';
_httpError = null; _httpError = null;
_loadErrorSent = false; _loadErrorSent = false;
@@ -635,6 +644,7 @@ class PlaybackController {
_timeSubs.clear(); _timeSubs.clear();
_player = null; _player = null;
_video = null; _video = null;
_held = false;
alive.value = false; alive.value = false;
_clearMarks(); _clearMarks();
await _settleUi(); await _settleUi();
@@ -706,11 +716,7 @@ class PlaybackController {
await _pin('mute', 'yes', p); await _pin('mute', 'yes', p);
final path = ytdlPath ?? _ytdlPath; final path = ytdlPath ?? _ytdlPath;
if (path != null) { if (path != null) {
final dynamic platform = p.platform; pre.ytdlRaw = await _setYtdl(p, path);
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.ytdl = true; pre.ytdl = true;
} }
// With a view up, its own video output: mpv decodes (media_kit // With a view up, its own video output: mpv decodes (media_kit
@@ -765,9 +771,8 @@ class PlaybackController {
} catch (_) {} } catch (_) {}
} }
/// [pre] becomes the session player: the view swaps to its video /// [pre] becomes the session player ([_adopt]); the full cache pins
/// output ([generation]), volume, shaders, log level and the full cache /// replace its small ones.
/// carry over, and the old player goes once the view let go of it.
Future<bool> _promote(_Preload pre) async { Future<bool> _promote(_Preload pre) async {
await pre.opened.future; await pre.opened.future;
final old = _player; final old = _player;
@@ -780,6 +785,83 @@ class PlaybackController {
await pre.logSub?.cancel(); await pre.logSub?.cancel();
if (!pre.ready.isCompleted) pre.ready.complete(true); if (!pre.ready.isCompleted) pre.ready.complete(true);
final p = pre.player; 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<void> _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<bool> _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<String> _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<void> _adopt(SyncPlayer p, VideoController? video, {required bool ytdl, String ytdlRaw = ''}) async {
final old = _player!;
final volume = old.state.volume; final volume = old.state.volume;
await _logSub?.cancel(); await _logSub?.cancel();
for (final sub in _timeSubs) { for (final sub in _timeSubs) {
@@ -787,9 +869,10 @@ class PlaybackController {
} }
_timeSubs.clear(); _timeSubs.clear();
_player = p; _player = p;
_video = pre.video; _video = video;
_ytdlSetOn = pre.ytdl ? p : null; _held = true;
_ytdlRaw = pre.ytdlRaw; _ytdlSetOn = ytdl ? p : null;
_ytdlRaw = ytdlRaw;
_chapterJson = ''; _chapterJson = '';
_httpError = null; _httpError = null;
_loadErrorSent = false; _loadErrorSent = false;
@@ -805,23 +888,22 @@ class PlaybackController {
_requestLog(p, logLevel); _requestLog(p, logLevel);
_watchMarks(p); _watchMarks(p);
generation.value++; generation.value++;
// Normally stopped already (the coordinator leaves the previous
// media first): never two sounds at once.
unawaited(old.pause().catchError((_) {}));
try { try {
await p.setVolume(volume); await p.setVolume(volume);
await _pin('mute', 'no', p);
await _pinBufferProps(p);
} catch (e) { } catch (e) {
debugPrint('promoted player settings: $e'); debugPrint('new player volume: $e');
} }
unawaited(applyShaders()); unawaited(applyShaders());
unawaited(_refreshTracks()); unawaited(_refreshTracks());
// The view lets go of the old texture first (as in [dispose]). _retiring = () async {
unawaited(() async {
await _settleUi(); await _settleUi();
try { try {
await old.dispose(); await old.dispose();
} catch (_) {} } catch (_) {}
}()); }();
return true;
} }
Future<void> stop() async { Future<void> stop() async {
@@ -290,6 +290,10 @@ class SessionCoordinator {
// Unlike [_mediaId] it survives a lost connection, so a reconnect to the // Unlike [_mediaId] it survives a lost connection, so a reconnect to the
// same media keeps the player, and a swap stops the previous media. // same media keeps the player, and a swap stops the previous media.
int _playerMediaId = -1; 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? _openUrl;
String _wsHost = ''; String _wsHost = '';
int _wsPort = 0; int _wsPort = 0;
@@ -361,8 +365,9 @@ class SessionCoordinator {
jellyfin = jellyfin ?? JellyfinAccounts.instance, jellyfin = jellyfin ?? JellyfinAccounts.instance,
trackPrefs = trackPrefs ?? TrackPrefs(); trackPrefs = trackPrefs ?? TrackPrefs();
/// Player lifetime = one session: created here on connect, disposed by /// The session's first player: created here on connect, disposed by
/// [leave]. Never reused across sessions (a GPU-side video freeze sticks /// [leave]; each new media gets its own ([PlaybackController.openUrl]).
/// Never reused across sessions or media (a GPU-side video freeze sticks
/// to the player). /// to the player).
Future<void> initPlayer() async { Future<void> initPlayer() async {
if (_playerReady) return; if (_playerReady) return;
@@ -465,6 +470,7 @@ class SessionCoordinator {
diff.reset(); diff.reset();
_mediaId = -1; _mediaId = -1;
_playerMediaId = -1; _playerMediaId = -1;
_openedFor = -1;
settledMediaId.value = -1; settledMediaId.value = -1;
_openUrl = null; _openUrl = null;
playerError.value = ''; 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<bool> _openMedia(String url, {Map<String, String>? headers}) async { Future<bool> _openMedia(String url, {Map<String, String>? headers}) async {
if (url == _openUrl) return true; if (url == _openUrl) return true;
final keepPlayer = _playerMediaId == _openedFor;
_openedFor = _playerMediaId;
_openUrl = url; _openUrl = url;
_openHeaders = headers; _openHeaders = headers;
diff.reset(); diff.reset();
@@ -564,7 +574,10 @@ class SessionCoordinator {
try { try {
await _prepareYtDlp(url); await _prepareYtDlp(url);
switchTrace.mark(mediaId, SwitchTrace.open); 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); switchTrace.mark(mediaId, promoted ? SwitchTrace.preloaded : SwitchTrace.opened);
health.value = health.value =
const HealthStatus(HealthSeverity.ok, 'connected'); const HealthStatus(HealthSeverity.ok, 'connected');
@@ -938,6 +951,8 @@ class SessionCoordinator {
Future<void> _leavePreviousMedia(int mediaId) async { Future<void> _leavePreviousMedia(int mediaId) async {
final held = _playerMediaId; final held = _playerMediaId;
_playerMediaId = mediaId; _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; if (held == -1 || held == mediaId) return;
_openUrl = null; _openUrl = null;
_openHeaders = null; _openHeaders = null;
@@ -11,6 +11,8 @@ class SwitchTrace {
static const state = 'state'; static const state = 'state';
static const resolved = 'resolved'; static const resolved = 'resolved';
static const open = 'open'; 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 opened = 'opened';
static const preloaded = 'preloaded'; static const preloaded = 'preloaded';
static const buffered = 'buffered'; static const buffered = 'buffered';
+36 -15
View File
@@ -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 (`onLoadError`), with ffmpeg's HTTP status when this player gets
ffmpeg's log (ffmpeg logs through one mpv instance per process: the 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 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 the coordinator's `playerError` in plain words (`playErrorText`, core
`link_probe.dart`: 412/precondition/login/cookies/sign in → "This site `link_probe.dart`: 412/precondition/login/cookies/sign in → "This site
needs cookies — Settings → Player → yt-dlp → Cookies", 403 → "Not allowed 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 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) 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`) 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 overrides it. It is read per player, so a change applies on the next media,
Reload video. join or Reload video.
Shaders (per viewer, local only, no wire change): mpv `glsl-shaders`, set Shaders (per viewer, local only, no wire change): mpv `glsl-shaders`, set
through the same dynamic `setProperty` and read back through the same dynamic `setProperty` and read back
(`PlaybackController.applyShaders`). The value is the platform path list (`PlaybackController.applyShaders`). The value is the platform path list
(`:` joined, `;` on Windows). One saved choice in `AppSettings` (`:` joined, `;` on Windows). One saved choice in `AppSettings`
(`shaderPreset`, `shaderQuality`, `customShaders`): applied on every player (`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 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 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 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 thread (upstream media-kit#1404), so an X11 session or a Flutter/GDK change
can silently fall back to software. can silently fall back to software.
Player lifecycle: the `Player` and its `VideoController` live for one Player lifecycle: a `Player` and its `VideoController` live for one
session: created on join, disposed on leave, replaced by `recreate()` for media: created on join, disposed on leave, replaced by `recreate()` for
"Reload video" (button or Ctrl+R; volume kept), or by a promoted preload "Reload video" (button or Ctrl+R; volume kept), and replaced on every
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 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 `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()` 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 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 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 (stream URLs expire); a failed preload is not retried until then and
changes nothing (the usual open follows). When the coordinator opens changes nothing (the usual open follows). When the coordinator opens
the same URL and headers (`openUrl`), the preload is promoted instead: the same URL and headers (`openUrl`), the preload is promoted instead
it becomes the session player (log, marks and track observers move of a fresh player: it becomes the session player through the same
over, volume, unmute, the full cache pins, shaders), the view swaps to hand-over (see "Player lifecycle" under "Media"), unmuted and with the
its video output (`generation`), and the old player is disposed once full cache pins. Its buffer comes along,
the view let go of its texture (as on leave). Its buffer comes along,
so the report sent right after the open already passes the gate: the 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 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 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 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. 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 `SwitchTrace` (env `SYNC_RELAY_TRACE_SWITCH=1` prints it) times each
phase of a media change: state, resolved, open, opened or preloaded, phase of a media change: state, resolved, open, player (a fresh
buffered, playing. 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 The row of the item shows a dot on its picture's corner (a tooltip
says which): hollow waiting, pulsing getting ready (resolving, then says which): hollow waiting, pulsing getting ready (resolving, then
preloading its first 4 s), filled ready, red failed (tried again every preloading its first 4 s), filled ready, red failed (tried again every