Files
plezy/lib/mpv/player/player.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

424 lines
17 KiB
Dart

import 'dart:io' show Platform;
import '../../media/media_display_criteria.dart';
import '../../media/playback_rate.dart';
import '../models.dart';
import 'audio_rendering_mode.dart';
import 'platform/player_android.dart';
import 'player_native.dart';
import 'player_state.dart';
import 'player_streams.dart';
import 'platform/player_linux.dart';
import 'platform/player_windows.dart';
export 'player_base.dart';
/// Abstract interface for the video player.
///
/// This interface defines all playback control methods, state access,
/// and reactive streams for the video player.
///
/// Example usage:
/// ```dart
/// final player = Player();
/// await player.open(Media('https://example.com/video.mp4'));
/// await player.play();
///
/// // Configure player properties
/// await player.setProperty('hwdec', 'auto');
/// await player.setProperty('demuxer-max-bytes', '150000000');
///
/// // Listen to position updates
/// player.streams.position.listen((position) {
/// print('Position: $position');
/// });
///
/// // Access current state
/// print('Playing: ${player.state.playing}');
/// ```
abstract class Player {
/// Current synchronous state snapshot.
///
/// Use this for immediate state access in UI.
PlayerState get state;
/// Reactive streams for state changes.
///
/// Use these for reactive UI updates.
PlayerStreams get streams;
/// Fresh playback position, stored on every native position tick.
///
/// [PlayerState.position] is only refreshed at ~4Hz alongside the position
/// stream; use this for time-sensitive reads (sync anchors, drift math).
/// ExoPlayer's native tick is itself 250ms, which bounds freshness there.
Duration get currentPosition;
/// Where the source that just handed over was when it did, or null if none
/// has. A gapless advance retargets [state] and [currentPosition] at the new
/// source immediately, so anything finalising the outgoing item — progress
/// reporting, scrobbling — must read its last position from here.
Duration? get outgoingSourcePosition => null;
/// Whether audio passthrough (bitstream output) is currently active.
///
/// [setRate] with a non-1.0 rate tears passthrough down, so callers that
/// adjust the rate transiently (e.g. sync micro-corrections) must check
/// this first.
bool get audioPassthroughActive;
/// The type of player backend being used (e.g., 'mpv', 'exoplayer').
String get playerType;
/// Open a media source for playback.
///
/// [media] - The media source to open.
/// [play] - Whether to start playback immediately (default: true).
Future<void> open(
Media media, {
bool play = true,
bool isLive = false,
List<SubtitleTrack>? externalSubtitles,
Duration? timelineDuration,
});
/// Start or resume playback.
Future<void> play();
/// Pause playback.
Future<void> pause();
/// Toggle between play and pause.
Future<void> playOrPause();
/// Stop playback and reset position.
Future<void> stop();
/// Seek to a specific position.
Future<void> seek(Duration position);
/// Arm (or replace/clear) the item the backend should auto-advance into
/// when the current one plays out — the gapless-audio primitive.
///
/// Audio players keep a native playlist of `[current, next?]`: ExoPlayer
/// via `addMediaItem`, mpv via `loadfile append` with `gapless-audio`.
/// When the advance happens the backend emits
/// [PlayerStreams.trackTransition] with the armed [Media.uri] instead of
/// `completed`. Pass `null` to clear. No-op on video backends.
Future<void> setNext(Media? media);
/// Select an audio track.
Future<void> selectAudioTrack(AudioTrack track);
/// Select a subtitle track.
///
/// Pass [SubtitleTrack.off] to disable subtitles.
Future<void> selectSubtitleTrack(SubtitleTrack track);
/// Select a secondary subtitle track (displayed simultaneously with primary).
///
/// Only supported on mpv backends (desktop + Android mpv fallback).
/// Pass [SubtitleTrack.off] to disable secondary subtitles.
Future<void> selectSecondarySubtitleTrack(SubtitleTrack track);
/// Whether this player backend supports secondary subtitle tracks.
bool get supportsSecondarySubtitles;
/// Whether this backend ingests external subtitles in [open] (single
/// prepare(), safe to auto-play immediately). Backends returning false
/// need external subtitles added after open via [addSubtitleTrack] while
/// paused, and the caller resumes once the tracks are selected.
bool get attachesExternalSubtitlesAtOpen;
/// Whether the backend detects container fps from rendered frame
/// timestamps, so `container-fps` only becomes available a few frames
/// after playback starts (retry the property read instead of giving up).
bool get detectsFpsAfterRender;
/// Whether the video decoder must be refreshed (seek-in-place or
/// drop-buffers) after a display mode switch. True for mpv on Android,
/// where MediaCodec can stall against the reconfigured surface.
bool get needsDecoderRefreshAfterDisplaySwitch;
/// Whether [getStats] aggregates performance stats natively for the
/// active backend (the Android plugin covers both ExoPlayer and its mpv
/// fallback). Backends returning false are sampled via mpv property
/// reads instead.
bool get providesNativeStats;
/// Add an external subtitle track.
///
/// [uri] - URL or path to the subtitle file.
/// [title] - Optional display title.
/// [language] - Optional language code.
/// [select] - Whether to select this track immediately.
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false});
/// Set the playback volume.
///
/// [volume] - Volume level from 0.0 (muted) to 100.0 (max).
Future<void> setVolume(double volume);
/// Set the playback rate/speed.
///
/// [rate] - Playback rate from [minimumPlaybackRate] to [maximumPlaybackRate] (1.0 = normal speed).
Future<void> setRate(double rate);
/// Set the audio output device.
///
/// [device] - The audio device to use.
Future<void> setAudioDevice(AudioDevice device);
/// Set an MPV property by name.
///
/// Common properties:
/// - 'hwdec': Hardware decoding mode ('auto', 'no', 'videotoolbox', etc.)
/// - 'demuxer-max-bytes': Buffer size in bytes
/// - 'audio-delay': Audio sync offset in seconds (e.g., '0.5')
/// - 'sub-delay': Subtitle sync offset in seconds
/// - 'sub-font': Subtitle font name
/// - 'sub-font-size': Subtitle font size
/// - 'sub-color': Subtitle text color
/// - 'sub-back-color': Subtitle background color
/// - 'sub-border-size': Subtitle border size
/// - 'sub-margin-y': Vertical subtitle margin
/// - 'sub-ass': Enable/disable ASS subtitle rendering ('yes'/'no')
/// - 'audio-exclusive': Exclusive audio mode ('yes'/'no')
/// - 'audio-spdif': Audio passthrough formats (e.g., 'ac3,eac3,dts,truehd')
Future<void> setProperty(String name, String value);
/// Get an MPV property value by name.
Future<String?> getProperty(String name);
/// Set the native MPV log message level (e.g., "warn", "v", "debug").
///
/// This controls the volume of log messages sent from the native player
/// over the event channel. Use "warn" in production and "v" for debugging.
Future<void> setLogLevel(String level);
/// Execute a raw MPV command.
///
/// [args] - Command and arguments as a list of strings.
Future<void> command(List<String> args);
/// Prime native display matching from server metadata before the decoder
/// emits stream properties. Unsupported platforms ignore this.
///
/// [extraDelayMs] is added after a native display-switch completion event,
/// for TVs or AVRs that need extra HDMI settle time.
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0});
/// Configure subtitle fonts for libass rendering.
///
/// Extracts a comprehensive Unicode font (Go Noto) to the cache directory
/// and sets `sub-fonts-dir` and `sub-font` properties.
Future<void> configureSubtitleFonts();
/// Enable or disable audio passthrough mode.
///
/// When enabled, supported audio codecs (AC3, DTS, etc.) will be
/// passed through to the audio device without decoding.
Future<void> setAudioPassthrough(bool enabled);
/// The system's resolved audio rendering mode (Apple only); null elsewhere.
Future<AudioRenderingMode?> getAudioRenderingMode();
/// Enable or disable loudness normalization.
///
/// mpv backends insert/remove the `loudnorm` audio filter. Android
/// ExoPlayer attaches platform audio effects (DynamicsProcessing on
/// API 28+, LoudnessEnhancer otherwise) and forces decoded non-tunneled
/// PCM output while enabled so the effects can process the stream.
Future<void> setAudioNormalization(bool enabled);
/// Force a stereo downmix with a Kodi-style center channel boost.
///
/// [centerBoostDb] (0-12) raises the center channel above its standard
/// -3 dB downmix coefficient to improve dialogue clarity. [normalize]
/// attenuates the mix so it cannot clip; off keeps the original level
/// (Kodi's "maintain original volume"). mpv backends rebuild the audio
/// chain via `audio-channels`; Android ExoPlayer routes a
/// ChannelMixingAudioProcessor in the audio sink and force-decodes
/// encoded audio while enabled.
Future<void> setAudioDownmix({required bool enabled, required int centerBoostDb, required bool normalize});
/// Show or hide the video rendering layer.
///
/// On macOS, this controls the Metal layer visibility.
/// On other platforms, this may have no effect.
///
/// When [restoreOnWindowVisible] is true, macOS may restore the layer as soon
/// as AppKit reports the window visible again instead of waiting for Dart's
/// lifecycle resume callback.
///
/// Returns true if the operation was successful.
Future<bool> setVisible(bool visible, {bool restoreOnWindowVisible = false});
/// Update the video frame/surface dimensions.
///
/// On iOS/macOS, this updates the Metal layer's frame to match the current
/// window size. Call this when the layout changes (e.g., device rotation).
/// On other platforms, this is a no-op.
Future<void> updateFrame();
/// Whether this player's video output can currently carry HDR.
///
/// A query rather than a constant because on Linux it genuinely varies: the
/// native side needs a 10-bit plane, a compositor advertising the source's
/// transfer function and BT.2020, and an output the compositor reports as
/// being in HDR. Moving the window to an SDR monitor changes the answer.
Future<bool> isHdrOutputSupported();
/// Set the video frame rate for display refresh rate matching.
///
/// On Android, this hints the system to adjust the display refresh rate
/// to match the video content's frame rate, reducing judder and saving
/// battery on LTPO displays.
///
/// [fps] - The video frame rate (e.g., 23.976, 24, 30, 60).
/// [durationMs] - The video duration in milliseconds.
/// [extraDelayMs] - Extra settle time (ms) added to the native display-change
/// wait before playback is auto-resumed. Used to absorb the
/// user-configured "display switch delay" on Android TV.
///
/// Returns `true` if a display mode switch was initiated and the platform
/// will resume playback once the display settles; `false` if no switch was
/// needed (seamless fallback, invalid fps, no matching mode), in which case
/// the caller is responsible for starting playback itself.
///
/// On other platforms, this is a no-op that returns `false`.
Future<bool> setVideoFrameRate(
double fps,
int durationMs, {
int extraDelayMs = 0,
int videoWidth = 0,
int videoHeight = 0,
});
/// Clear the video frame rate hint and restore default display mode.
///
/// Call this when playback ends to restore the normal display refresh rate.
/// On other platforms, this is a no-op.
Future<void> clearVideoFrameRate();
/// Apply subtitle styling to the native rendering layer.
///
/// ExoPlayer renders subtitles natively (CaptionStyleCompat for text subs,
/// libass font scale for ASS), so styling must be pushed after [open].
/// No-op on mpv backends, which style subtitles via `sub-*` properties.
Future<void> setSubtitleStyle({
required double fontSize,
required String textColor,
required double borderSize,
required String borderColor,
required String bgColor,
required int bgOpacity,
int subtitlePosition = 100,
bool bold = false,
bool italic = false,
bool anchorToScreen = false,
});
/// Apply the box-fit mode to the native video layer
/// (0=FIT, 1=ZOOM/cover, 2=FILL/stretch).
///
/// ExoPlayer scales via AspectRatioFrameLayout; mpv backends are a no-op
/// here and scale via `panscan`/`video-aspect-override` properties instead.
Future<void> setBoxFitMode(int mode);
/// Apply custom zoom to the native video layer. No-op on mpv backends,
/// which zoom via the `video-zoom` property.
Future<void> setVideoZoom(double scale);
/// Aggregated native playback stats (codecs, dimensions, dropped frames…).
///
/// Returns an empty map on backends without native stats aggregation;
/// query mpv properties directly there instead.
Future<Map<String, dynamic>> getStats();
/// The backend actually playing right now, resolved from the native side.
///
/// Unlike [playerType] (the configured backend), this reflects runtime
/// fallbacks — e.g. 'mpv' after ExoPlayer hit an unsupported format.
Future<String> runtimePlayerType();
/// Request audio focus before starting playback.
///
/// On Android, this notifies the system that the app wants to play audio,
/// causing other media apps (Spotify, podcasts, etc.) to pause.
///
/// Returns true if audio focus was granted.
/// On other platforms, this is a no-op and returns true.
Future<bool> requestAudioFocus();
/// Abandon audio focus when playback stops.
///
/// On Android, this notifies the system that the app is done playing audio,
/// allowing other apps to resume their playback.
///
/// On other platforms, this is a no-op.
Future<void> abandonAudioFocus();
/// Whether the player has been disposed.
bool get disposed;
/// Dispose of the player and release resources.
///
/// [preserveDisplayMode] keeps any native display-mode hint active while a
/// replacement video route is being opened. Use false when leaving playback.
///
/// After calling this, the player instance should not be used.
Future<void> dispose({bool preserveDisplayMode = false});
/// Creates a new player instance.
///
/// Returns a platform-specific implementation:
/// - macOS/iOS: [PlayerNative] using MPVKit/libmpv with Metal rendering
/// - Android: [PlayerAndroid] using ExoPlayer (default) or [PlayerNative] using MPV (fallback)
/// - Windows: [PlayerWindows] using libmpv with native window embedding
/// - Linux: [PlayerLinux] using libmpv on a native Wayland video plane
///
/// On Android, pass [useExoPlayer] to override the default:
/// - true: Use ExoPlayer (default, better hardware support)
/// - false: Use MPV (more features, ASS subtitle rendering)
factory Player({bool? useExoPlayer}) {
if (Platform.isAndroid) {
// Default to ExoPlayer on Android, with MPV as fallback
// The caller should pass useExoPlayer based on SettingsService.getUseExoPlayer()
final useExo = useExoPlayer ?? true;
if (useExo) {
return PlayerAndroid(); // ExoPlayer (default)
}
return PlayerNative(); // MPV fallback
}
if (Platform.isMacOS || Platform.isIOS) {
return PlayerNative();
}
if (Platform.isWindows) {
return PlayerWindows();
}
if (Platform.isLinux) {
return PlayerLinux();
}
throw UnsupportedError('Player is not supported on this platform');
}
/// Creates the dedicated audio-only player used for music playback.
///
/// An mpv audio-only core on every platform — regardless of the Android
/// video backend setting — running on its own native core and channels
/// (`com.plezy/mpv_audio_player`), so it never contends with the video
/// pipeline. Desktop and Android need none of the video plumbing (display
/// modes, GL textures, surfaces) — the plain mpv wrapper suffices. Only
/// one native player is kept alive at a time: the music service disposes
/// this instance when video playback claims the session (see
/// `PlaybackCoordinator`), and the video core only exists while the video
/// player screen is open.
factory Player.audio() {
if (Platform.isAndroid || Platform.isMacOS || Platform.isIOS || Platform.isWindows || Platform.isLinux) {
return PlayerNative.audio();
}
throw UnsupportedError('Player is not supported on this platform');
}
}