Files
plezy/lib/mpv/player/player_native.dart
T
edde746 bcd6fe9906 feat(linux): HDR video on a native Wayland plane
Video on Linux went through a Flutter texture: 8-bit sRGB, which cannot carry
HDR at all, and which forced a whole-window Flutter recomposite for every video
frame. This moves it onto a wl_subsurface stacked below the Flutter surface, with
mpv rendering into an EGL window surface on it through the libmpv render API. The
subsurface is desynchronized, so video and UI now present independently.

With the plane in place HDR follows: the surface is described to the compositor
through wp_color_manager_v1 as the source's own curve and gamut - PQ or HLG,
BT.2020 - carrying whatever HDR10 static metadata the stream actually declares.
The description and the buffer it describes land on the same commit, staged and
validated before mpv is switched, so a PQ frame is never presented labelled sRGB.
A five-second watchdog bounds the one wait a compositor could otherwise leave
hanging. A session that cannot host the plane - X11, or a compositor without
wl_subcompositor - fails initialize with VIDEO_PLANE_UNSUPPORTED naming the
reason: the texture path is gone, and refusing by name beats degrading to
something the user cannot see. An SDR output, a missing capability or an 8-bit
config keep the plane and simply leave it undescribed.

The output's colour state is trusted only when it has been earned. Every landed
property step records itself as it lands; a reset or sequence that cannot
finish downgrades its result to unknown and marks the applied-output cache
untrusted until a clean apply earns it back. A plane whose output state cannot
be named is quarantined - hidden, its description withdrawn - and the
quarantine is recorded state: an unrelated visibility change cannot put a
mislabelled plane back on screen, and only a commit that resolves to a nameable
outcome lifts it. A rect collapsing to zero detaches the buffer exactly as
hiding does, a refused setVideoRect drops the Dart-side sent-rect cache so the
next layout pass retries for free, and a refused tone-mapping pick tells the
user instead of dying in a log.

NVIDIA's Wayland EGL (through at least 610.xx) offers no 10-bit unorm window
configs, so the plane takes half-float as the tier between 10-bit unorm and
8-bit, declares the whole surface opaque so the compositor never reads the
alpha those configs carry, and states GL_RGBA16F rather than a 10-bit lie.
Whether the output is in HDR is read from luminance headroom above its own
reference white rather than from the preferred transfer function, which current
KWin no longer answers PQ for; the margin is half a stop, because KWin reports
an undimmed maximum over a software-dimmed SDR white. Validated on an RTX 4090
(driver 610.57.04) under KWin 6.7.4 with locked-exposure photographs.

Who tone-maps is a user choice. The default is the compositor: photographed on a
400-nit HDR output against a PQ chart it keeps 400 -> 1000 nits monotonic and
separated where the player leg flattens them, because the player path drives
mpv's legacy vo_gpu, whose own standalone output scores the same. The gap is the
renderer, not the wiring.

The decision itself - what the source carries, what the output supports, what to
tell mpv and what to tell the compositor - lives in hdr_metadata.h, free of
Wayland and GTK so its luminance validation can be tested without a display
server. Sending an incoherent luminance set is a protocol error that disconnects
the client, so the rules are worth a unit test.

The deb, rpm and pacman packages now declare wayland-client, wayland-egl and EGL:
the plane links them directly and bundle-libs.sh deliberately never bundles them,
since they are coupled to the running compositor and GPU driver.

lib/dev/harness_main.dart is a second entrypoint for measuring this on hardware -
it drives one clip with scripted mpv properties and reports the colour state mpv
actually settled on. Nothing imports it, so it is tree-shaken out of the app.

Verified on a Steam Deck against an external 400-nit HDR display: the compositor
reports PQ / BT.2020, the connector carries HDR_OUTPUT_METADATA, and against mpv
vo=gpu-next on the same frame the shipped build sits 4.90 counts away overall -
closer to the reference HDR player than to its own SDR fallback.
2026-08-10 08:48:13 +02:00

1075 lines
42 KiB
Dart

import 'dart:async' show unawaited;
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:flutter/services.dart';
import '../../media/media_display_criteria.dart';
import '../../utils/app_logger.dart';
import '../models.dart';
import 'audio_rendering_mode.dart';
import 'player_base.dart';
typedef _AudioStateRequest = ({
bool passthrough,
bool normalization,
bool downmix,
int downmixCenterBoostDb,
bool downmixNormalize,
double rate,
});
typedef _AudioStateGenerations = ({int passthrough, int normalization, int downmix, int rate});
/// MPV-backed player for platforms where AetherEngine is not the native route.
class PlayerNative extends PlayerBase {
/// Video player on the default mpv channels/core.
PlayerNative()
: methodChannel = const MethodChannel('com.plezy/mpv_player'),
eventChannel = const EventChannel('com.plezy/mpv_player/events'),
audioOnly = false;
/// Audio-only player on the dedicated music channels/core (see
/// [Player.audio]). Skips every video concern: no render layer
/// ([setVisible] no-ops via [audioOnly]), no subtitle plumbing, no
/// display-mode handling.
PlayerNative.audio()
: methodChannel = const MethodChannel('com.plezy/mpv_audio_player'),
eventChannel = const EventChannel('com.plezy/mpv_audio_player/events'),
audioOnly = true;
String _dvConversionMode = 'auto';
String _dvConversionLog = 'no';
// Gapless-audio arming state (audioOnly). The native playlist is always
// [current, next?]; these track whether entry 1 exists and what it plays.
// _armedNextUri keeps the ORIGINAL media URI (the music service matches
// trackTransition events against it); _armedNextFd is the content-fd claim
// when the armed URI needed fdclose:// conversion (see _toPlayableUri).
bool _hasArmedNext = false;
String? _armedNextUri;
int? _armedNextFd;
/// Host tests aren't Android, so the content:// → fdclose:// path would be
/// unreachable; forces the conversion regardless of platform.
@visibleForTesting
static bool debugForceContentFdConversion = false;
/// Overrides the Linux video-plane detection in host tests.
@visibleForTesting
static bool? debugUseLinuxVideoPlane;
/// Whether this process drives video through the Linux Wayland plane, which
/// is `Platform.isLinux` and nothing finer — Linux has no other render path.
///
/// The one place the test override is resolved, so production code and host
/// tests agree on which path is live without reading a test-only field.
static bool get usesLinuxVideoPlane => debugUseLinuxVideoPlane ?? Platform.isLinux;
// Set by open() and consumed by that load's file-loaded event, so it is
// not mistaken for a gapless advance (see _handleAudioFileLoaded).
bool _expectOpenFileLoad = false;
/// Whether this instance drives the audio-only core.
final bool audioOnly;
@override
final MethodChannel methodChannel;
@override
final EventChannel eventChannel;
@override
String get logPrefix => audioOnly ? 'MPV-audio' : 'MPV';
@override
String get playerType => 'mpv';
@override
bool get providesNativeStats => Platform.isAndroid;
@override
bool get attachesExternalSubtitlesAtOpen => true;
/// Node properties are returned as structured maps on desktop and Apple
/// platforms, but as JSON strings on Android.
static final String _nodeFormat = Platform.isAndroid ? 'string' : 'node';
static String _normalizeDvConversionMode(String value) {
return switch (value.toLowerCase()) {
'disabled' || 'native' => 'disabled',
'dv81' || 'p8' || 'p7_to_p8' || 'p7-to-p8' => 'dv81',
'hevc' || 'hevc_strip' || 'p7_to_hevc' || 'p7-to-hevc' => 'hevc_strip',
_ => 'auto',
};
}
static String _normalizeBoolProperty(String value) {
return switch (value.toLowerCase()) {
'1' || 'true' || 'yes' || 'on' => 'yes',
_ => 'no',
};
}
static String _fixedLengthQuote(String value) {
return '%${utf8.encode(value).length}%$value';
}
/// Query-free tail of [uri] for logs (keeps the part id, drops tokens).
static String _uriTail(String uri) {
final path = uri.split('?').first;
return path.length <= 40 ? path : '…${path.substring(path.length - 40)}';
}
static String _escapePathListEntry(String value, String separator) {
return value.replaceAll(r'\', r'\\').replaceAll(separator, '\\$separator');
}
static String? _externalSubtitlesLoadfileOption(List<SubtitleTrack>? externalSubtitles) {
final separator = Platform.isWindows ? ';' : ':';
final escapedUris = externalSubtitles
?.map((subtitle) => subtitle.uri)
.whereType<String>()
.where((uri) => uri.isNotEmpty)
.toSet()
.map((uri) => _escapePathListEntry(uri, separator))
.toList();
if (escapedUris == null || escapedUris.isEmpty) return null;
return 'sub-files=${_fixedLengthQuote(escapedUris.join(separator))}';
}
/// Per-entry `http-header-fields` options for a `loadfile ... append`
/// options arg. Every header rides its own `-append` entry because mpv's
/// string-LIST parser splits a plain `http-header-fields=a,b` value on
/// commas with no way to escape them — a header value containing a comma
/// (`X-Plex-Device: Mac17,9` on Apple hardware) would be split into a
/// colon-less garbage line that Plex rejects with 400 "Error parsing HTTP
/// request". `-append` takes a single verbatim item; the fixed-length
/// quote shields it from the outer key=value list split. The leading
/// `-clr` stops the file-local list from inheriting (and duplicating) the
/// current track's global headers set by [open].
static String? _httpHeaderFieldsLoadfileOption(Map<String, String>? headers) {
if (headers == null || headers.isEmpty) return null;
final appends = headers.entries
.map((e) => 'http-header-fields-append=${_fixedLengthQuote('${e.key}: ${e.value}')}')
.join(',');
return 'http-header-fields-clr=,$appends';
}
MediaDisplayCriteria? _effectiveDisplayCriteria(MediaDisplayCriteria? criteria) {
if (criteria == null || (criteria.doviProfile ?? 0) != 7) return criteria;
final convertToDv81 = _dvConversionMode == 'auto' || _dvConversionMode == 'dv81';
if (convertToDv81) {
return MediaDisplayCriteria(
fps: criteria.fps,
width: criteria.width,
height: criteria.height,
doviProfile: 8,
doviLevel: criteria.doviLevel,
doviCompatibilityId: 1,
transfer: criteria.transfer ?? 'smpte2084',
primaries: criteria.primaries ?? 'bt2020',
matrix: criteria.matrix ?? 'bt2020nc',
);
}
return MediaDisplayCriteria(
fps: criteria.fps,
width: criteria.width,
height: criteria.height,
doviProfile: 0,
doviCompatibilityId: criteria.doviCompatibilityId ?? 1,
transfer: criteria.transfer ?? 'smpte2084',
primaries: criteria.primaries ?? 'bt2020',
matrix: criteria.matrix ?? 'bt2020nc',
);
}
// Memoizes the in-flight init Future so concurrent callers (e.g. the
// parallel `requestAudioFocus()` and `setProperty()` paths kicked off in
// VideoPlayerScreen._initializePlayer) share one `invoke('initialize')`.
// Two concurrent invokes on Android caused MpvPlayerPlugin.handleInitialize
// to dispose-and-recreate the in-flight core, hanging playback (#930).
Future<void>? _initFuture;
Future<void> _audioStateTail = Future<void>.value();
Future<void>? _disposeFuture;
bool _disposing = false;
bool get _nativeCoreUnavailable => disposed || _disposing;
@override
Future<T?> invoke<T>(String method, [dynamic args]) {
if (_nativeCoreUnavailable) return Future<T?>.value();
return super.invoke<T>(method, args);
}
double _requestedRate = 1.0;
Future<void> _ensureInitialized() async {
if (initialized) return;
return _initFuture ??= _doInitialize();
}
Future<void> _doInitialize() async {
try {
final result = await invoke<Object>('initialize');
if (result != true) {
throw Exception('Failed to initialize player');
}
if (_nativeCoreUnavailable) throw StateError('Player was disposed during initialization');
// Subscribe to MPV properties before flipping `initialized` so partial
// failures don't leave us in a half-initialized state that the memoized
// future would falsely treat as ready.
await observeCoreProperties(trackListFormat: _nodeFormat);
await observeProperty('secondary-sid', 'string');
await observeProperty('demuxer-cache-state', _nodeFormat);
await observeProperty('audio-device-list', _nodeFormat);
await observeProperty('audio-device', 'string');
if (audioOnly) {
// Debug aid only: raw playlist positions in the log trail. Gapless
// advance DETECTION rides the file-loaded event instead — see
// _handleAudioFileLoaded for why property edges are unreliable.
await observeProperty('playlist-pos', _nodeFormat);
// The Apple audio core sets this at context init; set it defensively
// here so every mpv audio backend behaves identically. Direct invoke —
// setProperty() would await _ensureInitialized and deadlock on the
// memoized future of this very _doInitialize call.
await invoke('setProperty', {'name': 'gapless-audio', 'value': 'weak'});
}
if (_nativeCoreUnavailable) throw StateError('Player was disposed during initialization');
initialized = true;
} catch (e) {
_initFuture = null;
if (!_nativeCoreUnavailable) {
errorController.add(PlayerError('Initialization failed: $e'));
}
rethrow;
}
}
Future<int?> _openContentFd(String contentUri) async {
try {
return await invoke<int>('openContentFd', {'uri': contentUri});
} catch (e) {
return null;
}
}
/// Closes a detached content fd that mpv will never consume. Fire-and-forget
/// safe: a failure only leaks one fd.
Future<void> _closeContentFd(int fd, {bool duringDispose = false}) async {
try {
if (duringDispose) {
await super.invoke('closeContentFd', {'fd': fd});
} else {
await invoke('closeContentFd', {'fd': fd});
}
} catch (e) {
appLogger.d('$logPrefix: closeContentFd($fd) failed', error: e);
}
}
/// Converts Android SAF content:// URIs to `fdclose://<fd>` — mpv owns the fd
/// and closes it when it opens the entry. Returns the loadfile URI and the
/// opened fd (null when no conversion applied). [strict] throws instead of
/// falling back to the raw URI when the fd cannot be opened: mpv cannot
/// open content:// itself, so arming one would stall playback at the track
/// boundary — setNext must fail loudly so the music service falls back to
/// an explicit open.
Future<(String, int?)> _toPlayableUri(String uri, {bool strict = false}) async {
final convert = (Platform.isAndroid || debugForceContentFdConversion) && uri.startsWith('content://');
if (!convert) return (uri, null);
final fd = await _openContentFd(uri);
if (fd == null) {
if (strict) throw StateError('openContentFd failed for ${_uriTail(uri)}');
return (uri, null);
}
return ('fdclose://$fd', fd);
}
@override
Future<void> open(
Media media, {
bool play = true,
bool isLive = false,
List<SubtitleTrack>? externalSubtitles,
Duration? timelineDuration,
}) async {
if (_nativeCoreUnavailable) return;
await _ensureInitialized();
if (_nativeCoreUnavailable) return;
// `loadfile replace` (below) clears the native playlist, dropping any
// gapless entry armed via setNext — settle its content-fd claim first.
// No transition is surfaced: the caller is replacing playback anyway.
await _clearArmedNext(adoptIfRolledIn: false);
final startPosition = media.start ?? Duration.zero;
configureTimeline(duration: timelineDuration);
clearTracks();
setExternalSubtitleMetadata(externalSubtitles);
resetPlaybackProgress(startPosition);
setSeekable(false);
if (!audioOnly) await setVisible(true);
// Rebuild the header list via `change-list` items — a plain
// `setProperty('http-header-fields', joined)` splits on commas inside
// header VALUES (`X-Plex-Device: Mac17,9` on Apple hardware), producing a
// malformed request Plex rejects with 400. `append` takes each item
// verbatim. Always clear first so a previous open's headers never leak
// into header-less media.
await command(['change-list', 'http-header-fields', 'clr', '']);
if (media.headers != null && media.headers!.isNotEmpty) {
for (final entry in media.headers!.entries) {
await command(['change-list', 'http-header-fields', 'append', '${entry.key}: ${entry.value}']);
}
}
// 'start' must be set before loadfile.
if (startPosition.inSeconds > 0) {
await setProperty('start', (startPosition.inMilliseconds / 1000.0).toString());
} else {
await setProperty('start', 'none');
}
// Prevents race condition that can freeze the video decoder on Android (issue #226).
if (!play) {
await setProperty('pause', 'yes');
}
// Prevent mpv's own default subtitle selection from racing the
// server-backed TrackManager decision applied after tracks are discovered.
await setProperty('sid', 'no');
await setProperty('secondary-sid', 'no');
// Convert content:// URIs to fdclose:// for MPV on Android (SAF SD card
// downloads). The immediate `loadfile replace` consumes the fd, so no
// claim tracking is needed here (unlike setNext).
final (uri, _) = await _toPlayableUri(media.uri);
final loadfileArgs = ['loadfile', uri, 'replace'];
final loadfileOption = _externalSubtitlesLoadfileOption(externalSubtitles);
if (loadfileOption != null) {
loadfileArgs.addAll(['-1', loadfileOption]);
}
if (audioOnly) _expectOpenFileLoad = true;
await command(loadfileArgs);
// mpv's pause property survives loadfile; in-place reloads pause the old
// file before resolving, so explicitly unpause for the replacement. Set
// after loadfile so the paused old file never audibly unpauses
// pre-replace.
if (play) {
await setProperty('pause', 'no');
}
}
@override
Future<void> play() async {
if (_nativeCoreUnavailable) return;
await setProperty('pause', 'no');
}
@override
Future<void> pause() async {
if (_nativeCoreUnavailable) return;
await setProperty('pause', 'yes');
}
@override
Future<void> stop() async {
if (_nativeCoreUnavailable) return;
// `stop` tears down the playlist without mpv opening the armed entry —
// settle its content-fd claim first. No transition: playback is ending.
await _clearArmedNext(adoptIfRolledIn: false);
await command(['stop']);
setSeekable(false);
if (!audioOnly) await invoke('setVisible', {'visible': false});
}
@override
Future<void> seek(Duration position) async {
if (_nativeCoreUnavailable) return;
await runSeek(position, () => command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute']));
}
@override
Future<void> setNext(Media? media) async {
if (_nativeCoreUnavailable || !audioOnly || !initialized) return;
await _clearArmedNext();
if (media == null) return;
final (loadUri, fd) = await _toPlayableUri(media.uri, strict: true);
// Per-entry options are the 4th loadfile argument on mpv >= 0.38
// (`loadfile <url> append -1 opt=val`), exactly like open() passes
// sub-files. `gapless-audio=weak` splices the armed entry into the
// running audio stream when formats match.
final args = ['loadfile', loadUri, 'append'];
final headerOption = _httpHeaderFieldsLoadfileOption(media.headers);
if (headerOption != null) {
args.addAll(['-1', headerOption]);
}
try {
await command(args);
} catch (e) {
// The entry never joined the playlist, so the fd has no consumer.
if (fd != null) unawaited(_closeContentFd(fd));
rethrow;
}
_hasArmedNext = true;
_armedNextUri = media.uri;
_armedNextFd = fd;
appLogger.d('MPV-audio: armed next ${_uriTail(media.uri)}');
}
/// Clears the armed entry (if any), resolving the arm/advance race and the
/// content-fd claim. mpv may have already rolled into the armed entry
/// before this runs; blindly removing index 1 then would remove the
/// PLAYING entry, so playlist-pos is checked first. fd ownership: mpv owns
/// the fd from the moment it opens the entry (fdclose closes it at stream
/// close); Dart may close only when the entry provably never opened —
/// playlist-pos 0 both before and after a successful remove. Anything
/// ambiguous leaks the fd (one fd, ms-wide window) rather than risk
/// closing an fd mpv holds. The remove's success cannot be the ownership
/// signal: Android's command bridge never surfaces mpv command failures.
///
/// [adoptIfRolledIn]: when mpv already advanced into the armed entry,
/// surface the transition here (the pending file-loaded event becomes a
/// no-op once the flags are cleared) — without this a queue edit landing
/// exactly at the gapless boundary desyncs the music service from the
/// audio for the whole next track. Callers that replace or stop playback
/// pass false: no one is listening for that entry anymore.
Future<void> _clearArmedNext({bool adoptIfRolledIn = true, bool duringDispose = false}) async {
if (!_hasArmedNext) return;
final uri = _armedNextUri;
final fd = _armedNextFd;
_hasArmedNext = false;
_armedNextUri = null;
_armedNextFd = null;
String? pos;
try {
pos = duringDispose
? await super.invoke<String>('getProperty', {'name': 'playlist-pos'})
: await getProperty('playlist-pos');
} catch (_) {
// Unknown state — fall through to the remove, never close the fd.
}
if (pos == '1') {
appLogger.d('MPV-audio: clear requested but armed entry already playing');
if (adoptIfRolledIn) _completeArmedAdvance(uri);
return;
}
// The handover is off: the entry is not playing and is about to be removed.
// Anything frozen for it would otherwise outlive the arm and be preferred
// by a later advance whose own boundary edge went missing.
discardFrozenOutgoingPosition();
appLogger.d('MPV-audio: clearing armed entry (playlist-remove 1)');
try {
if (duringDispose) {
await super.invoke('command', {
'args': ['playlist-remove', '1'],
});
} else {
await command(['playlist-remove', '1']);
}
} on PlatformException {
// Entry 1 vanished in the arm/advance race — mpv rolled into it and
// the file-loaded handler already rebased. The fd (if any) is mpv's.
return;
}
if (fd == null) return;
String? postPos;
try {
postPos = duringDispose
? await super.invoke<String>('getProperty', {'name': 'playlist-pos'})
: await getProperty('playlist-pos');
} catch (_) {}
if (pos == '0' && postPos == '0') {
unawaited(_closeContentFd(fd, duringDispose: duringDispose));
}
// Any other combination is ambiguous (mpv advanced mid-clear, idle
// playlist, property error): leak on doubt.
}
/// The armed entry became the playing one (mpv rolled into it): clear the
/// arm — the fd (if any) was consumed by mpv — remove the spent entry so
/// the playing entry rebases to index 0, and surface the transition.
void _completeArmedAdvance(String? uri) {
// A different source is playing now, so a seek still in flight against the
// old one must not land its target on this one's timeline (#1819).
takeSourceOwnership();
_hasArmedNext = false;
_armedNextUri = null;
_armedNextFd = null;
appLogger.d('MPV-audio: armed entry advanced → playlist-remove 0, ${_uriTail(uri ?? '')}');
unawaited(_removeSpentPlaylistEntry());
if (uri != null) trackTransitionController.add(uri);
}
Future<void> _removeSpentPlaylistEntry() async {
try {
await command(['playlist-remove', '0']);
} catch (error, stackTrace) {
appLogger.w('MPV-audio: failed to remove spent playlist entry', error: error, stackTrace: stackTrace);
}
}
@override
void handlePropertyChange(String name, dynamic value) {
if (audioOnly && name == 'playlist-pos') {
// Detection still belongs to _handleAudioFileLoaded, but this is the last
// point ordered ahead of the new source's own position reports: they ride
// this same property flow, while `file-loaded` rides the event flow. Take
// the outgoing track's final position while it is still the current one.
if (_hasArmedNext) freezeOutgoingSourcePosition();
appLogger.d('MPV-audio: playlist-pos=$value (armed=$_hasArmedNext)');
return;
}
super.handlePropertyChange(name, value);
}
@override
void handlePlayerEvent(String name, Map? data) {
if (audioOnly && name == 'file-loaded') _handleAudioFileLoaded();
super.handlePlayerEvent(name, data);
}
/// Gapless auto-advance detection: a `file-loaded` that open() didn't
/// produce while an entry is armed means mpv rolled into the armed entry.
/// Surface the transition, then rebase the playlist so the now playing
/// entry sits at index 0 again ([setNext] always appends at 1). The rebase
/// only removes the spent entry behind the playing one, so it cannot
/// disturb position/duration — those refresh with the same file-loaded.
///
/// Detection deliberately rides this EVENT, not `playlist-pos` property
/// edges: mpv coalesces observed-property notifications per observer
/// (1→0→1 under delivery lag nets out to nothing) and the Android bridge
/// additionally drops property changes when its shared 64-slot buffer
/// overflows (`MutableSharedFlow.tryEmit` from the native event thread),
/// so an edge can vanish entirely — which stalled playback at the end of
/// the armed track. `file-loaded` fires exactly once per started file on
/// the low-volume event flow. Clearing [_hasArmedNext] before emitting
/// makes a hypothetical duplicate signal a no-op (it cannot double
/// advance).
void _handleAudioFileLoaded() {
if (_expectOpenFileLoad) {
_expectOpenFileLoad = false;
appLogger.d('MPV-audio: file-loaded (open)');
return;
}
if (!_hasArmedNext) {
appLogger.d('MPV-audio: file-loaded (nothing armed, ignored)');
return;
}
_completeArmedAdvance(_armedNextUri);
}
@override
Future<void> dispose({bool preserveDisplayMode = false}) {
final existing = _disposeFuture;
if (existing != null) return existing;
_disposing = true;
final disposal = _disposeNative(preserveDisplayMode: preserveDisplayMode);
_disposeFuture = disposal;
return disposal;
}
Future<void> _disposeNative({required bool preserveDisplayMode}) async {
if (disposed) return;
// Settle an armed-but-unconsumed content fd before the base teardown
// disables invoke() — the playlist is torn down without mpv ever opening
// the entry.
if (_hasArmedNext) {
try {
await _clearArmedNext(adoptIfRolledIn: false, duringDispose: true);
} catch (_) {
// Leak on doubt.
}
}
await _audioStateTail;
await super.dispose(preserveDisplayMode: preserveDisplayMode);
}
@override
Future<void> selectAudioTrack(AudioTrack track) async {
if (_nativeCoreUnavailable) return;
await setProperty('aid', track.id);
}
@override
Future<void> selectSubtitleTrack(SubtitleTrack track) async {
if (_nativeCoreUnavailable) return;
await setProperty('sid', track.id);
}
@override
Future<void> selectSecondarySubtitleTrack(SubtitleTrack track) async {
if (_nativeCoreUnavailable) return;
await setProperty('secondary-sid', track.id);
}
@override
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {
if (_nativeCoreUnavailable) return;
final args = ['sub-add', uri, select ? 'select' : 'auto'];
if (title != null) args.add('title=$title');
if (language != null) args.add('lang=$language');
await command(args);
}
@override
Future<void> setVolume(double volume) async {
if (_nativeCoreUnavailable) return;
await setProperty('volume', volume.toString());
if (!_nativeCoreUnavailable) setVolumeState(volume);
}
@override
Future<void> setRate(double rate) {
if (_nativeCoreUnavailable) return Future<void>.value();
_requestedRate = rate;
return _enqueueAudioStateReconciliation(_rateAudioField);
}
@override
Future<void> setAudioDevice(AudioDevice device) async {
if (_nativeCoreUnavailable) return;
await setProperty('audio-device', device.name);
}
/// The system's resolved audio rendering mode, used for the Dolby playback
/// badge. Apple populates `AVAudioSession.renderingMode` for CarPlay and
/// AirPlay routes, so an Apple TV on HDMI is expected to report
/// `notApplicable` — treat that as unknown, never as "not Dolby".
/// Returns null on platforms without the native method.
@override
Future<AudioRenderingMode?> getAudioRenderingMode() async {
if (!Platform.isIOS || _nativeCoreUnavailable) return null;
try {
final raw = await invoke<Map<Object?, Object?>>('getAudioRenderingMode', const {});
if (raw == null) return null;
return AudioRenderingMode(
name: raw['name'] as String? ?? 'unknown',
rawValue: (raw['rawValue'] as num?)?.toInt() ?? 0,
route: raw['route'] as String? ?? 'none',
outputChannels: (raw['outputChannels'] as num?)?.toInt() ?? 0,
maxOutputChannels: (raw['maxOutputChannels'] as num?)?.toInt() ?? 0,
);
} on PlatformException {
return null;
} on MissingPluginException {
return null;
}
}
@override
Future<void> setProperty(String name, String value) => _setProperty(name, value, synchronizeRate: true);
Future<void> _setProperty(String name, String value, {required bool synchronizeRate}) async {
if (_nativeCoreUnavailable) return;
final updatesDvMode = (Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-mode';
final updatesDvLog = (Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-log';
if (updatesDvMode) value = _normalizeDvConversionMode(value);
if (updatesDvLog) value = _normalizeBoolProperty(value);
await _ensureInitialized();
await invoke('setProperty', {'name': name, 'value': value});
if (_nativeCoreUnavailable) return;
if (updatesDvMode) _dvConversionMode = value;
if (updatesDvLog) _dvConversionLog = value;
if (synchronizeRate && name == 'speed') {
final rate = double.tryParse(value);
if (rate != null && rate.isFinite) {
_currentRate = rate;
_requestedRate = rate;
final accepted = _acceptedAudioState;
_acceptedAudioState = (
passthrough: accepted.passthrough,
normalization: accepted.normalization,
downmix: accepted.downmix,
downmixCenterBoostDb: accepted.downmixCenterBoostDb,
downmixNormalize: accepted.downmixNormalize,
rate: rate,
);
} else {
// The native bridge may accept custom mpv speed syntax. Its numeric
// value is unknown, so the next typed setRate must write explicitly.
_currentRate = double.nan;
}
}
}
@override
Future<String?> getProperty(String name) async {
if (_nativeCoreUnavailable) return null;
if ((Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-mode') {
return _dvConversionMode;
}
if ((Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-log') {
return _dvConversionLog;
}
await _ensureInitialized();
return await invoke<String>('getProperty', {'name': name});
}
@override
Future<Map<String, dynamic>> getStats() async {
if (_nativeCoreUnavailable || !Platform.isAndroid) return super.getStats();
await _ensureInitialized();
final result = await invoke<Map>('getStats');
return Map<String, dynamic>.from(result ?? const {});
}
/// mpv commands that relocate the playhead while computing their own
/// destination, so Dart never learns where it landed up front (#1819).
static const _playheadRelocatingCommands = {'sub-seek'};
@override
Future<void> command(List<String> args) async {
if (_nativeCoreUnavailable) return;
await _ensureInitialized();
// Re-checked after the await: initialization can fail, or the core can be
// torn down, while this call is suspended. Announcing a jump that no
// command will follow would retire a consumer's pending target for nothing.
if (_nativeCoreUnavailable) return;
if (args.isEmpty || !_playheadRelocatingCommands.contains(args.first)) {
await invoke('command', {'args': args});
return;
}
// Claimed before the announcement so two overlapping subtitle seeks, or a
// seek issued while this one runs, cannot both think they own the playhead.
final token = beginPlayheadRelocation();
// Announced before dispatch for the same reason runSeek announces its
// request: the stale window is the round trip, not what follows it. The
// destination is unknown at this point, hence null.
announcePlayheadJump(null);
await invoke('command', {'args': args});
// The command was accepted, so the playhead has moved even if the read
// below cannot say where. Take ownership now: an in-flight seek group
// settling afterwards must not roll back across a cue that happened.
commitPlayheadRelocation(token);
// Read back where mpv actually went. `PlayerState.position` is what
// relative seeks rebase from and its tick updates are throttled, so
// without this a skip pressed straight after would start from the
// pre-command position.
//
// Nothing is published when the read fails: guessing would hand a
// fabricated position to consumers as authoritative. The null announced
// before dispatch already told consumers to drop what they were holding,
// and the backend's next tick supplies the real position.
final seconds = double.tryParse(await invoke<String>('getProperty', {'name': 'time-pos'}) ?? '');
if (seconds != null && seconds.isFinite && !seconds.isNegative) {
publishPlayheadRelocation(Duration(milliseconds: (seconds * 1000).round()), token: token);
}
}
@override
bool get needsDecoderRefreshAfterDisplaySwitch => Platform.isAndroid;
@override
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {
if (_nativeCoreUnavailable || audioOnly || !Platform.isIOS) return;
await _ensureInitialized();
await invoke('setDisplayCriteria', {
'criteria': _effectiveDisplayCriteria(criteria)?.toJson(),
'extraDelayMs': extraDelayMs,
});
}
@override
Future<void> setLogLevel(String level) async {
if (_nativeCoreUnavailable) return;
await _ensureInitialized();
await invoke('setLogLevel', {'level': level});
}
@override
Future<bool> setVisible(bool visible, {bool restoreOnWindowVisible = false}) async {
if (_nativeCoreUnavailable) return false;
final changed = await super.setVisible(visible, restoreOnWindowVisible: restoreOnWindowVisible);
return changed && !_nativeCoreUnavailable;
}
static const int _passthroughAudioField = 1 << 0;
static const int _normalizationAudioField = 1 << 1;
static const int _downmixAudioField = 1 << 2;
static const int _rateAudioField = 1 << 3;
bool _passthroughRequested = false;
bool _passthroughActive = false;
bool _normalizationRequested = false;
bool _normalizationActive = false;
bool _downmixRequested = false;
bool _downmixActive = false;
int _downmixCenterBoostDb = 0;
int _activeDownmixCenterBoostDb = 0;
bool _downmixNormalize = false;
bool _activeDownmixNormalize = false;
double _currentRate = 1.0;
int _passthroughGeneration = 0;
int _normalizationGeneration = 0;
int _downmixGeneration = 0;
int _rateGeneration = 0;
_AudioStateRequest _acceptedAudioState = const (
passthrough: false,
normalization: false,
downmix: false,
downmixCenterBoostDb: 0,
downmixNormalize: false,
rate: 1.0,
);
@override
bool get audioPassthroughActive => _passthroughActive;
/// Codecs the platform can take as a bitstream. On iOS/tvOS compressed
/// audio goes through the system renderer, which only handles Dolby
/// Digital (Plus); desktop does real device passthrough for the full list.
static final String _passthroughCodecs = Platform.isIOS ? 'ac3,eac3' : 'ac3,eac3,dts,dts-hd,truehd';
_AudioStateRequest get _requestedAudioState => (
passthrough: _passthroughRequested,
normalization: _normalizationRequested,
downmix: _downmixRequested,
downmixCenterBoostDb: _downmixCenterBoostDb,
downmixNormalize: _downmixNormalize,
rate: _requestedRate,
);
_AudioStateRequest _rebaseAudioState(_AudioStateRequest accepted, _AudioStateRequest requested, int fields) => (
passthrough: fields & _passthroughAudioField != 0 ? requested.passthrough : accepted.passthrough,
normalization: fields & _normalizationAudioField != 0 ? requested.normalization : accepted.normalization,
downmix: fields & _downmixAudioField != 0 ? requested.downmix : accepted.downmix,
downmixCenterBoostDb: fields & _downmixAudioField != 0
? requested.downmixCenterBoostDb
: accepted.downmixCenterBoostDb,
downmixNormalize: fields & _downmixAudioField != 0 ? requested.downmixNormalize : accepted.downmixNormalize,
rate: fields & _rateAudioField != 0 ? requested.rate : accepted.rate,
);
void _restoreFailedRequestedFields(_AudioStateRequest previous, int fields, _AudioStateGenerations generations) {
if (fields & _passthroughAudioField != 0 && generations.passthrough == _passthroughGeneration) {
_passthroughRequested = previous.passthrough;
}
if (fields & _normalizationAudioField != 0 && generations.normalization == _normalizationGeneration) {
_normalizationRequested = previous.normalization;
}
if (fields & _downmixAudioField != 0 && generations.downmix == _downmixGeneration) {
_downmixRequested = previous.downmix;
_downmixCenterBoostDb = previous.downmixCenterBoostDb;
_downmixNormalize = previous.downmixNormalize;
}
if (fields & _rateAudioField != 0 && generations.rate == _rateGeneration) {
_requestedRate = previous.rate;
}
}
Future<void> _enqueueAudioStateReconciliation(int fields) {
final requested = _requestedAudioState;
if (fields & _passthroughAudioField != 0) ++_passthroughGeneration;
if (fields & _normalizationAudioField != 0) ++_normalizationGeneration;
if (fields & _downmixAudioField != 0) ++_downmixGeneration;
if (fields & _rateAudioField != 0) ++_rateGeneration;
final generations = (
passthrough: _passthroughGeneration,
normalization: _normalizationGeneration,
downmix: _downmixGeneration,
rate: _rateGeneration,
);
final operation = _audioStateTail.then((_) => _reconcileAudioState(requested, fields, generations));
_audioStateTail = operation.catchError((Object _, StackTrace _) {});
return operation;
}
Future<void> _reconcileAudioState(
_AudioStateRequest requested,
int fields,
_AudioStateGenerations generations,
) async {
if (_nativeCoreUnavailable) return;
final previous = _acceptedAudioState;
final target = _rebaseAudioState(previous, requested, fields);
try {
await _applyAudioState(target);
if (_nativeCoreUnavailable) return;
_acceptedAudioState = target;
} catch (error, stackTrace) {
try {
await _applyAudioState(
previous,
forceDownmix: fields & _downmixAudioField != 0,
forceNormalization: fields & _downmixAudioField != 0,
);
} catch (rollbackError, rollbackStackTrace) {
appLogger.e(
'MPV: failed to restore accepted audio state',
error: rollbackError,
stackTrace: rollbackStackTrace,
);
}
_restoreFailedRequestedFields(previous, fields, generations);
Error.throwWithStackTrace(error, stackTrace);
}
}
Future<void> _applyAudioState(
_AudioStateRequest target, {
bool forceDownmix = false,
bool forceNormalization = false,
}) async {
if (_nativeCoreUnavailable) return;
final passthroughShouldBeActive = target.passthrough && target.rate == 1.0 && !target.downmix;
// mpv cannot scaletempo compressed audio and filters cannot process a
// bitstream. Always leave passthrough before applying either state.
if (_passthroughActive && !passthroughShouldBeActive) {
await _applyPassthrough(false);
}
if (_currentRate != target.rate) {
await _setProperty('speed', target.rate.toString(), synchronizeRate: false);
_currentRate = target.rate;
}
if (forceDownmix ||
_downmixActive != target.downmix ||
(target.downmix &&
(_activeDownmixCenterBoostDb != target.downmixCenterBoostDb ||
_activeDownmixNormalize != target.downmixNormalize))) {
await super.setAudioDownmix(
enabled: target.downmix,
centerBoostDb: target.downmixCenterBoostDb,
normalize: target.downmixNormalize,
);
_downmixActive = target.downmix;
_activeDownmixCenterBoostDb = target.downmixCenterBoostDb;
_activeDownmixNormalize = target.downmixNormalize;
}
final normalizationShouldBeActive = target.normalization && !passthroughShouldBeActive;
if (forceNormalization || _normalizationActive != normalizationShouldBeActive) {
await super.setAudioNormalization(normalizationShouldBeActive);
_normalizationActive = normalizationShouldBeActive;
}
if (passthroughShouldBeActive && !_passthroughActive) {
await _applyPassthrough(true);
}
}
@override
Future<void> setAudioPassthrough(bool enabled) {
if (_nativeCoreUnavailable) return Future<void>.value();
_passthroughRequested = enabled;
return _enqueueAudioStateReconciliation(_passthroughAudioField);
}
Future<void> _applyPassthrough(bool enabled) async {
await setProperty('audio-spdif', enabled ? _passthroughCodecs : '');
if (_nativeCoreUnavailable) return;
// audio-spdif is the authoritative transition. Publish only after mpv
// accepts it; audio-exclusive below is an independent device-mode hint.
_passthroughActive = enabled;
// audio-exclusive redirects coreaudio to coreaudio_exclusive on macOS
// (and exclusive WASAPI on Windows); on iOS/tvOS it is set once at
// playback start and must not be clobbered here.
if (!Platform.isIOS) {
try {
await setProperty('audio-exclusive', enabled ? 'yes' : 'no');
} catch (error, stackTrace) {
appLogger.w('MPV: failed to update exclusive-audio hint', error: error, stackTrace: stackTrace);
}
}
}
@override
Future<void> setAudioNormalization(bool enabled) {
if (_nativeCoreUnavailable) return Future<void>.value();
_normalizationRequested = enabled;
return _enqueueAudioStateReconciliation(_normalizationAudioField);
}
@override
Future<void> setAudioDownmix({required bool enabled, required int centerBoostDb, required bool normalize}) {
if (_nativeCoreUnavailable) return Future<void>.value();
_downmixRequested = enabled;
_downmixCenterBoostDb = centerBoostDb;
_downmixNormalize = normalize;
return _enqueueAudioStateReconciliation(_downmixAudioField);
}
@override
Future<void> updateFrame() async {
if (_nativeCoreUnavailable || !initialized) return;
if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux) {
await invoke('updateFrame');
}
}
@override
Future<bool> setVideoFrameRate(
double fps,
int durationMs, {
int extraDelayMs = 0,
int videoWidth = 0,
int videoHeight = 0,
}) async {
if (_nativeCoreUnavailable || !Platform.isAndroid || !initialized) return false;
final result = await invoke<bool>('setVideoFrameRate', {
'fps': fps,
'duration': durationMs,
'extraDelayMs': extraDelayMs,
'videoWidth': videoWidth,
'videoHeight': videoHeight,
});
return result ?? false;
}
@override
Future<void> clearVideoFrameRate() async {
if (_nativeCoreUnavailable || !Platform.isAndroid || !initialized) return;
await invoke('clearVideoFrameRate');
}
@override
Future<bool> requestAudioFocus() async {
if (_nativeCoreUnavailable) return false;
if (!Platform.isAndroid) return true;
await _ensureInitialized();
return await invoke<bool>('requestAudioFocus') ?? false;
}
@override
Future<void> abandonAudioFocus() async {
if (_nativeCoreUnavailable || !Platform.isAndroid || !initialized) return;
await invoke('abandonAudioFocus');
}
/// See [Player.isHdrOutputSupported] for why this is a query and not a constant.
///
/// Only Linux delegates to the native side, because only there does the answer
/// move: it folds in the output the plane currently sits on. Everywhere else it
/// is a platform constant. Windows has a native query of its own, but nothing
/// consults this method there - the settings sheet offers HDR on Windows
/// unconditionally - so asking would only let the two disagree about the same
/// platform. Nothing is cached on either path.
@override
Future<bool> isHdrOutputSupported() async {
// No video plane without video, on any platform, so this precedes the
// platform question rather than sitting inside one branch of it.
if (_nativeCoreUnavailable || audioOnly) return false;
// Asked through usesLinuxVideoPlane, not Platform.isLinux, so this and the
// settings sheet's _probesHdrSupport resolve the same way under the test
// override; on a real Linux host the two are the same answer.
if (usesLinuxVideoPlane) return await invoke<bool>('isHDRSupported') ?? false;
return Platform.isIOS || Platform.isMacOS || Platform.isWindows;
}
}