Files
plezy/linux/runner/mpv/mpv_plugin.cc
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

1182 lines
62 KiB
C++

#include "mpv_plugin.h"
#include <cstring>
#include <deque>
#include <functional>
#include <limits>
#include <new>
#include <optional>
#include "wayland_video_surface.h"
using PlayerPtr = std::unique_ptr<mpv::MpvPlayer>;
using VideoSurfacePtr = std::unique_ptr<mpv::WaylandVideoSurface>;
// One queued HDR transaction: what to apply, and who to tell when it settles.
//
// `mode` is engaged only when the request *is* the mode change. Everything else
// leaves it empty and inherits whatever is in force at the moment its turn
// comes: a mode captured at enqueue time could have been refused since, and
// applying it then would put a curve on screen that Dart has already been told
// did not take.
struct PendingHdrRequest {
bool allow = false;
std::optional<mpv::HdrToneMapping> mode;
std::function<void(int)> done;
};
using HdrQueue = std::deque<PendingHdrRequest>;
struct _MpvPlugin {
GObject parent_instance;
FlPluginRegistrar* registrar;
FlMethodChannel* method_channel;
FlEventChannel* event_channel;
PlayerPtr player;
// The native Wayland plane every video instance renders into. Non-null once
// initialize() has brought it up; there is no second render path, so a null
// here on a video instance means initialization refused.
VideoSurfacePtr video_surface;
// Set when the plane must be redrawn even though mpv has no new frame -
// after a resize or after becoming visible, where the buffer on screen is
// stale or absent. Sticky, because the render it asks for may first have to
// wait out an unacknowledged frame.
gboolean plane_needs_render;
gboolean visible;
gboolean initialized;
gboolean audio_only;
// What Dart last asked for via hdr-enabled. Remembered because the answer can
// change without Dart saying anything: moving the window to another monitor
// changes whether the output is in HDR at all, and the plane has to be
// re-described when it does.
gboolean hdr_wanted;
// The last geometry Dart sent, kept whether or not a plane existed to take
// it. Dart sends a rect only when its numbers change, so one arriving before
// start_video_plane is the only one the plane may ever be offered.
struct PendingRect {
int32_t x = 0;
int32_t y = 0;
int32_t width = 0;
int32_t height = 0;
int32_t scale = 1;
};
PendingRect pending_rect;
gboolean has_pending_rect;
// Bumped on every teardown so an async HDR callback that outlived its plane
// can tell it is answering for a generation that no longer exists.
guint64 generation;
// Who tone-maps when HDR is on. Defaults to the compositor, which is the
// behaviour that shipped first and needs no knowledge of the display; the
// player-side path is opted into.
//
// Two fields, deliberately. `hdr_tone_mapping` is the mode mpv last *accepted*;
// `hdr_tone_mapping_desired` is what the app last asked for. Requests are
// applied strictly in order, so an internal re-apply (playback restart,
// preferred-description change) queued behind a user's mode change must carry
// the desired mode - reading the committed one would send the stale mode last
// and make it final.
mpv::HdrToneMapping hdr_tone_mapping = mpv::HdrToneMapping::kCompositor;
mpv::HdrToneMapping hdr_tone_mapping_desired = mpv::HdrToneMapping::kCompositor;
// Bumped per user mode request, so a failure only reverts `desired` when no
// newer request has already replaced it.
uint64_t hdr_mode_request_serial = 0;
// Same purpose for hdr-enabled: a refused request must only hand hdr_wanted
// back if no newer one has claimed it since.
uint64_t hdr_enable_request_serial = 0;
// Exactly one HDR transaction runs at a time, end to end.
//
// A transaction spans staging and validating the image description, switching
// mpv's output colour space, and committing both. Interleaving two cannot be
// made consistent after the fact: a superseded transaction may already have
// moved mpv, so refusing its commit leaves mpv on one curve and the surface
// describing another. Serializing the whole thing is what makes that
// unreachable, rather than something to detect and unwind.
bool hdr_transaction_in_flight = false;
// Waiting user requests, in order. Each keeps its own callback because each has
// a Dart method call to answer, and answering one with another's outcome is the
// same divergence one level up.
HdrQueue hdr_queue;
// Internal re-applies coalesce into a flag instead of queueing: they have no
// caller to answer, playback-restart fires on every seek, and each transaction
// re-reads the source when its turn comes, so collapsing several loses nothing.
bool hdr_reapply_pending = false;
// The peak mpv was last told to tone-map to, 0 meaning "do not". Compared
// against the display's current peak so a move between two HDR outputs is not
// mistaken for no change at all.
uint32_t applied_target_peak = 0;
// Set when a transaction ended in kUnknown: mpv stopped answering partway
// through being put back, so what the plane emits cannot be named and the
// surface carries no description. Recorded rather than inferred from the
// surface being hidden, because Dart's own visibility changes move that bit
// for entirely unrelated reasons and would otherwise lift the quarantine by
// accident. Cleared by the next transaction that ends in a nameable state.
bool hdr_output_unnameable = false;
// What the source snapshot last logged as, so a seek does not repeat it. Its
// default state means "no stream", which no real source matches, so the first
// one always logs. Placement-constructed in init - see the note by finalize.
mpv::HdrMetadata last_logged_source;
};
// g_type_create_instance zeroes the instance and runs no constructor, so the
// member initialisers above never execute: every scalar starts at zero and
// nothing else assigns these. kCompositor has to *be* zero for that to land on
// the intended default.
static_assert(
static_cast<int>(mpv::HdrToneMapping::kCompositor) == 0,
"MpvPlugin's zeroed instance memory must decode as kCompositor");
G_DEFINE_TYPE(MpvPlugin, mpv_plugin, G_TYPE_OBJECT)
// Forward declarations
static void mpv_plugin_handle_method_call(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data);
static mpv::HdrMetadata read_source_hdr_metadata(MpvPlugin* self);
static void apply_hdr_state(MpvPlugin* self, bool allow, mpv::HdrToneMapping mode, std::function<void(int)> done);
static void request_hdr_reapply(MpvPlugin* self);
static void run_next_hdr_transaction(MpvPlugin* self);
static void submit_hdr_transaction(
MpvPlugin* self, bool allow, std::optional<mpv::HdrToneMapping> mode, std::function<void(int)> done);
// Events reach Dart unchanged; this only watches them go past. A playback
// restart is the first moment the source's colour space and HDR metadata are
// knowable, and HDR is normally permitted well before that - typically before
// any file is open - so the decision made back then has to be revisited.
//
// It goes through the full apply path rather than only refreshing the
// description, because a new file can change the *curve*: PQ to HLG, or HDR to
// SDR entirely, each of which mpv has to be reconfigured for and not just
// re-described. Self-cancelling when nothing changed, which matters because this
// also fires on every seek.
static void observe_event_for_hdr(MpvPlugin* self, FlValue* event) {
if (!self->video_surface) return;
if (event == nullptr || fl_value_get_type(event) != FL_VALUE_TYPE_MAP) return;
FlValue* name = fl_value_lookup_string(event, "name");
if (name == nullptr || fl_value_get_type(name) != FL_VALUE_TYPE_STRING) return;
if (g_strcmp0(fl_value_get_string(name), "playback-restart") != 0) return;
request_hdr_reapply(self);
}
static void send_event(MpvPlugin* self, FlValue* event) {
observe_event_for_hdr(self, event);
if (self->event_channel) {
g_autoptr(GError) error = nullptr;
if (!fl_event_channel_send(self->event_channel, event, nullptr, &error) && error != nullptr) {
g_warning("Failed to send event: %s", error->message);
}
}
}
// Every event Dart accepts carries type:"event" - its decoder silently drops a
// map without it. Building the envelope in one place means the next caller
// cannot omit it and quietly get nothing.
static void send_named_event(MpvPlugin* self, const char* name) {
g_autoptr(FlValue) event = fl_value_new_map();
fl_value_set_string_take(event, "type", fl_value_new_string("event"));
fl_value_set_string_take(event, "name", fl_value_new_string(name));
send_event(self, event);
}
static void release_video_resources(MpvPlugin* self) {
// Anything still in flight is now answering for a plane that is going away.
++self->generation;
// Queued transactions will never run, and each may be holding a reference to a
// Dart method call that has to be answered or it is leaked along with its
// response.
auto queued = std::move(self->hdr_queue);
self->hdr_queue.clear();
self->hdr_transaction_in_flight = false;
self->hdr_reapply_pending = false;
for (auto& request : queued) {
if (request.done) request.done(MPV_ERROR_UNINITIALIZED);
}
if (self->player) {
// The plane is a raw callback target. Revoke every callback path before
// tearing it down; Dispose then drains any callback already holding a
// native lease.
self->player->SetRedrawCallback(nullptr);
self->player->SetSourceMetadataCallback(nullptr);
self->player->SetEventCallback(nullptr);
self->player->Dispose();
self->player.reset();
}
// After the player is gone: disposal hands the render context to the
// process-lifetime teardown queue, which releases it surfacelessly, so the
// plane's EGL surface does not have to outlive it.
if (self->video_surface) {
self->video_surface->Destroy();
self->video_surface.reset();
}
self->initialized = FALSE;
self->visible = FALSE;
self->plane_needs_render = FALSE;
// All of these describe a plane and an mpv instance that no longer exist, and
// initialize() builds both fresh: a new MpvPlayer whose applied target
// properties are back to "auto", and a new surface with no description
// attached. Left standing, the stale record makes a repeated hdr-tone-mapping
// write match the old accepted mode and answer success without applying
// anything, and gives handle_preferred_changed a peak to compare against that
// nothing is actually using.
self->hdr_tone_mapping = mpv::HdrToneMapping::kCompositor;
self->hdr_tone_mapping_desired = mpv::HdrToneMapping::kCompositor;
self->applied_target_peak = 0;
// The quarantine belongs to the mpv instance that stopped answering, not to
// the app. A new plane and a new player have said nothing yet, so nothing
// about them is unnameable, and leaving this set would hide the next session
// outright: the flag suppresses exactly the setVisible that would show it.
self->hdr_output_unnameable = false;
self->last_logged_source = mpv::HdrMetadata();
// hdr_wanted included, and this is not obvious. It reads like the user's
// permission, which outlives any one plane - but this plugin is a
// process-lifetime singleton, so "outlives the plane" means "outlives every
// later session too". Dart re-pushes the preference on each initialize and
// reconciles a refusal by persisting what it believes native holds, and that
// belief is "off, because the plane is new". Carrying a TRUE over from an
// earlier session makes that belief false exactly when a refusal happens, and
// the two then disagree with no way back. A fresh plane starts from no
// permission and waits to be told.
self->hdr_wanted = FALSE;
}
// Hands the plane the last geometry Dart sent. Called both when a rect arrives
// and when a plane is created after one already had, which is the case that
// would otherwise leave the plane sizeless: render_video_plane bails while
// has_size() is false, and Dart re-sends only when its numbers change.
static void apply_pending_rect(MpvPlugin* self) {
if (!self->has_pending_rect || self->video_surface == nullptr) return;
const auto& rect = self->pending_rect;
self->video_surface->SetRect(rect.x, rect.y, rect.width, rect.height, rect.scale);
}
// Renders and presents one frame on the native video plane. Skipped while the
// plane is hidden or has not been given a rect yet; both of those paths render
// explicitly once the condition clears, because mpv's redraw latch stays set
// until a render consumes it and would otherwise suppress every later frame.
//
// |force| is for the callers who need pixels regardless of whether mpv has
// produced a new frame: a resize, or the plane becoming visible again.
static void render_video_plane(MpvPlugin* self, gboolean force) {
if (force) self->plane_needs_render = TRUE;
if (!self->player || !self->video_surface || !self->video_surface->valid()) return;
if (!self->video_surface->visible() || !self->video_surface->has_size()) return;
// Present() is held while a colour transition is staged, so a render now would
// be discarded. The forced render after CommitHdrTransition is what resumes;
// plane_needs_render stays set meanwhile, so nothing is lost.
if (self->video_surface->hdr_transition_staged()) return;
// Skip entirely while the compositor has not acknowledged the last frame:
// an occluded plane is never acknowledged, and rendering into it anyway
// would burn GPU work on frames that can never be shown.
if (self->video_surface->frame_pending()) return;
// The frame callback fires once per *display* refresh, so rendering from it
// unconditionally pins the plane to the monitor's rate - 120 swaps/s for
// 60fps content on a 120Hz output, half of them redrawing the same picture.
// mpv's redraw latch is what says a new frame actually exists.
if (!self->plane_needs_render && !self->player->NeedsRedraw()) return;
if (self->player->RenderToSurface(
self->video_surface->egl_surface(), self->video_surface->width(), self->video_surface->height())) {
// Only once a frame has actually been published. Present() returns false on
// an eglSwapBuffers failure having already destroyed its frame callback, so
// clearing the flag first would drop both the retry and the thing that would
// have rescheduled it, and the plane would sit on a stale buffer until an
// unrelated event arrived. Its other false returns are all re-tested above
// on this same thread, so a swap failure is the only way to get here.
if (self->video_surface->Present()) self->plane_needs_render = FALSE;
}
}
// Collects what the source actually is, plus its HDR10 static metadata.
//
// Both halves matter. The colour space decides whether the plane may be
// described as HDR at all — describing it because a setting is on, rather than
// because the stream carries an HDR curve, asks the compositor to undo a
// transform nobody applied. The luminances then decide how hard the compositor
// tone-maps: without them it must assume the worst case PQ permits, 10000 nits,
// and rolls highlights off far harder than the content needs.
//
// The protocol carries luminances as whole nits, and a value that rounds to zero
// would read as "not stated", so anything positive is kept at a minimum of 1.
static mpv::HdrMetadata read_source_hdr_metadata(MpvPlugin* self) {
mpv::HdrMetadata metadata;
if (!self->player) return metadata;
mpv::SourceHdrMetadata source;
if (!self->player->ReadSourceHdrMetadata(&source)) return metadata;
// mpv's own trc / primaries names, from video/csputils.c's tables.
if (source.transfer == "pq") {
metadata.transfer = mpv::SourceTransfer::kPq;
} else if (source.transfer == "hlg") {
metadata.transfer = mpv::SourceTransfer::kHlg;
}
if (source.primaries == "bt.2020") {
metadata.primaries = mpv::SourcePrimaries::kBt2020;
}
auto nits = [](double value) -> uint32_t {
if (!(value > 0.0)) return 0;
const double rounded = value + 0.5;
if (rounded >= 4294967295.0) return 4294967295u;
const uint32_t whole = static_cast<uint32_t>(rounded);
return whole > 0 ? whole : 1;
};
metadata.max_cll = nits(source.max_cll);
metadata.max_fall = nits(source.max_fall);
metadata.max_luminance = nits(source.max_luminance);
metadata.min_luminance = source.min_luminance;
// Only when it moves. This is the input to the whole HDR decision, so it
// belongs in an ordinary log - but playback-restart fires on every seek, and
// an unconditional line would bury the output state it should sit next to.
if (metadata != self->last_logged_source) {
self->last_logged_source = metadata;
g_message(
"MPV video plane: source is %s / %s, MaxCLL=%u MaxFALL=%u mastering=%.4f-%u nits",
source.transfer.empty() ? "(no stream)" : source.transfer.c_str(),
source.primaries.empty() ? "(no stream)" : source.primaries.c_str(), metadata.max_cll, metadata.max_fall,
metadata.min_luminance, metadata.max_luminance);
}
return metadata;
}
// Whether the client and the display could carry HDR at all, before the source
// is considered. Both halves must agree: this client has to be able to describe
// an HDR plane, and the output the surface sits on has to actually be in HDR.
// The second can change under us when the window moves between monitors, which
// is why nothing caches it.
static bool hdr_available(MpvPlugin* self) {
return self->video_surface != nullptr && self->video_surface->supports_hdr() && self->video_surface->output_is_hdr();
}
// One gate, in hdr_metadata.h, so the four conditions and the peak clamp are
// testable without a compositor. Both the apply path and the preferred-changed
// check go through here, so an eighth HdrInputs field cannot be filled in one
// and forgotten in the other - they would then disagree about what is on screen.
//
// The caller must have established that the surface exists.
static mpv::HdrDecision decide_hdr(
MpvPlugin* self, bool allow, mpv::HdrToneMapping mode, const mpv::HdrMetadata& source) {
mpv::HdrInputs inputs;
inputs.allowed = allow;
inputs.client_can_describe = self->video_surface->supports_hdr();
inputs.output_is_hdr = self->video_surface->output_is_hdr();
inputs.source_describable = self->video_surface->CanDescribeSource(source);
inputs.requested = mode;
inputs.display_peak_nits = self->video_surface->preferred().max_luminance;
inputs.sdr_reference_nits = self->video_surface->preferred().reference_luminance;
return mpv::DecideHdr(inputs, source);
}
// Applies an HDR state to both halves of the plane, atomically on screen.
//
// The surface's colour state and the buffer it describes land on the *same*
// child-surface commit, and that commit is performed by eglSwapBuffers inside
// Present(). So neither "pixels first" nor "description first" is atomic on its
// own: whichever goes second leaves a window in which presented frames carry one
// colour space while labelled with the other. On enable that window is the
// compositor's validation round-trip, and PQ frames read as sRGB are a visible
// flash.
//
// The three-step transition closes it:
//
// 1. BeginHdrTransition stages and validates the description, holding Present()
// so nothing can commit mid-change.
// 2. Once it settles, mpv's output colour space is switched.
// 3. CommitHdrTransition attaches the state and releases the hold, and the
// plane is rendered and presented immediately - so the first buffer in the
// new colour space is the one that carries it.
//
// Any failure aborts, leaving both halves exactly as they were.
//
// `mode` is the *desired* tone-map owner, passed in rather than read from the
// plugin, so the committed field is updated only when the request carrying that
// mode is the one mpv accepted. Requests are applied in order, so the last
// success is what mpv holds and what gets committed last.
//
// The source is read once and the same snapshot drives every step.
static void apply_hdr_state(MpvPlugin* self, bool allow, mpv::HdrToneMapping mode, std::function<void(int)> done) {
if (self->video_surface == nullptr || self->player == nullptr) {
if (done) done(MPV_ERROR_UNINITIALIZED);
return;
}
const mpv::HdrMetadata source = read_source_hdr_metadata(self);
const mpv::HdrDecision decision = decide_hdr(self, allow, mode, source);
// What the buffer will actually contain: the source untouched, or the same
// curve and gamut reduced to the peak we are about to declare. DecideHdr
// already clamped that peak to the curve's primary colour volume, so mpv aims
// at exactly what the compositor is told.
const mpv::HdrMetadata described =
decision.tone_map_in_player ? mpv::DescribeTonemappedTo(source, decision.target_peak_nits) : source;
const mpv::SourceTransfer transfer = decision.describe ? described.transfer : mpv::SourceTransfer::kSdr;
const guint64 generation = self->generation;
self->video_surface->BeginHdrTransition(
decision.describe, described, [self, decision, transfer, mode, generation, done](uint64_t token, bool staged) {
if (self->generation != generation || self->video_surface == nullptr || self->player == nullptr) {
if (done) done(MPV_ERROR_UNINITIALIZED);
return;
}
if (!staged) {
self->video_surface->AbortHdrTransition(token);
// Releasing the hold is not enough to restart the plane. mpv's redraw
// latch saturated while Present() was held - OnMpvRenderUpdate only
// schedules on the false->true edge - and no frame callback is
// outstanding to poke it either, so without a forced render here the
// plane sits on its last buffer until something unrelated moves.
render_video_plane(self, TRUE);
g_warning("MPV video plane: colour transition abandoned; leaving HDR as it was");
if (done) done(MPV_ERROR_UNSUPPORTED);
return;
}
self->player->SetHdrOutput(
transfer, decision.target_peak_nits,
[self, decision, mode, generation, token, done](mpv::MpvPlayer::HdrOutputResult result, int error) {
using Result = mpv::MpvPlayer::HdrOutputResult;
// The plane may have been torn down while the property was in flight.
if (self->generation != generation || self->video_surface == nullptr || self->player == nullptr) {
if (done) done(error);
return;
}
// An earlier kUnknown hid the plane rather than show pixels it could
// not label. Every outcome below except another kUnknown leaves mpv
// in a state that *can* be named, so the visibility Dart actually
// asked for is restored for all of them - restoring it only on
// kApplied left a clean unwind showing black until some unrelated
// visibility change arrived. Done before the switch so each arm's
// own render publishes it.
//
// Driven off the recorded quarantine rather than off the surface
// being hidden: those are different facts. Dart hides the plane
// whenever the player is off screen, and reading that as "quarantined"
// would restore visibility the user did not ask for.
//
// The commit is resolved first, because "mpv applied it" and "the
// surface is describing it" are two facts and the quarantine cares
// about the second. A kApplied whose non-zero token was refused is
// the case they part company: the watchdog withdrew the description
// while mpv was still answering, so mpv has moved to PQ and the
// surface has nothing attached. Lifting on that would publish the
// mislabelled frame the kUnknown arm below refuses to publish - and
// the same slow mpv causes both halves, so they arrive together.
// Short-circuit: only kApplied may commit; the other arms abort or
// withdraw instead.
const bool committed = result == Result::kApplied && self->video_surface->CommitHdrTransition(token);
const bool nameable =
result != Result::kUnknown && (result != Result::kApplied || committed || token == 0);
const bool unquarantined = nameable && self->hdr_output_unnameable;
if (unquarantined) {
self->hdr_output_unnameable = false;
if (self->visible != FALSE) self->video_surface->SetVisible(true);
}
switch (result) {
case Result::kApplied: {
// Pixels and state now agree; publish them together.
if (committed || unquarantined) render_video_plane(self, TRUE);
if (committed && decision.describe) {
g_message(
"MPV video plane: HDR on, tone mapping by %s",
decision.tone_map_in_player ? "the player" : "the compositor");
}
// A refusal has two very different meanings. Token zero is the
// benign one: nothing needed staging because nothing changed, so
// the record below is already true. A non-zero token that was
// refused means the transition was torn down while this mpv leg
// was in flight - the watchdog withdrew the description - and
// mpv has now moved to a colour space the surface no longer
// claims. Recording that as applied would make it the truth as
// far as every later no-change test is concerned, including the
// one in handle_preferred_changed that would otherwise repair
// it. Leave the record alone and ask for a fresh apply instead.
if (!committed && token != 0) {
g_warning("MPV video plane: the colour transition was withdrawn mid-flight; re-applying");
request_hdr_reapply(self);
break;
}
self->hdr_tone_mapping = mode;
self->applied_target_peak = decision.target_peak_nits;
break;
}
case Result::kRestored:
// mpv is back where it was, so the description already committed
// is still true of the pixels and must be left exactly alone.
// The plane still has to be restarted - see the !staged arm.
self->video_surface->AbortHdrTransition(token);
render_video_plane(self, TRUE);
g_warning(
"MPV video plane: mpv refused the %s output colour space and was put back, "
"so the surface description is unchanged: %s",
decision.describe ? "HDR" : "SDR", mpv_error_string(error));
break;
case Result::kForcedSdr:
// mpv could not be put back and is now SDR. Any committed HDR
// description describes pixels that no longer exist, so it goes
// too - and immediately, paired with a fresh frame. The render is
// unconditional because the hold is released either way.
self->video_surface->ForceUndescribed();
render_video_plane(self, TRUE);
self->applied_target_peak = 0;
g_warning(
"MPV video plane: mpv's colour space could not be restored and was forced to "
"SDR; HDR withdrawn: %s",
mpv_error_string(error));
break;
case Result::kUnknown:
// Nothing can be said truthfully about these pixels, so nothing is
// said and nothing is shown. A later transaction can recover.
// Recorded, so that a setVisible arriving in between - the app
// going off screen and back, which has nothing to do with colour -
// cannot quietly put the mislabelled plane back on screen.
self->hdr_output_unnameable = true;
self->video_surface->ForceUndescribed();
self->video_surface->SetVisible(false);
self->applied_target_peak = 0;
g_warning(
"MPV video plane: mpv's output colour space is no longer commandable; the "
"plane is hidden rather than shown mislabelled: %s",
mpv_error_string(error));
break;
}
if (done) done(error);
});
});
}
// Runs the next queued HDR transaction, or drains the coalesced internal
// re-apply once the queue empties.
static void run_next_hdr_transaction(MpvPlugin* self) {
if (self->hdr_queue.empty()) {
self->hdr_transaction_in_flight = false;
if (self->hdr_reapply_pending) {
self->hdr_reapply_pending = false;
request_hdr_reapply(self);
}
return;
}
PendingHdrRequest request = std::move(self->hdr_queue.front());
self->hdr_queue.pop_front();
self->hdr_transaction_in_flight = true;
// Resolved here rather than at enqueue, so a mode that has since been refused
// is not applied on this request's back.
const mpv::HdrToneMapping mode = request.mode.value_or(self->hdr_tone_mapping_desired);
apply_hdr_state(self, request.allow, mode, [self, done = std::move(request.done)](int error) {
if (done) done(error);
// Only now, with the surface unstaged and mpv settled, may the next one start.
run_next_hdr_transaction(self);
});
}
// Queues an HDR transaction. `mode` is engaged only for the mode change itself;
// everything else passes nullopt and carries whatever mode is in force when its
// turn comes.
static void submit_hdr_transaction(
MpvPlugin* self, bool allow, std::optional<mpv::HdrToneMapping> mode, std::function<void(int)> done) {
if (self->video_surface == nullptr || self->player == nullptr) {
if (done) done(MPV_ERROR_UNINITIALIZED);
return;
}
PendingHdrRequest request;
request.allow = allow;
request.mode = mode;
request.done = std::move(done);
self->hdr_queue.push_back(std::move(request));
if (!self->hdr_transaction_in_flight) run_next_hdr_transaction(self);
}
// Whether an HDR transaction is under way. A merely queued request counts as
// under way and needs no separate test: submit_hdr_transaction starts one the
// moment nothing is running, and run_next_hdr_transaction clears the flag only
// on the branch where the queue is already empty - so a non-empty queue always
// implies the flag is set.
static bool hdr_busy(MpvPlugin* self) { return self->hdr_transaction_in_flight; }
// The only entry point for internal re-applies: playback restarts and
// preferred-description changes. Nobody is waiting on these, so instead of
// queueing they collapse into a single pending flag and re-read the mode and the
// source when their turn comes.
static void request_hdr_reapply(MpvPlugin* self) {
if (self->video_surface == nullptr) return;
// Folded into the flag rather than queued behind what is already running:
// starting a re-apply beside waiting user requests would put it ahead of them
// in effect, and each transaction re-reads the source and the preferred
// description when its turn comes, so coalescing loses nothing.
if (hdr_busy(self)) {
self->hdr_reapply_pending = true;
return;
}
submit_hdr_transaction(self, self->hdr_wanted != FALSE, std::nullopt, nullptr);
}
// Re-applies the current request when the compositor's preferred description
// moves - a monitor change, HDR switched on or off under the app, or the output
// going away entirely.
static void handle_preferred_changed(MpvPlugin* self) {
if (self->video_surface == nullptr) return;
// Dart mirrors the HDR controls off isHDRSupported, and that answer depends on
// which output the surface sits on. This is the only place that learns it
// moved: dragging a window between monitors raises no Flutter lifecycle event
// on Wayland, so without this the settings sheet goes on offering a toggle the
// output can no longer honour, or hiding one it now could. Sent before the
// busy check below, because a deferred re-apply is still a change Dart needs
// to hear about.
send_named_event(self, "hdr-output-changed");
// A transaction that has not committed yet has not moved hdr_active() or
// applied_target_peak, so comparing against them here would judge the new
// preference against the state the in-flight transaction is about to
// replace. When the two happen to match, the newest word from the compositor
// would be dropped rather than coalesced - which is reachable just by
// dragging the window across two monitors, where KWin emits several
// preferred_changed events and the intermediate one can carry no_output.
// Defer instead; request_hdr_reapply already coalesces, and apply_hdr_state
// re-reads the preferred description when its turn comes.
if (hdr_busy(self)) {
request_hdr_reapply(self);
return;
}
// Ask the same gate that would run anyway what the answer is now, and only
// re-apply when it differs from what is in force. The on/off state alone is not
// enough: in player mode the peak we tone-map to *is* the display's peak, so
// moving between two HDR outputs of different brightness changes what must be
// sent while the boolean stays put.
const mpv::HdrMetadata source = read_source_hdr_metadata(self);
const mpv::HdrDecision decision = decide_hdr(self, self->hdr_wanted != FALSE, self->hdr_tone_mapping_desired, source);
if (decision.describe == self->video_surface->hdr_active() &&
decision.target_peak_nits == self->applied_target_peak) {
return;
}
g_message("MPV video plane: preferred description changed; re-evaluating HDR");
request_hdr_reapply(self);
}
// Brings up the native Wayland video plane, the only way this runner renders
// video. Returns false with |error| set to the specific reason: there is no
// second path to fall through to, so the reason is what the user is told.
static gboolean start_video_plane(MpvPlugin* self, FlView* view, std::string* error) {
if (view == nullptr) {
*error = "The window has no Flutter view to place a video plane under";
return FALSE;
}
GtkWidget* widget = GTK_WIDGET(view);
if (!mpv::WaylandVideoSurface::IsSupported(gtk_widget_get_display(widget))) {
*error =
"Video needs a Wayland session. This looks like an X11 session; log in "
"under Wayland, or run X11 applications through XWayland instead.";
return FALSE;
}
// my_application.cc gives the toplevel an RGBA visual because the plane is
// stacked *below* it and only shows through an alpha channel. If that did
// not take, presenting here would put video behind an opaque surface: the
// video area would go blank while every other symptom looked healthy. Say so
// instead, because the symptom on its own points nowhere near here.
GtkWidget* toplevel = gtk_widget_get_toplevel(widget);
if (toplevel != nullptr && gtk_widget_is_toplevel(toplevel)) {
GdkScreen* screen = gtk_widget_get_screen(toplevel);
GdkVisual* rgba = screen != nullptr ? gdk_screen_get_rgba_visual(screen) : nullptr;
if (rgba == nullptr || gtk_widget_get_visual(toplevel) != rgba) {
*error = "The compositor gave the window no alpha channel, so a video plane below it could never be seen";
return FALSE;
}
}
auto surface = std::make_unique<mpv::WaylandVideoSurface>();
if (!surface->Create(widget, error)) return FALSE;
if (!self->player->InitRenderContextForSurface(
surface->egl_display(), surface->egl_config(), surface->egl_surface(), surface->depth_bits())) {
surface->Destroy();
*error = "The GPU driver would not create a render context for the video plane";
return FALSE;
}
self->video_surface = std::move(surface);
self->video_surface->SetFrameCallback([self]() { render_video_plane(self, FALSE); });
self->video_surface->SetForcedRenderCallback([self]() { render_video_plane(self, TRUE); });
self->video_surface->SetPreferredChangedCallback([self]() { handle_preferred_changed(self); });
self->player->SetRedrawCallback([self]() { render_video_plane(self, FALSE); });
// playback-restart is not ordered against the video reconfigure that gives the
// source its colour space, so the re-apply observe_event_for_hdr asks for can
// land on the previous file's metadata - or on none at all for the first file.
// The parse's own event therefore re-applies as well; request_hdr_reapply
// coalesces the two into one transaction when they arrive together, and a late
// parse converges rather than leaving a wrong description standing.
self->player->SetSourceMetadataCallback([self]() { request_hdr_reapply(self); });
// A rect that arrived before this plane existed is the only one Dart may ever
// offer, since it re-sends solely on change. Hand it over now, before the
// first frame, so the plane is never left sizeless and blank.
apply_pending_rect(self);
return TRUE;
}
static void mpv_plugin_dispose(GObject* object) {
MpvPlugin* self = MPV_PLUGIN(object);
release_video_resources(self);
g_clear_object(&self->method_channel);
g_clear_object(&self->event_channel);
g_clear_object(&self->registrar);
G_OBJECT_CLASS(mpv_plugin_parent_class)->dispose(object);
}
// GObject instances come from g_type_create_instance, which zeroes the memory and
// runs no C++ constructors, and are released without running destructors. Every
// non-trivial member therefore has to be placement-constructed here and destroyed
// in finalize, in reverse.
//
// A zeroed std::unique_ptr happens to behave like an empty one, which is why the
// two smart pointers survived without this; a zeroed std::deque does not - its
// internal map pointers must be initialised before the first push_back, or it
// dereferences null.
static void mpv_plugin_finalize(GObject* object) {
MpvPlugin* self = MPV_PLUGIN(object);
self->hdr_queue.~HdrQueue();
self->video_surface.~VideoSurfacePtr();
self->player.~PlayerPtr();
G_OBJECT_CLASS(mpv_plugin_parent_class)->finalize(object);
}
static void mpv_plugin_class_init(MpvPluginClass* klass) {
G_OBJECT_CLASS(klass)->dispose = mpv_plugin_dispose;
G_OBJECT_CLASS(klass)->finalize = mpv_plugin_finalize;
}
static void mpv_plugin_init(MpvPlugin* self) {
new (&self->player) PlayerPtr();
new (&self->video_surface) VideoSurfacePtr();
new (&self->hdr_queue) HdrQueue();
// Its default member initialisers make it non-trivially-default-constructible,
// so the zeroed storage is not yet an object even though every field is scalar.
// Trivially destructible, so finalize has nothing to undo.
new (&self->last_logged_source) mpv::HdrMetadata();
self->visible = FALSE;
self->initialized = FALSE;
self->audio_only = FALSE;
self->generation = 0;
}
MpvPlugin* mpv_plugin_new(FlPluginRegistrar* registrar, const gchar* channel_name, gboolean audio_only) {
MpvPlugin* self = MPV_PLUGIN(g_object_new(MPV_PLUGIN_TYPE, nullptr));
self->registrar = FL_PLUGIN_REGISTRAR(g_object_ref(registrar));
self->audio_only = audio_only;
self->player = std::make_unique<mpv::MpvPlayer>(audio_only);
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
self->method_channel =
fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar), channel_name, FL_METHOD_CODEC(codec));
fl_method_channel_set_method_call_handler(self->method_channel, mpv_plugin_handle_method_call, self, nullptr);
g_autofree gchar* event_channel_name = g_strconcat(channel_name, "/events", nullptr);
self->event_channel =
fl_event_channel_new(fl_plugin_registrar_get_messenger(registrar), event_channel_name, FL_METHOD_CODEC(codec));
return self;
}
// Static references to keep the plugin instances alive.
static MpvPlugin* g_mpv_plugin = nullptr;
static MpvPlugin* g_mpv_audio_plugin = nullptr;
void mpv_plugin_register_with_registrar(FlPluginRegistrar* registrar) {
g_mpv_plugin = mpv_plugin_new(registrar, "com.plezy/mpv_player", FALSE);
}
void mpv_audio_plugin_register_with_registrar(FlPluginRegistrar* registrar) {
g_mpv_audio_plugin = mpv_plugin_new(registrar, "com.plezy/mpv_audio_player", TRUE);
}
/// Method call handler.
static void mpv_plugin_handle_method_call(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) {
(void)channel;
MpvPlugin* self = MPV_PLUGIN(user_data);
const gchar* method = fl_method_call_get_name(method_call);
FlValue* args = fl_method_call_get_args(method_call);
g_autoptr(FlMethodResponse) response = nullptr;
if (strcmp(method, "initialize") == 0) {
if (self->audio_only) {
// Audio-only music core: no plane, no render context - mpv runs with
// video disabled entirely (see MpvPlayer).
if (!self->initialized) {
if (!self->player || self->player->IsDisposed()) {
self->player = std::make_unique<mpv::MpvPlayer>(/*audio_only=*/true);
}
if (self->player->Initialize()) {
self->player->SetEventCallback([self](FlValue* event) { send_event(self, event); });
self->initialized = TRUE;
}
}
if (self->initialized) {
response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_bool(TRUE)));
} else {
response =
FL_METHOD_RESPONSE(fl_method_error_response_new("INIT_FAILED", "Failed to initialize MPV player", nullptr));
}
} else if (self->video_surface && self->video_surface->valid()) {
response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_bool(TRUE)));
} else {
if (!self->player || self->player->IsDisposed()) {
self->player = std::make_unique<mpv::MpvPlayer>();
}
std::string error;
if (!self->player->Initialize()) {
release_video_resources(self);
response =
FL_METHOD_RESPONSE(fl_method_error_response_new("INIT_FAILED", "Failed to initialize MPV player", nullptr));
} else if (start_video_plane(self, fl_plugin_registrar_get_view(self->registrar), &error)) {
++self->generation;
self->player->SetEventCallback([self](FlValue* event) { send_event(self, event); });
self->initialized = TRUE;
response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_bool(TRUE)));
} else {
// There is no second render path. Refuse with the reason rather than
// presenting into something the user cannot see.
g_warning("MPV: no video plane: %s", error.c_str());
release_video_resources(self);
response = FL_METHOD_RESPONSE(fl_method_error_response_new("VIDEO_PLANE_UNSUPPORTED", error.c_str(), nullptr));
}
}
} else if (strcmp(method, "dispose") == 0) {
release_video_resources(self);
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
} else if (strcmp(method, "command") == 0) {
if (!self->player || !self->initialized) {
response = FL_METHOD_RESPONSE(fl_method_error_response_new("NOT_INITIALIZED", "Player not initialized", nullptr));
} else {
FlValue* args_value = fl_value_lookup_string(args, "args");
if (args_value == nullptr || fl_value_get_type(args_value) != FL_VALUE_TYPE_LIST) {
response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'args' list", nullptr));
} else {
std::vector<std::string> command_args;
size_t len = fl_value_get_length(args_value);
for (size_t i = 0; i < len; i++) {
FlValue* item = fl_value_get_list_value(args_value, i);
if (fl_value_get_type(item) == FL_VALUE_TYPE_STRING) {
command_args.push_back(fl_value_get_string(item));
}
}
g_object_ref(method_call);
self->player->CommandAsync(command_args, [method_call](int error) {
g_autoptr(FlMethodResponse) async_response = nullptr;
if (error < 0) {
async_response =
FL_METHOD_RESPONSE(fl_method_error_response_new("COMMAND_FAILED", "MPV command failed", nullptr));
} else {
async_response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
}
fl_method_call_respond(method_call, async_response, nullptr);
g_object_unref(method_call);
});
return; // Response sent asynchronously
}
}
} else if (strcmp(method, "setProperty") == 0) {
if (!self->player || !self->initialized) {
response = FL_METHOD_RESPONSE(fl_method_error_response_new(
plezy::mpv_common::kSetPropertyNotInitializedCode, "Player not initialized", nullptr));
} else {
FlValue* name_value = fl_value_lookup_string(args, "name");
FlValue* value_value = fl_value_lookup_string(args, "value");
if (name_value == nullptr || fl_value_get_type(name_value) != FL_VALUE_TYPE_STRING) {
response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'name'", nullptr));
} else if (value_value == nullptr || fl_value_get_type(value_value) != FL_VALUE_TYPE_STRING) {
response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'value'", nullptr));
} else if (self->video_surface && g_strcmp0(fl_value_get_string(name_value), "hdr-tone-mapping") == 0) {
// Not an mpv property: it selects which side reduces the source's range,
// which changes both mpv's target-peak and the luminances the compositor
// is told. Re-applied immediately so the switch is visible without a
// seek.
//
// Unknown values are rejected rather than folded into the default. This
// knob's whole purpose is A/B comparison, and silently answering a typo
// with "compositor, success" would mislabel the very measurement it
// exists to produce.
const char* mode = fl_value_get_string(value_value);
const bool is_player = g_strcmp0(mode, "player") == 0;
const bool is_compositor = g_strcmp0(mode, "compositor") == 0;
if (!is_player && !is_compositor) {
response = FL_METHOD_RESPONSE(fl_method_error_response_new(
"INVALID_ARGS", "hdr-tone-mapping must be 'compositor' or 'player'", nullptr));
} else {
const mpv::HdrToneMapping requested =
is_player ? mpv::HdrToneMapping::kPlayer : mpv::HdrToneMapping::kCompositor;
// A no-op answer is only honest once the mode has actually settled. If a
// change is queued or running, `desired` holds a value mpv has not yet
// accepted, and answering a duplicate with immediate success would have
// Dart persist a mode the original request may still revert. Such a
// duplicate is queued instead and gets a real outcome; the extra
// property writes are idempotent.
if (requested != self->hdr_tone_mapping || hdr_busy(self)) {
// `desired` moves now, so an internal re-apply that runs later carries
// the new mode. `hdr_tone_mapping` itself is committed by the
// transaction only if mpv accepts the change, which is what keeps
// native and Dart - which does not persist on failure - in agreement.
self->hdr_tone_mapping_desired = requested;
const uint64_t serial = ++self->hdr_mode_request_serial;
g_object_ref(method_call);
submit_hdr_transaction(self, self->hdr_wanted != FALSE, requested, [self, method_call, serial](int error) {
g_autoptr(FlMethodResponse) async_response = nullptr;
if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) {
async_response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
} else {
// Hand the desire back to whatever is actually in force, read
// live rather than captured: an intervening request may have
// committed since. Skipped if a newer request already claimed
// the desire.
if (self->hdr_mode_request_serial == serial) {
self->hdr_tone_mapping_desired = self->hdr_tone_mapping;
}
async_response = FL_METHOD_RESPONSE(fl_method_error_response_new(
plezy::mpv_common::kSetPropertyFailedCode, mpv_error_string(error), nullptr));
}
fl_method_call_respond(method_call, async_response, nullptr);
g_object_unref(method_call);
});
return;
}
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
}
} else if (self->video_surface && g_strcmp0(fl_value_get_string(name_value), "hdr-enabled") == 0) {
// HDR spans both halves of the plane: mpv has to emit PQ / BT.2020, and
// the compositor has to be told that is what the buffer holds. Neither
// alone produces HDR, so this cannot go through the plain property path.
const bool enabled = plezy::mpv_common::ParseEnabledFlag(fl_value_get_string(value_value));
// What every internal re-apply reads. This records the user's
// permission, which is app policy and not a capability, so it is kept
// even when the output cannot show HDR right now: decide_hdr gates on
// output_is_hdr separately, and the whole point of hdr_wanted is that
// the plane can be re-described when the window reaches an HDR output.
// Rolling it back on a temporarily-SDR output would strand the session
// permanently SDR while Dart went on believing HDR was enabled - it
// swallows this error and keeps the setting persisted.
//
// A plane that can never describe HDR is a different matter. That is
// fixed for the session - an 8-bit config, or a compositor without the
// colour-management pieces - so refusing is honest and Dart can say so.
const gboolean previous_wanted = self->hdr_wanted;
self->hdr_wanted = enabled ? TRUE : FALSE;
if (enabled && !(self->video_surface->supports_hdr())) {
self->hdr_wanted = previous_wanted;
response = FL_METHOD_RESPONSE(fl_method_error_response_new(
"HDR_UNSUPPORTED", "This compositor or video plane cannot carry HDR", nullptr));
} else {
g_object_ref(method_call);
const uint64_t serial = ++self->hdr_enable_request_serial;
submit_hdr_transaction(self, enabled, std::nullopt, [self, method_call, serial, previous_wanted](int error) {
g_autoptr(FlMethodResponse) async_response = nullptr;
if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) {
async_response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
} else {
// Only if no newer request has claimed the field since, for the
// same reason the tone-mapping path checks its serial.
if (self->hdr_enable_request_serial == serial) self->hdr_wanted = previous_wanted;
async_response = FL_METHOD_RESPONSE(fl_method_error_response_new(
plezy::mpv_common::kSetPropertyFailedCode, mpv_error_string(error), nullptr));
}
fl_method_call_respond(method_call, async_response, nullptr);
g_object_unref(method_call);
});
return;
}
} else {
g_object_ref(method_call);
self->player->SetPropertyAsync(
fl_value_get_string(name_value), fl_value_get_string(value_value), [method_call](int error) {
g_autoptr(FlMethodResponse) async_response = nullptr;
if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) {
async_response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
} else {
const char* error_code = plezy::mpv_common::SetPropertyErrorCode(error);
const std::string description = error == MPV_ERROR_UNINITIALIZED
? std::string("Player not initialized")
: plezy::mpv_common::SetPropertyErrorDescription(error);
async_response =
FL_METHOD_RESPONSE(fl_method_error_response_new(error_code, description.c_str(), nullptr));
}
fl_method_call_respond(method_call, async_response, nullptr);
g_object_unref(method_call);
});
return; // Response sent asynchronously
}
}
} else if (strcmp(method, "setLogLevel") == 0) {
if (!self->player || !self->initialized) {
response = FL_METHOD_RESPONSE(fl_method_error_response_new("NOT_INITIALIZED", "Player not initialized", nullptr));
} else {
FlValue* level_value = fl_value_lookup_string(args, "level");
if (level_value == nullptr || fl_value_get_type(level_value) != FL_VALUE_TYPE_STRING) {
response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'level'", nullptr));
} else {
self->player->SetLogLevel(fl_value_get_string(level_value));
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
}
}
} else if (strcmp(method, "getProperty") == 0) {
if (!self->player || !self->initialized) {
response = FL_METHOD_RESPONSE(fl_method_error_response_new("NOT_INITIALIZED", "Player not initialized", nullptr));
} else {
FlValue* name_value = fl_value_lookup_string(args, "name");
if (name_value == nullptr || fl_value_get_type(name_value) != FL_VALUE_TYPE_STRING) {
response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'name'", nullptr));
} else {
g_object_ref(method_call);
self->player->GetPropertyAsync(
fl_value_get_string(name_value), [method_call](int error, const std::string& value) {
g_autoptr(FlMethodResponse) async_response = nullptr;
if (error < 0 || value.empty()) {
async_response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
} else {
async_response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_string(value.c_str())));
}
fl_method_call_respond(method_call, async_response, nullptr);
g_object_unref(method_call);
});
return; // Response sent asynchronously
}
}
} else if (strcmp(method, "observeProperty") == 0) {
if (!self->player || !self->initialized) {
response = FL_METHOD_RESPONSE(fl_method_error_response_new("NOT_INITIALIZED", "Player not initialized", nullptr));
} else {
FlValue* name_value = fl_value_lookup_string(args, "name");
FlValue* format_value = fl_value_lookup_string(args, "format");
FlValue* id_value = fl_value_lookup_string(args, "id");
if (name_value == nullptr || fl_value_get_type(name_value) != FL_VALUE_TYPE_STRING) {
response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'name'", nullptr));
} else if (format_value == nullptr || fl_value_get_type(format_value) != FL_VALUE_TYPE_STRING) {
response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'format'", nullptr));
} else if (id_value == nullptr || fl_value_get_type(id_value) != FL_VALUE_TYPE_INT) {
response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'id'", nullptr));
} else {
self->player->ObserveProperty(
fl_value_get_string(name_value), fl_value_get_string(format_value),
static_cast<int>(fl_value_get_int(id_value)));
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
}
}
} else if (strcmp(method, "setVisible") == 0) {
FlValue* visible_value = fl_value_lookup_string(args, "visible");
if (visible_value == nullptr || fl_value_get_type(visible_value) != FL_VALUE_TYPE_BOOL) {
response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing 'visible'", nullptr));
} else {
self->visible = fl_value_get_bool(visible_value);
if (self->video_surface) {
// Hiding is always Dart's to do. Showing is not, while the plane is
// quarantined: the description was withdrawn because what mpv emits
// could not be named, and showing it now would be the mislabelled
// picture the kUnknown arm just refused. Dart's wish is still recorded
// above, so the transaction that lifts the quarantine honours it.
if (self->visible && self->hdr_output_unnameable) {
// Actively, rather than waiting for an unrelated event: coming back on
// screen is exactly when it is worth asking mpv again, and the
// transaction either names the output and unhides, or lands on
// kUnknown again and leaves things as they are.
request_hdr_reapply(self);
} else {
self->video_surface->SetVisible(self->visible);
// Becoming visible has to render explicitly: the redraw latch was
// consumed (or suppressed) while hidden, so no callback is pending.
if (self->visible) render_video_plane(self, TRUE);
}
}
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
}
} else if (strcmp(method, "isHDRSupported") == 0) {
// The output half of the gate is what stops the app offering an HDR toggle
// on an SDR panel, where enabling it only invites the compositor to tone-map
// a plane that never needed to be PQ in the first place.
response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_bool(hdr_available(self))));
} else if (strcmp(method, "setVideoRect") == 0) {
{
auto read_int = [args](const char* key, int64_t* out) {
FlValue* value = fl_value_lookup_string(args, key);
if (value == nullptr || fl_value_get_type(value) != FL_VALUE_TYPE_INT) return false;
*out = fl_value_get_int(value);
return true;
};
int64_t left = 0, top = 0, right = 0, bottom = 0;
if (!read_int("left", &left) || !read_int("top", &top) || !read_int("right", &right) ||
!read_int("bottom", &bottom)) {
response =
FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGS", "Missing video rect bounds", nullptr));
} else {
FlValue* dpr_value = fl_value_lookup_string(args, "devicePixelRatio");
double dpr = 1.0;
if (dpr_value != nullptr && fl_value_get_type(dpr_value) == FL_VALUE_TYPE_FLOAT) {
dpr = fl_value_get_float(dpr_value);
}
// GTK3 only ever reports integer scale factors, and the rect already
// arrives in physical pixels, so the scale is purely how many buffer
// pixels make up one surface-local unit.
//
// Clamped before the cast, not after: a double->int32 conversion whose
// truncated value does not fit is undefined, and the two architectures
// disagree about what falls out - x86-64 gives INT32_MIN, which the
// lower bound below would catch, while AArch64 saturates to INT32_MAX,
// which it would not. That value then becomes SetRect's rounding block
// and would be sent as the buffer scale. NaN fails both comparisons and
// takes the default.
if (!(dpr >= 1.0)) dpr = 1.0;
if (dpr > 16.0) dpr = 16.0;
const int32_t scale = static_cast<int32_t>(dpr + 0.5);
// Same reasoning as the scale above, applied to the bounds: these are
// int64 channel arguments, so `right - left` can overflow before the
// narrowing, and the narrowing itself is implementation-defined before
// C++20. Clamp into int32 first and take the width in 64 bits, so a
// hostile rect becomes a large plane rather than undefined behaviour.
constexpr int64_t kMin = std::numeric_limits<int32_t>::min();
constexpr int64_t kMax = std::numeric_limits<int32_t>::max();
auto clamp32 = [](int64_t value) -> int64_t { return value < kMin ? kMin : (value > kMax ? kMax : value); };
left = clamp32(left);
top = clamp32(top);
const int64_t width = clamp32(clamp32(right) - left);
const int64_t height = clamp32(clamp32(bottom) - top);
// Remembered rather than dropped when there is no plane yet. A video
// session is legitimately asked for geometry between construction and
// start_video_plane, and Dart only re-sends a rect whose numbers
// changed - so a rect discarded here is one the plane may never hear
// again, leaving it sizeless and blank. The audio-only core keeps this
// too and simply never reads it.
self->pending_rect.x = static_cast<int32_t>(left);
self->pending_rect.y = static_cast<int32_t>(top);
self->pending_rect.width = static_cast<int32_t>(width);
self->pending_rect.height = static_cast<int32_t>(height);
self->pending_rect.scale = scale;
self->has_pending_rect = TRUE;
if (self->video_surface) {
apply_pending_rect(self);
// Re-render at the new size straight away; waiting for the next mpv
// frame would leave a stale buffer stretched across the new rect.
render_video_plane(self, TRUE);
}
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
}
}
} else if (strcmp(method, "updateFrame") == 0) {
// The cross-platform "kick the video output" call. On the plane that means
// forcing a render: mpv may have no new frame, but the caller is asking
// because what is on screen is stale.
if (self->visible) render_video_plane(self, TRUE);
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
} else if (strcmp(method, "isInitialized") == 0) {
gboolean initialized = self->player && self->initialized;
response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_bool(initialized)));
} else {
response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new());
}
fl_method_call_respond(method_call, response, nullptr);
}