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:
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 '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<void> 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<bool> openUrl(String url, {Map<String, String>? 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<bool> openUrl(String url,
|
||||
{Map<String, String>? 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<bool> _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<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;
|
||||
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<void> stop() async {
|
||||
|
||||
@@ -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<void> 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<bool> _openMedia(String url, {Map<String, String>? 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<void> _leavePreviousMedia(int mediaId) async {
|
||||
final held = _playerMediaId;
|
||||
_playerMediaId = mediaId;
|
||||
// Opened from the welcome, before any state: that was this media.
|
||||
if (held == -1 && _openedFor == -1 && _openUrl != null) _openedFor = mediaId;
|
||||
if (held == -1 || held == mediaId) return;
|
||||
_openUrl = null;
|
||||
_openHeaders = null;
|
||||
|
||||
@@ -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';
|
||||
|
||||
+36
-15
@@ -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
|
||||
|
||||
Reference in new issue
Block a user