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

146 lines
5.6 KiB
Dart

import '../models.dart';
/// Reactive streams for player state changes.
///
/// Subscribe to these streams to receive updates when the player state changes.
/// For synchronous state access, use [PlayerState].
class PlayerStreams {
/// Stream of playing state changes.
final Stream<bool> playing;
/// Stream of completion state changes.
final Stream<bool> completed;
/// Stream of buffering state changes.
final Stream<bool> buffering;
/// Stream of position updates.
final Stream<Duration> position;
/// Emits whenever something asks the playhead to move discontinuously — every
/// seek, whatever asked for it, and every source opened at a start position
/// by an in-place reload.
///
/// The value is where the playhead is being put, or null when the backend
/// computes its own destination (`sub-seek`) and Dart does not know it yet.
/// A null MAY be followed by a non-null event once the destination has been
/// read back — but not always: an unreadable position publishes nothing
/// rather than guessing, and the backend's next tick supplies it instead.
///
/// These are announced at REQUEST time, not on completion, because the window
/// a consumer has to care about is exactly while the backend is still
/// working. Treat an event as intent plus a possible correction rather than
/// as an observed landing: a seek the backend rejects is usually followed by
/// a second event carrying the position it was actually left at, though a
/// request that never reached the backend at all has nothing to correct.
/// Listeners that issue seeks themselves will also see their own requests
/// here, so they must recognise their own targets rather than assume every
/// event is foreign.
///
/// [position] cannot stand in for this: a seek writes its target there
/// optimistically, and stale backend ticks then report the pre-seek position
/// again until the seek lands, so a listener cannot tell the two apart.
final Stream<Duration?> playheadJump;
/// Stream of duration changes (when media is loaded).
final Stream<Duration> duration;
/// Stream of seekability changes for the current media item.
final Stream<bool> seekable;
/// Stream of buffer position updates.
final Stream<Duration> buffer;
/// Stream of volume changes.
final Stream<double> volume;
/// Stream of playback rate changes.
final Stream<double> rate;
/// Stream of available tracks updates.
final Stream<Tracks> tracks;
/// Stream of track selection changes.
final Stream<TrackSelection> track;
/// Stream of log messages from the player.
final Stream<PlayerLog> log;
/// Stream of player errors.
final Stream<PlayerError> error;
/// Stream of audio device changes.
final Stream<AudioDevice> audioDevice;
/// Stream of available audio devices.
final Stream<List<AudioDevice>> audioDevices;
/// Stream that emits when playback restarts (first frame ready after load/seek).
final Stream<void> playbackRestart;
/// Stream that emits when the player has loaded the current media file.
final Stream<void> fileLoaded;
/// Emits when mpv starts loading a new file. This delimits load-scoped
/// readiness and failure signals for callers that arm before [Player.open].
final Stream<void> fileStarted;
/// Emits only when the active file ends because loading or playback failed.
/// Generic platform/property errors remain on [error] and must not be
/// mistaken for a media-open failure.
final Stream<void> fileLoadFailed;
/// Emits once mpv has discovered a non-external audio or video track for
/// the current file. Unlike [fileLoaded], this can fire before remote
/// subtitle sidecars finish opening.
final Stream<void> primaryMediaReady;
/// Emits when the compositor's preferred colour description for the video
/// plane changes: the window moved to another output, or an output's HDR
/// state was toggled under it. Linux only, where it is the only notice that
/// [Player.isHdrOutputSupported] may now answer differently - dragging a
/// window between monitors raises no app lifecycle event on Wayland.
final Stream<void> hdrOutputChanged;
/// Stream of seekable buffer ranges from the demuxer cache.
final Stream<List<BufferRange>> bufferRanges;
/// Stream that emits when the native player backend switches (e.g., ExoPlayer to MPV).
/// Only emitted on Android when ExoPlayer encounters an unsupported format.
final Stream<void> backendSwitched;
/// Emits the URI the backend auto-advanced into after playing out the
/// current item, when a next item was pre-armed via [Player.setNext]
/// (gapless music). Only audio players emit this; the value is the armed
/// [Media.uri].
final Stream<String> trackTransition;
const PlayerStreams({
required this.playing,
required this.completed,
required this.buffering,
required this.position,
required this.duration,
required this.seekable,
required this.buffer,
required this.volume,
required this.rate,
required this.tracks,
required this.track,
required this.log,
required this.error,
required this.audioDevice,
required this.audioDevices,
required this.bufferRanges,
required this.playbackRestart,
this.playheadJump = const Stream<Duration?>.empty(),
this.fileLoaded = const Stream<void>.empty(),
this.fileStarted = const Stream<void>.empty(),
this.fileLoadFailed = const Stream<void>.empty(),
this.primaryMediaReady = const Stream<void>.empty(),
this.hdrOutputChanged = const Stream<void>.empty(),
required this.backendSwitched,
this.trackTransition = const Stream<String>.empty(),
});
}