Files
plezy/linux/runner/mpv/mpv_player.cc
T

1612 lines
66 KiB
C++

#include "mpv_player.h"
#include <epoxy/egl.h>
#include <epoxy/gl.h>
#include <flutter_linux/flutter_linux.h>
#include <gdk/gdk.h>
#ifdef GDK_WINDOWING_WAYLAND
#include <gdk/gdkwayland.h>
#endif
#include <locale.h>
// EGL 1.5 names; EGL_KHR_create_context introduced the same values earlier.
// Declared here so the build does not depend on which EGL headers the distro
// ships - the runtime check is eglCreateContext refusing the attribute, which
// the caller already falls back from.
#ifndef EGL_CONTEXT_MAJOR_VERSION
#define EGL_CONTEXT_MAJOR_VERSION EGL_CONTEXT_CLIENT_VERSION
#endif
#ifndef EGL_CONTEXT_MINOR_VERSION
#define EGL_CONTEXT_MINOR_VERSION 0x30FB
#endif
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstring>
#include "sanitize_utf8.h"
namespace {
bool EnsureProcessNumericLocale() {
// libmpv parses numeric options on worker threads, so a thread-local locale
// is insufficient. This process-wide setting intentionally remains in force
// for the rest of the process after the first player starts.
static const bool configured = setlocale(LC_NUMERIC, "C") != nullptr;
return configured;
}
// Reply userdata for the runner's own `video-params` observation.
//
// Every Dart-facing observation takes its userdata from
// PropertyObservationRegistry, which hands out 1, 2, 3, … one per distinct
// property name and never resets the counter; the two audio observations below
// pass 0, which the registry also never hands out. UINT64_MAX is the one value
// the counter cannot reach without first wrapping — and a wrap would collide
// with those two just as surely, so the scheme already depends on it not
// happening.
constexpr uint64_t kVideoParamsUserdata = UINT64_MAX;
// Runner-internal observation of the decode path mpv actually took. The
// "silent software fallback" is the one hwdec failure mode with no visible
// symptom, so every transition is logged with a timestamp from the native
// side rather than inferred from an overlay readout.
constexpr uint64_t kHwdecCurrentUserdata = UINT64_MAX - 1;
} // namespace
// Flutter on Linux uses EGL (OpenGL ES) for both X11 and Wayland.
static void* get_opengl_proc_address(void* ctx, const char* name) {
(void)ctx;
return reinterpret_cast<void*>(eglGetProcAddress(name));
}
namespace mpv {
namespace {
NativeRenderTeardownOperations ProductionTeardownOperations() {
return {
[](EGLDisplay display, EGLContext context) {
if (!eglBindAPI(EGL_OPENGL_ES_API) || !eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, context)) {
g_warning("MPV: Failed to activate EGL context for teardown: 0x%x", eglGetError());
return false;
}
return true;
},
[](EGLDisplay display) {
if (!eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)) {
g_warning("MPV: Failed to release EGL context during teardown: 0x%x", eglGetError());
return false;
}
return true;
},
[](EGLDisplay display, EGLContext context) {
if (!eglDestroyContext(display, context)) {
g_warning("MPV: Failed to destroy EGL context during teardown: 0x%x", eglGetError());
return false;
}
return true;
},
[](mpv_render_context* render) { mpv_render_context_free(render); },
[](mpv_handle* handle) { mpv_terminate_destroy(handle); },
};
}
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
NativeRenderTeardownOperations*& TestTeardownOperationsOverride() {
static NativeRenderTeardownOperations* operations = nullptr;
return operations;
}
#endif
class NativeRenderTeardownQueue {
public:
static NativeRenderTeardownQueue& Instance() {
// Native driver/libmpv teardown can block indefinitely. Keep both the
// queue and its worker state alive until the OS ends the process so static
// destruction never joins the worker or invalidates state it may access.
static NativeRenderTeardownQueue* const queue = new NativeRenderTeardownQueue();
return *queue;
}
void Enqueue(NativeRenderTeardownBatch batch) {
if (batch.resources.empty() && !batch.handle) return;
{
std::lock_guard<std::mutex> lock(mutex_);
batches_.push_back(std::move(batch));
++generation_;
}
condition_.notify_one();
}
void Retry() {
{
std::lock_guard<std::mutex> lock(mutex_);
++generation_;
}
condition_.notify_one();
}
private:
NativeRenderTeardownQueue() : worker_([this]() { Run(); }) {}
void Run() {
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
const NativeRenderTeardownOperations operations =
TestTeardownOperationsOverride() ? *TestTeardownOperationsOverride() : ProductionTeardownOperations();
#else
const NativeRenderTeardownOperations operations = ProductionTeardownOperations();
#endif
std::unique_lock<std::mutex> lock(mutex_);
for (;;) {
condition_.wait(lock, [this]() { return !batches_.empty(); });
const uint64_t observed_generation = generation_;
std::vector<NativeRenderTeardownBatch> work = std::move(batches_);
batches_.clear();
// EGL activation and mpv shutdown can block in a driver. Keep queue
// admission independent so replacement initialization and disposal only
// pay the short ownership-transfer critical section.
lock.unlock();
std::vector<NativeRenderTeardownBatch> retry;
for (auto& batch : work) {
if (!TryReleaseNativeRenderTeardown(batch, operations)) retry.push_back(std::move(batch));
}
lock.lock();
for (auto& batch : retry) batches_.push_back(std::move(batch));
if (batches_.empty()) continue;
condition_.wait_for(lock, std::chrono::milliseconds(100), [this, observed_generation]() {
return generation_ != observed_generation;
});
}
}
std::mutex mutex_;
std::condition_variable condition_;
std::vector<NativeRenderTeardownBatch> batches_;
uint64_t generation_ = 0;
std::thread worker_;
};
// Mesa's software rasterizers, as named in GL_RENDERER. The video plane lands
// on one when the compositor hands clients no GPU device (Muffin 6.6.3 does
// exactly that), and that session is the one where handing mpv the Wayland
// display is not merely futile but fatal — see the MPV_RENDER_PARAM_WL_DISPLAY
// comment in InitRenderContextForSurface.
bool IsSoftwareGlRenderer(const char* renderer) {
if (renderer == nullptr) return false;
return strstr(renderer, "llvmpipe") != nullptr || strstr(renderer, "softpipe") != nullptr ||
strstr(renderer, "swrast") != nullptr || strstr(renderer, "Software Rasterizer") != nullptr;
}
} // namespace
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
void ConfigureNativeRenderTeardownQueueForTesting(NativeRenderTeardownOperations operations) {
auto*& configured = TestTeardownOperationsOverride();
if (configured) {
*configured = std::move(operations);
} else {
configured = new NativeRenderTeardownOperations(std::move(operations));
}
}
void EnqueueNativeRenderTeardownForTesting(NativeRenderTeardownBatch batch) {
NativeRenderTeardownQueue::Instance().Enqueue(std::move(batch));
}
#endif
bool TryReleaseNativeRenderTeardown(
NativeRenderTeardownBatch& batch, const NativeRenderTeardownOperations& operations) {
for (auto it = batch.resources.begin(); it != batch.resources.end();) {
if (it->context == EGL_NO_CONTEXT || !operations.make_current(it->display, it->context)) {
++it;
continue;
}
if (it->render) {
operations.free_render(it->render);
it->render = nullptr;
}
if (!operations.release_current(it->display)) {
++it;
continue;
}
if (!operations.destroy_context(it->display, it->context)) {
++it;
continue;
}
it = batch.resources.erase(it);
}
if (!batch.resources.empty()) return false;
if (batch.handle) {
operations.terminate_handle(batch.handle);
batch.handle = nullptr;
}
batch.callback_keep_alive.reset();
return true;
}
MpvPlayer::CallbackContext::Lease::Lease(CallbackContext* context, MpvPlayer* player)
: context_(context), player_(player) {}
MpvPlayer::CallbackContext::Lease::Lease(Lease&& other) noexcept : context_(other.context_), player_(other.player_) {
other.context_ = nullptr;
other.player_ = nullptr;
}
MpvPlayer::CallbackContext::Lease& MpvPlayer::CallbackContext::Lease::operator=(Lease&& other) noexcept {
if (this != &other) {
Release();
context_ = other.context_;
player_ = other.player_;
other.context_ = nullptr;
other.player_ = nullptr;
}
return *this;
}
MpvPlayer::CallbackContext::Lease::~Lease() { Release(); }
void MpvPlayer::CallbackContext::Lease::Release() {
if (!context_) return;
context_->ReleaseLease();
context_ = nullptr;
player_ = nullptr;
}
MpvPlayer::CallbackContext::CallbackContext(MpvPlayer* player)
: player_(player), main_context_(g_main_context_ref_thread_default()) {}
MpvPlayer::CallbackContext::~CallbackContext() { g_main_context_unref(main_context_); }
MpvPlayer::CallbackContext::Lease MpvPlayer::CallbackContext::Acquire() {
std::lock_guard<std::mutex> lock(mutex_);
if (!player_) return Lease();
++in_flight_;
return Lease(this, player_);
}
void MpvPlayer::CallbackContext::WaitUntilDetached() {
std::unique_lock<std::mutex> lock(mutex_);
quiescent_.wait(lock, [this]() { return player_ == nullptr; });
}
void MpvPlayer::CallbackContext::DetachAndWait() {
std::unique_lock<std::mutex> lock(mutex_);
player_ = nullptr;
quiescent_.notify_all();
quiescent_.wait(lock, [this]() { return in_flight_ == 0; });
}
void MpvPlayer::CallbackContext::ReleaseLease() {
std::lock_guard<std::mutex> lock(mutex_);
--in_flight_;
if (in_flight_ == 0) quiescent_.notify_all();
}
struct MpvPlayer::SourceCallbackData {
explicit SourceCallbackData(std::shared_ptr<CallbackContext> callback_context)
: context(std::move(callback_context)) {}
std::shared_ptr<CallbackContext> context;
guint source_id = 0;
};
MpvPlayer::MpvPlayer(bool audio_only)
: audio_only_(audio_only), callback_context_(std::make_shared<CallbackContext>(this)) {}
MpvPlayer::~MpvPlayer() { Dispose(); }
bool MpvPlayer::IsInitialized() const {
std::lock_guard<std::mutex> lock(native_mutex_);
return mpv_ != nullptr && (audio_only_ || mpv_gl_ != nullptr);
}
bool MpvPlayer::HasMpvHandle() const {
std::lock_guard<std::mutex> lock(native_mutex_);
return mpv_ != nullptr;
}
bool MpvPlayer::Initialize() {
std::lock_guard<std::mutex> lock(native_mutex_);
if (disposed_) {
g_warning("MPV: initialization requested after disposal");
return false;
}
if (mpv_) {
return true; // Already initialized.
}
if (!EnsureProcessNumericLocale()) {
g_warning("MPV: Failed to establish the process-wide C numeric locale");
return false;
}
// Create mpv instance.
mpv_ = mpv_create();
if (!mpv_) {
g_warning("MPV: mpv_create() failed");
return false;
}
plezy::mpv_common::ApplyCommonStartupOptions(mpv_, audio_only_);
mpv_set_option_string(mpv_, "terminal", "no");
if (!audio_only_) {
// Configure mpv for embedded playback.
mpv_set_option_string(mpv_, "vo", "libmpv");
mpv_set_option_string(mpv_, "hwdec", "auto");
// hdr-compute-peak is nested under the same predicate as the tone-map pass -
// it runs exactly when the source's declared peak exceeds target-peak - so it
// costs nothing while the compositor owns tone mapping and gives
// content-adaptive peak detection once we own it.
//
// `tone-mapping` is deliberately *not* set here: it travels with the output
// description and is applied and withdrawn in RunPendingHdrOutput instead.
// See applied_tone_mapping_ in mpv_player.h for why it cannot be global.
mpv_set_option_string(mpv_, "hdr-compute-peak", "auto");
// Declared by vo_gpu_next only, so inert for the render API this player
// runs. Set anyway so the startup value agrees with what a later
// `hdr-enabled` write puts here through SetHDREnabled.
mpv_set_option_string(mpv_, "target-colorspace-hint", plezy::mpv_common::TargetColorspaceHint(hdr_enabled_));
}
// Default to info-level logging. The vaapi hwdec probe and the "Using
// software decoding" fallback are MSGL_INFO messages, and both are the only
// evidence a silently software-decoding session leaves behind; at "warn"
// neither ever reaches the app log (mpv_request_log_messages takes a single
// global level - there is no per-module syntax here), so a hwdec regression
// is indistinguishable from a working one. Debug logging raises this
// further via setLogLevel.
mpv_request_log_messages(mpv_, "info");
// Initialize mpv.
int err = mpv_initialize(mpv_);
if (err < 0) {
g_warning("MPV: mpv_initialize() failed: %s", mpv_error_string(err));
mpv_destroy(mpv_);
mpv_ = nullptr;
return false;
}
// Set up event wakeup callback.
mpv_set_wakeup_callback(mpv_, OnMpvWakeup, callback_context_.get());
mpv_observe_property(mpv_, 0, "current-ao", MPV_FORMAT_STRING);
mpv_observe_property(mpv_, 0, "audio-device-list", MPV_FORMAT_NONE);
if (!audio_only_) {
// One node observation stands in for six blocking sub-property reads. The
// HDR decision runs on the GTK main thread and one of its callers fires on
// every seek, while libmpv's synchronous read hands the request to the core
// and waits for the playloop; every other property access here is async for
// exactly that reason.
//
// An audio-only core has no video-params to report, so it is not asked.
source_hdr_metadata_ = SourceHdrMetadata();
mpv_observe_property(mpv_, kVideoParamsUserdata, "video-params", MPV_FORMAT_NODE);
// Which decode path is in use. mpv only emits on change, so each event is
// a real transition worth a log line.
mpv_observe_property(mpv_, kHwdecCurrentUserdata, "hwdec-current", MPV_FORMAT_STRING);
}
g_message("MPV: Initialization successful (%s)", audio_only_ ? "audio-only" : "render context deferred");
return true;
}
void MpvPlayer::RetryPendingNativeTeardown() { NativeRenderTeardownQueue::Instance().Retry(); }
bool MpvPlayer::InitRenderContextForSurface(EGLDisplay display, EGLConfig config, EGLSurface surface, int depth_bits) {
RetryPendingNativeTeardown();
std::lock_guard<std::mutex> lock(native_mutex_);
if (audio_only_ || disposed_) {
g_warning("MPV: Video-plane render context requested for an unavailable player");
return false;
}
if (mpv_gl_) return true;
if (!mpv_) {
g_warning("MPV: Cannot create render context - mpv not initialized");
return false;
}
if (display == EGL_NO_DISPLAY || surface == EGL_NO_SURFACE) {
g_warning("MPV: Video plane provided no usable EGL display or surface");
return false;
}
if (!eglBindAPI(EGL_OPENGL_ES_API)) {
g_warning("MPV: Failed to bind OpenGL ES API: 0x%x", eglGetError());
return false;
}
// Nothing is shared with Flutter here, so take the highest ES version the
// driver will give. EGL_CONTEXT_CLIENT_VERSION=3 asks for exactly 3.0, which
// reads as "ES 3" while being the oldest of them; 3.2 brings float render
// targets (mpv picks an rgba16f FBO on this path) and ES 3.1 semantics for
// the rest.
//
// It does **not** buy compute shaders. mpv refuses them on any GLES context at
// any version, by construction (`video/out/opengl/ra_gl.c:136-139` in v0.40.0):
//
// // While we can handle compute shaders on GLES the spec (intentionally)
// // does not support binding textures for writing, which all uses inside
// // mpv would require. So disable it unconditionally anyway.
// if (ra->glsl_es) ra->caps &= ~RA_CAP_COMPUTE;
//
// So `hdr-compute-peak` is unreachable through the render API here however
// new the context is - confirmed on hardware with an ES 3.2 context whose
// glDispatchCompute and glBindImageTexture both resolve, where mpv still
// logs "Disabling HDR peak computation (compute shaders=0)". The player-side
// tone map therefore aims at the peak the source declares rather than one
// measured from the frames. Reaching it needs a desktop-GL context on the
// plane, which is the same architectural door as libplacebo and belongs with
// it rather than in a version bump.
//
// EGL_CONTEXT_MINOR_VERSION needs EGL 1.5 or EGL_KHR_create_context. Where
// neither is present eglCreateContext rejects the attribute outright, so the
// legacy CLIENT_VERSION-only request stays as the floor rather than letting
// a missing extension fail context creation altogether.
struct EsVersion {
EGLint major;
EGLint minor;
};
static constexpr EsVersion kPreferredEsVersions[] = {{3, 2}, {3, 1}, {3, 0}, {2, 0}};
EGLContext candidate_context = EGL_NO_CONTEXT;
EGLint chosen_major = 0;
EGLint chosen_minor = 0;
for (const EsVersion& version : kPreferredEsVersions) {
const EGLint context_attribs[] = {
EGL_CONTEXT_MAJOR_VERSION, version.major, EGL_CONTEXT_MINOR_VERSION, version.minor, EGL_NONE};
candidate_context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attribs);
if (candidate_context != EGL_NO_CONTEXT) {
chosen_major = version.major;
chosen_minor = version.minor;
break;
}
}
if (candidate_context == EGL_NO_CONTEXT) {
for (const EGLint client_version : {3, 2}) {
const EGLint context_attribs[] = {EGL_CONTEXT_CLIENT_VERSION, client_version, EGL_NONE};
candidate_context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attribs);
if (candidate_context != EGL_NO_CONTEXT) {
chosen_major = client_version;
chosen_minor = 0;
break;
}
}
}
if (candidate_context == EGL_NO_CONTEXT) {
g_warning("MPV: Failed to create the video-plane EGL context: 0x%x", eglGetError());
return false;
}
// Report the requested version, not the delivered one - those are different
// claims, and the delivered one is logged below once a context is current.
g_message("MPV video plane: requested OpenGL ES %d.%d", chosen_major, chosen_minor);
auto destroy_candidate_context = [&]() {
if (eglGetCurrentContext() == candidate_context) {
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
}
if (!eglDestroyContext(display, candidate_context)) {
g_warning("MPV: Failed to destroy rejected video-plane EGL context: 0x%x", eglGetError());
}
};
if (!eglMakeCurrent(display, surface, surface, candidate_context)) {
g_warning("MPV: Failed to activate the video-plane EGL context: 0x%x", eglGetError());
destroy_candidate_context();
return false;
}
// What the driver actually gave, and whether mpv will find the entry points
// its compute path needs. Asking for a version is not the same as getting
// it, and mpv's own report of "compute shaders=0" says nothing about which
// half is missing. All of these are cheap and all were needed to diagnose
// this. The renderer name additionally decides the hwdec display handoff
// below.
const GLubyte* gl_version = glGetString(GL_VERSION);
const GLubyte* gl_renderer = glGetString(GL_RENDERER);
const bool software_renderer = IsSoftwareGlRenderer(reinterpret_cast<const char*>(gl_renderer));
g_message(
"MPV video plane: GL_VERSION='%s' GL_RENDERER='%s' dispatch_compute=%s image_load_store=%s",
gl_version ? reinterpret_cast<const char*>(gl_version) : "(null)",
gl_renderer ? reinterpret_cast<const char*>(gl_renderer) : "(null)",
eglGetProcAddress("glDispatchCompute") ? "yes" : "no", eglGetProcAddress("glBindImageTexture") ? "yes" : "no");
// Pre-flight the VAAPI dmabuf interop prerequisites. mpv's GL-side probe
// (dmabuf_interop_gl_init) is lazy — first hardware decode attempt — and its
// failure never fails mpv_render_context_create, so a driver that lacks the
// pieces quietly decodes everything in software. Naming which prerequisite
// is missing on this display/context turns that into a diagnosable
// one-liner. The three extensions are the ones the probe requires;
// EGL_EXT_image_dma_buf_import is the display-level one, GL_OES_EGL_image is
// context-level. (The VAAPI *device* init is a different story: given a
// Wayland display below, mpv opens it eagerly inside
// mpv_render_context_create.)
const char* egl_exts = eglQueryString(display, EGL_EXTENSIONS);
const GLubyte* gl_exts = glGetString(GL_EXTENSIONS);
const bool has_dma_buf = egl_exts != nullptr && strstr(egl_exts, "EGL_EXT_image_dma_buf_import") != nullptr;
const bool has_image_base = egl_exts != nullptr && strstr(egl_exts, "EGL_KHR_image_base") != nullptr;
const bool has_oes_egl_image =
gl_exts != nullptr && strstr(reinterpret_cast<const char*>(gl_exts), "GL_OES_EGL_image") != nullptr;
if (!has_dma_buf || !has_image_base || !has_oes_egl_image) {
g_warning(
"MPV video plane: VAAPI dmabuf interop prerequisites missing "
"(EGL_EXT_image_dma_buf_import=%d EGL_KHR_image_base=%d GL_OES_EGL_image=%d); "
"hardware decoding may silently fall back to software",
has_dma_buf, has_image_base, has_oes_egl_image);
}
// Now that a context is current, the surface's swap interval can be set.
// eglSwapBuffers runs on the GTK main thread and must never block: at the
// default interval Mesa throttles it on the compositor's frame callback,
// which an occluded surface never receives. The plane paces itself with its
// own frame callback instead.
if (!eglSwapInterval(display, 0)) {
g_warning("MPV: could not disable EGL swap throttling on the video plane: 0x%x", eglGetError());
}
mpv_opengl_init_params gl_init_params{};
gl_init_params.get_proc_address = get_opengl_proc_address;
gl_init_params.get_proc_address_ctx = nullptr;
mpv_render_param params[] = {
{MPV_RENDER_PARAM_API_TYPE, const_cast<char*>(MPV_RENDER_API_TYPE_OPENGL)},
{MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &gl_init_params},
{MPV_RENDER_PARAM_INVALID, nullptr},
{MPV_RENDER_PARAM_INVALID, nullptr},
};
// The plane only exists on Wayland, and hwdec interop wants the display
// handle: without it VAAPI has to find a device by other means and can
// quietly end up on software decoding, on the path that exists for
// performance.
//
// Never on a software renderer, though. Zero-copy interop into llvmpipe does
// not exist, so the handle buys nothing — and the one session that produces
// a software renderer on the plane (a compositor that hands clients no GPU
// device; Muffin 6.6.3, issue #1963) is also the one where libva-wayland's
// vaInitialize segfaults on that handle, inside mpv_render_context_create,
// taking the app down before playback starts. Left without a display handle,
// mpv's hwdec=auto probes the DRM render nodes instead, which still works on
// such a session (the kernel driver is fine; only the compositor's device
// handoff is broken).
#ifdef GDK_WINDOWING_WAYLAND
GdkDisplay* gdk_display = gdk_display_get_default();
if (GDK_IS_WAYLAND_DISPLAY(gdk_display) && !software_renderer) {
params[2].type = MPV_RENDER_PARAM_WL_DISPLAY;
params[2].data = gdk_wayland_display_get_wl_display(gdk_display);
} else if (software_renderer) {
g_message("MPV video plane: software GL renderer; not handing mpv the Wayland display for VAAPI interop");
}
#endif
mpv_render_context* candidate_gl = nullptr;
const int error = mpv_render_context_create(&candidate_gl, mpv_, params);
if (error < 0 || candidate_gl == nullptr) {
g_warning("MPV: mpv_render_context_create() failed for the video plane: %s", mpv_error_string(error));
if (candidate_gl) mpv_render_context_free(candidate_gl);
destroy_candidate_context();
return false;
}
egl_display_ = display;
egl_context_ = candidate_context;
surface_depth_bits_ = depth_bits > 0 ? depth_bits : 8;
mpv_gl_ = candidate_gl;
mpv_render_context_set_update_callback(mpv_gl_, OnMpvRenderUpdate, callback_context_.get());
// Hand the context over unbound: from here on only the plane render thread
// makes it current (RenderToSurface), and an EGLContext can be current on at
// most one thread. Creation itself had to happen with it current here - mpv
// probes GL inside mpv_render_context_create.
if (!eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)) {
g_warning("MPV: could not unbind the video-plane EGL context after creation: 0x%x", eglGetError());
}
g_message("MPV: Render context created on the Wayland video plane");
return true;
}
bool MpvPlayer::RenderToSurface(EGLSurface surface, int width, int height) {
// Runs on the plane render thread. Deliberately not holding native_mutex_
// across the render: a 4K HDR tone-map takes tens of milliseconds, and the
// mutex has main-thread callers on every seek (ReadSourceHdrMetadata,
// UpdateSourceHdrMetadata) - holding it here would rebuild the very stall
// this thread exists to remove, behind a lock instead of a thread. The
// snapshot below is safe without it because of ordering, not locking: the
// plugin drains the render thread (release_video_resources) before
// Dispose() hands mpv_gl_ and the EGL context to the teardown queue, so
// neither can be freed while a job is running.
mpv_render_context* render_context = nullptr;
EGLDisplay display = EGL_NO_DISPLAY;
EGLContext context = EGL_NO_CONTEXT;
int depth_bits = 8;
{
std::lock_guard<std::mutex> lock(native_mutex_);
if (disposed_ || !mpv_gl_ || egl_context_ == EGL_NO_CONTEXT || surface == EGL_NO_SURFACE) return false;
if (width < 1 || height < 1) return false;
render_context = mpv_gl_;
display = egl_display_;
context = egl_context_;
depth_bits = surface_depth_bits_;
}
if (!eglBindAPI(EGL_OPENGL_ES_API) || !eglMakeCurrent(display, surface, surface, context)) {
g_warning("MPV: Failed to activate the video-plane EGL context for render: 0x%x", eglGetError());
return false;
}
// Consume the redraw latch before rendering: OnMpvRenderUpdate drops further
// notifications until it is cleared.
needs_redraw_.store(false);
mpv_opengl_fbo mpv_fbo{};
mpv_fbo.fbo = 0; // the window surface's default framebuffer
mpv_fbo.w = width;
mpv_fbo.h = height;
// Ignored by the render API's OpenGL backend, which reads the depth param
// instead, but it is what mpv#16818's gpu-next backend will read, so state
// it truthfully rather than leave a lie in place for that day.
mpv_fbo.internal_format = depth_bits >= 16 ? GL_RGBA16F : depth_bits >= 10 ? GL_RGB10_A2 : GL_RGBA8;
// The default framebuffer is bottom-up relative to mpv's image orientation,
// so this flips.
int flip_y = 1;
// Without this mpv assumes 8 bits and dithers a 10-bit PQ plane down to 8,
// which bands precisely in the dark ramp PQ spends most of its code space on.
int depth = depth_bits;
mpv_render_param params[] = {
{MPV_RENDER_PARAM_OPENGL_FBO, &mpv_fbo},
{MPV_RENDER_PARAM_FLIP_Y, &flip_y},
{MPV_RENDER_PARAM_DEPTH, &depth},
{MPV_RENDER_PARAM_INVALID, nullptr},
};
mpv_render_context_render(render_context, params);
return true;
}
void MpvPlayer::Dispose() {
if (disposed_.exchange(true)) {
return;
}
// Stop native producers before revoking access to the player. A callback
// already entered on an mpv thread owns a lease and is allowed to finish.
{
std::lock_guard<std::mutex> lock(native_mutex_);
if (mpv_) {
const char* stop_command[] = {"stop", nullptr};
const int stop_result = mpv_command_async(mpv_, 0, stop_command);
if (stop_result < 0) {
g_warning("MPV: Failed to enqueue stop during disposal: %s", mpv_error_string(stop_result));
}
}
if (mpv_gl_) {
mpv_render_context_set_update_callback(mpv_gl_, nullptr, nullptr);
}
if (mpv_) {
mpv_set_wakeup_callback(mpv_, nullptr, nullptr);
}
}
callback_context_->DetachAndWait();
{
std::lock_guard<std::mutex> lock(callback_mutex_);
redraw_callback_ = nullptr;
event_callback_ = nullptr;
source_metadata_callback_ = nullptr;
}
auto cancelled = pending_requests_.CancelAll();
for (auto& callback : cancelled.status) {
callback(MPV_ERROR_UNINITIALIZED);
}
for (auto& callback : cancelled.properties) {
callback(-1, "");
}
RemoveTrackedSources();
// The plane's context is normally already unbound by the time this runs: the
// plane render thread makes it current (RenderToSurface) and the plugin's
// teardown posts an unbind there before draining the thread ahead of this
// call. This main-thread release covers the two paths that still bind it
// here - the PLEZY_PLANE_RENDER_MAIN_THREAD inline fallback, and a context
// created but never rendered with, where InitRenderContextForSurface's own
// unbind failed. An EGLContext can be current to at most one thread, so
// handing it to the teardown worker while it is still bound here makes the
// worker's eglMakeCurrent fail with EGL_BAD_ACCESS; the pair is then
// retained, and by the note below the mpv handle cannot be terminated until
// every pair drains. Repeated open/close would carry a whole stale mpv core
// across each gap. Only our own context is released: Flutter's must be left
// exactly where it is.
if (egl_context_ != EGL_NO_CONTEXT && eglGetCurrentContext() == egl_context_) {
if (!eglMakeCurrent(egl_display_, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT)) {
g_warning("MPV: Failed to release the video-plane EGL context before teardown: 0x%x", eglGetError());
}
}
// Transfer the render context, the EGL context and the shared mpv handle to
// the managed teardown thread. A failed EGL bind leaves the complete pair in
// the queue; the handle cannot be terminated until every pair is gone.
NativeRenderTeardownBatch teardown;
{
std::lock_guard<std::mutex> lock(native_mutex_);
if (mpv_gl_ || egl_context_ != EGL_NO_CONTEXT) {
teardown.resources.push_back({mpv_gl_, egl_display_, egl_context_});
}
teardown.handle = mpv_;
teardown.callback_keep_alive = callback_context_;
mpv_gl_ = nullptr;
mpv_ = nullptr;
egl_display_ = EGL_NO_DISPLAY;
egl_context_ = EGL_NO_CONTEXT;
// The next player must not decide against this one's colour space.
source_hdr_metadata_ = SourceHdrMetadata();
}
NativeRenderTeardownQueue::Instance().Enqueue(std::move(teardown));
observed_properties_.Clear();
}
void MpvPlayer::Command(const std::vector<std::string>& args) { CommandAsync(args, nullptr); }
void MpvPlayer::CommandAsync(const std::vector<std::string>& args, CommandCallback callback) {
if (disposed_ || !mpv_) {
if (callback) callback(MPV_ERROR_UNINITIALIZED);
return;
}
plezy::mpv_common::SubmitCommandAsync(mpv_, pending_requests_, args, std::move(callback));
}
void MpvPlayer::SetProperty(const std::string& name, const std::string& value) {
SetPropertyAsync(name, value, nullptr);
}
void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& value, StatusCallback callback) {
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
// Ahead of the handle check: a substituted writer stands in for the core, so
// the absence of a real one is not a reason to refuse the write.
if (test_property_write_) {
test_property_write_(name, value, std::move(callback));
return;
}
#endif
if (disposed_ || !mpv_) {
if (callback) callback(MPV_ERROR_UNINITIALIZED);
return;
}
if (name == "hdr-enabled") {
SetHDREnabled(plezy::mpv_common::ParseEnabledFlag(value), std::move(callback));
return;
}
plezy::mpv_common::SubmitSetPropertyAsync(
mpv_, pending_requests_, name, value, [this, name, value, cb = std::move(callback)](int error) mutable {
// Native-side attribution for the same failure the platform channel
// reports: the HDR transaction's property writes never reach the
// channel handler, so without this a refused target-* write leaves
// only mpv's error string in the log. Values are truncated the same
// way the channel error description is, so a token or URL that lands
// in a property value stays bounded.
if (error < 0 && !disposed_) {
std::string logged = value;
if (logged.size() > plezy::mpv_common::kSetPropertyErrorDescriptionLimit) {
logged.resize(plezy::mpv_common::kSetPropertyErrorDescriptionLimit);
}
g_warning("MPV: setProperty '%s'='%s' failed: %s", name.c_str(), logged.c_str(), mpv_error_string(error));
}
if (cb) cb(error);
});
}
bool MpvPlayer::ReadSourceHdrMetadata(SourceHdrMetadata* out) {
if (out == nullptr) return false;
std::lock_guard<std::mutex> lock(native_mutex_);
if (disposed_ || !mpv_) return false;
*out = source_hdr_metadata_;
return true;
}
void MpvPlayer::UpdateSourceHdrMetadata(const mpv_node* params) {
{
std::lock_guard<std::mutex> lock(native_mutex_);
source_hdr_metadata_ = ParseSourceHdrMetadata(params);
}
// Outside the lock on purpose: the callback's whole job is to re-run the HDR
// decision, which reads the cache straight back through ReadSourceHdrMetadata.
SourceMetadataCallback callback;
{
std::lock_guard<std::mutex> lock(callback_mutex_);
callback = source_metadata_callback_;
}
if (callback) callback();
}
void MpvPlayer::SetSourceMetadataCallback(SourceMetadataCallback callback) {
std::lock_guard<std::mutex> lock(callback_mutex_);
source_metadata_callback_ = std::move(callback);
}
void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback callback) {
if (disposed_ || !mpv_) {
if (callback) callback(MPV_ERROR_UNINITIALIZED, "");
return;
}
plezy::mpv_common::SubmitGetPropertyAsync(mpv_, pending_requests_, name, std::move(callback));
}
void MpvPlayer::ObserveProperty(const std::string& name, const std::string& format, int id) {
if (disposed_ || !mpv_) return;
const auto request = observed_properties_.Register(name, format, id);
if (!request.added) return;
mpv_observe_property(mpv_, request.userdata, name.c_str(), request.format);
}
void MpvPlayer::SetEventCallback(EventCallback callback) {
std::lock_guard<std::mutex> lock(callback_mutex_);
event_callback_ = std::move(callback);
}
void MpvPlayer::SetRedrawCallback(RedrawCallback callback) {
std::lock_guard<std::mutex> lock(callback_mutex_);
redraw_callback_ = std::move(callback);
}
void MpvPlayer::SetLogLevel(const std::string& level) {
if (disposed_ || !mpv_) return;
mpv_request_log_messages(mpv_, level.c_str());
}
void MpvPlayer::OnMpvWakeup(void* ctx) {
auto* context = static_cast<CallbackContext*>(ctx);
auto lease = context->Acquire();
if (!lease) return;
MpvPlayer* player = lease.player();
if (!player->disposed_) {
player->ScheduleWakeupSource();
}
}
void MpvPlayer::OnMpvRenderUpdate(void* ctx) {
auto* context = static_cast<CallbackContext*>(ctx);
auto lease = context->Acquire();
if (!lease) return;
MpvPlayer* player = lease.player();
if (player->disposed_) return;
bool expected = false;
if (!player->needs_redraw_.compare_exchange_strong(expected, true)) {
return;
}
// The redraw must run on the player's owning GLib context, never on mpv's
// render/VO thread.
player->ScheduleRedrawSource();
}
void MpvPlayer::DestroySourceCallbackData(gpointer data) { delete static_cast<SourceCallbackData*>(data); }
void MpvPlayer::ScheduleWakeupSource() {
std::lock_guard<std::mutex> lock(source_mutex_);
if (disposed_ || wakeup_source_id_ != 0) return;
GSource* source = g_idle_source_new();
g_source_set_priority(source, G_PRIORITY_HIGH_IDLE);
auto* data = new SourceCallbackData(callback_context_);
g_source_set_callback(source, DispatchWakeupSource, data, DestroySourceCallbackData);
data->source_id = g_source_attach(source, callback_context_->main_context());
wakeup_source_id_ = data->source_id;
g_source_unref(source);
}
void MpvPlayer::ScheduleRedrawSource() {
std::lock_guard<std::mutex> lock(source_mutex_);
if (disposed_ || redraw_source_id_ != 0) return;
GSource* source = g_idle_source_new();
auto* data = new SourceCallbackData(callback_context_);
g_source_set_callback(source, DispatchRedrawSource, data, DestroySourceCallbackData);
data->source_id = g_source_attach(source, callback_context_->main_context());
redraw_source_id_ = data->source_id;
g_source_unref(source);
if (redraw_source_id_ == 0) {
needs_redraw_ = false;
}
}
void MpvPlayer::ScheduleRecoverySource() {
std::lock_guard<std::mutex> lock(source_mutex_);
if (disposed_ || recovery_source_id_ != 0) return;
GSource* source = g_timeout_source_new(100);
auto* data = new SourceCallbackData(callback_context_);
g_source_set_callback(source, DispatchRecoverySource, data, DestroySourceCallbackData);
data->source_id = g_source_attach(source, callback_context_->main_context());
recovery_source_id_ = data->source_id;
g_source_unref(source);
}
gboolean MpvPlayer::DispatchWakeupSource(gpointer data) {
auto* source_data = static_cast<SourceCallbackData*>(data);
auto lease = source_data->context->Acquire();
if (!lease) return G_SOURCE_REMOVE;
MpvPlayer* player = lease.player();
{
std::lock_guard<std::mutex> lock(player->source_mutex_);
if (player->wakeup_source_id_ == source_data->source_id) {
player->wakeup_source_id_ = 0;
}
}
if (!player->disposed_ && player->mpv_) {
player->ProcessEvents();
}
return G_SOURCE_REMOVE;
}
gboolean MpvPlayer::DispatchRedrawSource(gpointer data) {
auto* source_data = static_cast<SourceCallbackData*>(data);
auto lease = source_data->context->Acquire();
if (!lease) return G_SOURCE_REMOVE;
MpvPlayer* player = lease.player();
{
std::lock_guard<std::mutex> lock(player->source_mutex_);
if (player->redraw_source_id_ == source_data->source_id) {
player->redraw_source_id_ = 0;
}
}
if (player->disposed_) return G_SOURCE_REMOVE;
RedrawCallback callback;
{
std::lock_guard<std::mutex> lock(player->callback_mutex_);
callback = player->redraw_callback_;
}
if (callback) callback();
return G_SOURCE_REMOVE;
}
gboolean MpvPlayer::DispatchRecoverySource(gpointer data) {
auto* source_data = static_cast<SourceCallbackData*>(data);
auto lease = source_data->context->Acquire();
if (!lease) return G_SOURCE_REMOVE;
MpvPlayer* player = lease.player();
if (player->disposed_) return G_SOURCE_REMOVE;
player->MaybeRunAudioRecovery();
if (player->audio_recovery_.HasPendingWork()) {
return G_SOURCE_CONTINUE;
}
std::lock_guard<std::mutex> lock(player->source_mutex_);
if (player->recovery_source_id_ == source_data->source_id) {
player->recovery_source_id_ = 0;
}
return G_SOURCE_REMOVE;
}
void MpvPlayer::RemoveTrackedSources() {
std::lock_guard<std::mutex> lock(source_mutex_);
GMainContext* context = callback_context_->main_context();
auto remove = [context](guint& source_id) {
if (source_id == 0) return;
GSource* source = g_main_context_find_source_by_id(context, source_id);
if (source) g_source_destroy(source);
source_id = 0;
};
remove(wakeup_source_id_);
remove(redraw_source_id_);
remove(recovery_source_id_);
}
bool MpvPlayer::ProcessEvents() {
if (disposed_ || !mpv_) return false;
while (true) {
mpv_event* event = mpv_wait_event(mpv_, 0);
if (event->event_id == MPV_EVENT_NONE) {
break;
}
if (event->event_id == MPV_EVENT_SHUTDOWN) {
return false;
}
HandleMpvEvent(event);
}
return true;
}
void MpvPlayer::LogRecovery(const std::string& text) {
g_warning("MPV audio-recovery: %s", text.c_str());
FlValue* data = fl_value_new_map();
fl_value_set_string_take(data, "prefix", fl_value_new_string("audio-recovery"));
fl_value_set_string_take(data, "level", fl_value_new_string("warn"));
fl_value_set_string_take(data, "text", fl_value_new_string(text.c_str()));
SendEvent("log-message", data);
fl_value_unref(data);
}
void MpvPlayer::TryAudioReload(const char* reason, int attempt, uint64_t request_generation) {
LogRecovery("issuing ao-reload (reason=" + std::string(reason) + ", attempt " + std::to_string(attempt) + ")");
const std::string reason_copy = reason;
auto callback_context = callback_context_;
CommandAsync({"ao-reload"}, [callback_context, reason_copy, attempt, request_generation](int error) {
auto lease = callback_context->Acquire();
if (!lease) return;
MpvPlayer* player = lease.player();
player->audio_recovery_.CompleteReload(request_generation);
player->LogRecovery(
"ao-reload completed (reason=" + reason_copy + ", attempt " + std::to_string(attempt) +
", error=" + std::to_string(error) + ")");
});
}
void MpvPlayer::MaybeRunAudioRecovery() {
const auto action = audio_recovery_.NextReload(plezy::mpv_common::AudioRecoveryState::Clock::now());
if (action.reason == plezy::mpv_common::AudioReloadReason::kNone) {
return;
}
const char* reason = action.reason == plezy::mpv_common::AudioReloadReason::kResume ? "resume" : "null-fallback";
TryAudioReload(reason, action.attempt, action.request_generation);
if (action.exhausted) {
LogRecovery("audio recovery budget exhausted; waiting for device list change");
}
}
void MpvPlayer::EnsureAudioRecoveryTimer() {
if (!audio_recovery_.HasPendingWork()) return;
ScheduleRecoverySource();
}
void MpvPlayer::HandleMpvEvent(mpv_event* event) {
if (plezy::mpv_common::DispatchReplyEvent(
pending_requests_, event, [](const char* value) { return SanitizeUtf8(value); })) {
return;
}
switch (event->event_id) {
case MPV_EVENT_LOG_MESSAGE: {
auto* msg = static_cast<mpv_event_log_message*>(event->data);
if (!msg) break;
g_message("MPV [%s] %s: %s", msg->level, msg->prefix, msg->text);
FlValue* data = fl_value_new_map();
fl_value_set_string_take(data, "prefix", fl_value_new_string(SanitizeUtf8(msg->prefix).c_str()));
fl_value_set_string_take(data, "level", fl_value_new_string(SanitizeUtf8(msg->level).c_str()));
fl_value_set_string_take(data, "text", fl_value_new_string(SanitizeUtf8(msg->text).c_str()));
SendEvent("log-message", data);
fl_value_unref(data);
break;
}
case MPV_EVENT_PROPERTY_CHANGE: {
auto* prop = static_cast<mpv_event_property*>(event->data);
if (!prop || !prop->name) break;
mpv_node node = plezy::mpv_common::ExtractPropertyNode(prop);
// This runner's own observation, which nothing on the Dart side asked
// for and nothing there is waiting on. mpv delivers one event per
// observation, so a Dart-side observer of the same property still gets
// its own under its own userdata.
if (event->reply_userdata == kVideoParamsUserdata) {
UpdateSourceHdrMetadata(&node);
break;
}
if (event->reply_userdata == kHwdecCurrentUserdata) {
const char* value = node.format == MPV_FORMAT_STRING ? node.u.string : nullptr;
g_message("MPV: hwdec-current=%s", value && value[0] != '\0' ? value : "(none)");
break;
}
const auto notice = plezy::mpv_common::ObserveAudioRecoveryProperty(audio_recovery_, event, prop);
if (notice.message) LogRecovery(notice.message);
// Recovery runs off a GLib timer here, so newly queued work has to arm it.
if (notice.scheduled_work) EnsureAudioRecoveryTimer();
SendPropertyChange(prop->name, &node);
break;
}
case MPV_EVENT_END_FILE: {
audio_recovery_.SetFileLoaded(false);
// Whatever comes next is a different source until video-params says
// otherwise, and describing it against this one's colour space is the
// one failure worth a transient wrong answer to avoid. No re-apply is
// requested: the plane keeps the description it has until the next
// playback-restart or video-params change, exactly as before.
{
std::lock_guard<std::mutex> lock(native_mutex_);
source_hdr_metadata_ = SourceHdrMetadata();
}
auto* end = static_cast<mpv_event_end_file*>(event->data);
if (!end) break;
FlValue* data = fl_value_new_map();
fl_value_set_string_take(data, "sourceId", fl_value_new_int(end->playlist_entry_id));
fl_value_set_string_take(data, "reason", fl_value_new_int(static_cast<int>(end->reason)));
if (end->reason == MPV_END_FILE_REASON_ERROR) {
fl_value_set_string_take(data, "error", fl_value_new_int(static_cast<int>(end->error)));
fl_value_set_string_take(
data, "message", fl_value_new_string(SanitizeUtf8(mpv_error_string(end->error)).c_str()));
}
SendEvent("end-file", data);
fl_value_unref(data);
break;
}
case MPV_EVENT_START_FILE: {
auto* start = static_cast<mpv_event_start_file*>(event->data);
if (!start) break;
active_source_id_ = start->playlist_entry_id;
has_active_source_id_ = true;
SendActiveSourceEvent("start-file");
break;
}
case MPV_EVENT_FILE_LOADED: {
audio_recovery_.SetFileLoaded(true);
EnsureAudioRecoveryTimer();
SendActiveSourceEvent("file-loaded");
break;
}
case MPV_EVENT_PLAYBACK_RESTART: {
double position_seconds = 0.0;
const double* position = nullptr;
if (mpv_ && mpv_get_property(mpv_, "time-pos", MPV_FORMAT_DOUBLE, &position_seconds) >= 0) {
position = &position_seconds;
}
SendPlaybackRestartEvent(position);
break;
}
default:
break;
}
}
namespace {
// Adapts the shared, bounded mpv_node walk onto GLib-owned FlValues.
struct FlValueNodeBuilder {
using Value = FlValue*;
using ListBuilder = FlValue*;
using MapBuilder = FlValue*;
static Value Null() { return fl_value_new_null(); }
static Value Boolean(bool value) { return fl_value_new_bool(value); }
static Value Int(int64_t value) { return fl_value_new_int(value); }
static Value Double(double value) { return fl_value_new_float(value); }
static Value String(const char* value, size_t length) {
return fl_value_new_string(SanitizeUtf8(value, length).c_str());
}
static ListBuilder NewList() { return fl_value_new_list(); }
static void Append(ListBuilder& list, Value value) { fl_value_append_take(list, value); }
static Value FinishList(ListBuilder list) { return list; }
static MapBuilder NewMap() { return fl_value_new_map(); }
static void Insert(MapBuilder& map, const char* key, size_t key_length, Value value) {
fl_value_set_string_take(map, SanitizeUtf8(key, key_length).c_str(), value);
}
static Value FinishMap(MapBuilder map) { return map; }
static void AbandonMap(MapBuilder& map) { fl_value_unref(map); }
};
} // namespace
FlValue* MpvPlayer::NodeToFlValue(mpv_node* node) { return plezy::mpv_common::ConvertNode<FlValueNodeBuilder>(node); }
FlValue* MpvPlayer::NodeToFlValue(mpv_node* node, plezy::mpv_common::NodeConversionBudget* budget) {
return plezy::mpv_common::ConvertNode<FlValueNodeBuilder>(node, 0, budget);
}
void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) {
if (!name) return;
int id = 0;
if (!observed_properties_.LookupId(name, &id)) return;
FlValue* list = fl_value_new_list();
fl_value_append_take(list, fl_value_new_int(id));
if (data) {
fl_value_append_take(list, NodeToFlValue(data));
} else {
fl_value_append_take(list, fl_value_new_null());
}
if (has_active_source_id_) {
fl_value_append_take(list, fl_value_new_int(active_source_id_));
} else {
fl_value_append_take(list, fl_value_new_null());
}
EventCallback callback;
{
std::lock_guard<std::mutex> lock(callback_mutex_);
callback = event_callback_;
}
if (callback) callback(list);
fl_value_unref(list);
}
void MpvPlayer::SendActiveSourceEvent(const std::string& name) {
FlValue* data = nullptr;
if (has_active_source_id_) {
data = fl_value_new_map();
fl_value_set_string_take(data, "sourceId", fl_value_new_int(active_source_id_));
}
SendEvent(name, data);
if (data) fl_value_unref(data);
}
void MpvPlayer::SendPlaybackRestartEvent(const double* position_seconds) {
const bool has_position = position_seconds && std::isfinite(*position_seconds);
FlValue* data = nullptr;
if (has_active_source_id_ || has_position) {
data = fl_value_new_map();
if (has_active_source_id_) {
fl_value_set_string_take(data, "sourceId", fl_value_new_int(active_source_id_));
}
if (has_position) {
fl_value_set_string_take(data, "positionSeconds", fl_value_new_float(*position_seconds));
}
}
SendEvent("playback-restart", data);
if (data) fl_value_unref(data);
}
void MpvPlayer::SendEvent(const std::string& name, FlValue* data) {
FlValue* event_map = fl_value_new_map();
fl_value_set_string_take(event_map, "type", fl_value_new_string("event"));
fl_value_set_string_take(event_map, "name", fl_value_new_string(name.c_str()));
if (data) {
fl_value_set_string_take(event_map, "data", fl_value_ref(data));
}
EventCallback callback;
{
std::lock_guard<std::mutex> lock(callback_mutex_);
callback = event_callback_;
}
if (callback) callback(event_map);
fl_value_unref(event_map);
}
void MpvPlayer::ApplyPropertySequence(
std::shared_ptr<std::vector<PropertyChange>> changes, size_t index, StatusCallback callback) {
if (changes == nullptr || index >= changes->size()) {
if (callback) callback(MPV_ERROR_SUCCESS);
return;
}
const PropertyChange& change = (*changes)[index];
SetPropertyAsync(change.name, change.value, [this, changes, index, cb = std::move(callback)](int error) mutable {
if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) {
ApplyPropertySequence(changes, index + 1, std::move(cb));
return;
}
// `index` entries already landed and must come back, newest
// first, so a refused change leaves the previous state intact
// rather than a half-applied mixture of the two.
RollbackPropertySequence(changes, index, error, std::move(cb));
});
}
void MpvPlayer::RollbackPropertySequence(
std::shared_ptr<std::vector<PropertyChange>> changes, size_t undo_count, int failure, StatusCallback callback) {
if (changes == nullptr || undo_count == 0) {
// Only now is mpv genuinely back where it started, so only now may the caller
// hear about it. The original failure is what it needs, not the outcome of the
// unwinding.
if (callback) callback(failure);
return;
}
const size_t index = undo_count - 1;
const PropertyChange& change = (*changes)[index];
SetPropertyAsync(
change.name, change.rollback, [this, changes, index, failure, cb = std::move(callback)](int error) mutable {
if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) {
RollbackPropertySequence(changes, index, failure, std::move(cb));
return;
}
// Carrying on would leave mpv in a state that is neither the
// old one nor the new, and *no* surface description is correct
// for a signal nobody can name. Escalate to the one state that
// is always describable and always accepts its value: SDR.
g_warning(
"MPV: could not restore %s while unwinding a refused output colour space; "
"forcing SDR",
(*changes)[index].name.c_str());
ForceSdrOutput(0, failure, std::move(cb));
});
}
// The one place the applied-output cache is written, so every path that moves a
// property records it the same way. Matched by name, not position: the order the
// sequences use is load-bearing and has to stay free to change without silently
// reassigning the wrong field.
void MpvPlayer::RecordAppliedOutputProperty(const std::string& name, const std::string& value) {
if (name == "target-peak") {
applied_target_peak_ = value;
} else if (name == "target-prim") {
applied_target_prim_ = value;
} else if (name == "target-trc") {
applied_target_trc_ = value;
} else if (name == "tone-mapping") {
applied_tone_mapping_ = value;
}
}
void MpvPlayer::ForceSdrOutput(size_t index, int failure, StatusCallback callback) {
// Same order the apply path uses: the transfer function stops asking for HDR
// before the primaries, operator and peak follow it back.
static const char* const kResetOrder[] = {"target-trc", "target-prim", "tone-mapping", "target-peak"};
constexpr size_t kResetCount = sizeof(kResetOrder) / sizeof(kResetOrder[0]);
if (index >= kResetCount) {
// mpv is SDR now, not back where it started, so any HDR description the
// caller has already committed is a lie about these pixels.
hdr_unwind_result_ = HdrOutputResult::kForcedSdr;
output_state_known_ = true;
if (callback) callback(failure);
return;
}
SetPropertyAsync(kResetOrder[index], "auto", [this, index, failure, cb = std::move(callback)](int error) mutable {
if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) {
// Recorded as it lands, not once the whole reset is through. Recording
// only at the end would leave the cache naming the pre-reset curve for
// every property that did move if a later one is refused, and the no-op
// short-circuit would then answer a repeat request from it - committing an
// HDR description over pixels mpv had already reset to SDR.
RecordAppliedOutputProperty(kResetOrder[index], "auto");
ForceSdrOutput(index + 1, failure, std::move(cb));
return;
}
// `auto` is valid for every one of them, so this failing means mpv is
// no longer taking orders at all - usually because it is being
// disposed. Either way what it emits is now unknowable, and the
// caller must stop presenting the plane rather than guess.
hdr_unwind_result_ = HdrOutputResult::kUnknown;
// And the cache is now a record of what we *asked* for, not what mpv holds:
// some of the reset landed and some did not. Marking it untrusted is what
// stops the short-circuit skipping a later write on the strength of it. The
// strings are left alone deliberately - they are still the best rollback
// targets available if a later sequence gets that far.
output_state_known_ = false;
g_warning(
"MPV: output colour space is no longer commandable; what the plane emits "
"is unknown");
if (cb) cb(failure);
});
}
bool MpvPlayer::CanCommandOutputProperties() const {
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
// The substituted writer is the core here; see SetPropertyAsync, which routes
// to it ahead of the same handle check.
if (test_property_write_) return true;
#endif
return !disposed_ && mpv_ != nullptr;
}
#ifdef PLEZY_MPV_PLAYER_LIFECYCLE_TEST
void MpvPlayer::ConfigurePropertyWritesForTesting(PropertyWriteForTesting writer) {
test_property_write_ = std::move(writer);
}
MpvPlayer::AppliedOutputColourSpace MpvPlayer::AppliedOutputColourSpaceForTesting() const {
return {applied_target_trc_, applied_target_prim_, applied_tone_mapping_, applied_target_peak_};
}
#endif
void MpvPlayer::SetHdrOutput(SourceTransfer transfer, uint32_t target_peak_nits, HdrOutputCallback callback) {
if (!CanCommandOutputProperties()) {
// The third place a result is named, and it owes the same honesty as the
// other two: nothing was touched, so the previous state stands - which is
// only worth saying when that state is nameable. Otherwise a request that
// was already queued when the core went away is answered kUnknown by the
// drain while an identical one arriving a moment later hears kRestored.
if (callback) {
callback(output_state_known_ ? HdrOutputResult::kRestored : HdrOutputResult::kUnknown, MPV_ERROR_UNINITIALIZED);
}
return;
}
// Requests are serialized, and queued rather than coalesced.
//
// Playback restarts, preferred-description changes and the two settings can
// each ask for a new output colour space, and every step of a sequence
// completes asynchronously. Two overlapping sequences would interleave: a
// failure in the older one would issue rollbacks that overwrite properties the
// newer one had already set, while the newer one still reported success and
// recorded values mpv no longer holds. That is the divergence the sequencing
// exists to prevent.
//
// Each request keeps its own callback instead of being folded into the newest
// one, because callers commit their own state on success: telling a caller its
// change landed when a *different* request is what actually landed reintroduces
// the same divergence one level up. Strict ordering then makes the bookkeeping
// trivial — the last request to succeed is exactly what mpv holds, so nobody
// needs an epoch to work out whether their commit is still current.
hdr_queue_.push_back(HdrOutputRequest{transfer, target_peak_nits, std::move(callback)});
if (hdr_sequence_in_flight_) return;
RunPendingHdrOutput();
}
void MpvPlayer::RunPendingHdrOutput() {
if (!CanCommandOutputProperties()) {
hdr_sequence_in_flight_ = false;
auto orphaned = std::move(hdr_queue_);
hdr_queue_.clear();
for (auto& request : orphaned) {
// Nothing was touched, so the previous state - whatever it was - still
// stands as far as this request is concerned. Which is only worth telling
// the caller when that state is nameable; if the last unwind gave up
// halfway, "unchanged" describes a colour space nobody knows.
if (request.callback) {
request.callback(
output_state_known_ ? HdrOutputResult::kRestored : HdrOutputResult::kUnknown, MPV_ERROR_UNINITIALIZED);
}
}
return;
}
if (hdr_queue_.empty()) {
hdr_sequence_in_flight_ = false;
return;
}
HdrOutputRequest request = std::move(hdr_queue_.front());
hdr_queue_.pop_front();
hdr_sequence_in_flight_ = true;
// Assume a clean unwind; the escalation path in RollbackPropertySequence and
// ForceSdrOutput moves this on if it cannot manage one.
hdr_unwind_result_ = HdrOutputResult::kRestored;
// Four properties describe one output colour space, so they are applied as a
// unit. A plane whose primaries moved to BT.2020 while its transfer function
// stayed on gamma is neither SDR nor HDR, and the caller describes the surface
// to the compositor on success — a silently half-applied set would have the
// compositor told one thing and shown another.
//
// target-peak is what mpv maps to. Under PQ, left on auto it resolves to the
// format's nominal 10000 nits, so the renderer never tone-maps and the
// compositor owns the decision. Set to the display's real peak, mpv tone-maps
// to it and the caller declares that same peak, leaving the compositor nothing
// to do.
//
// On the SDR fallback the peak and curve are named for accuracy, not to make
// tone mapping happen: mpv 0.40 already resolves target-peak=auto to 203 nits
// and target-trc=auto to gamma 2.2 for an SDR curve, and measurement confirmed
// naming them changed the shadows but not the highlights. What they buy is the
// surface's real terms instead of assumed ones - the compositor's own reference
// white, and sRGB, which is what an undescribed Wayland surface is and what
// this compositor's preferred description for the output says. Hence no
// `enabled` in the peak condition below: an SDR peak is a real instruction, not
// a leftover from an HDR request.
const bool enabled = request.transfer != SourceTransfer::kSdr;
const char* primaries = enabled ? "bt.2020" : "auto";
// The option is an integer in [10, 10000]; anything outside means "auto".
const bool tone_map_here = request.peak_nits >= 10 && request.peak_nits <= kPqMaxLuminanceNits;
const std::string peak = tone_map_here ? std::to_string(request.peak_nits) : std::string("auto");
const char* curve = request.transfer == SourceTransfer::kHlg
? "hlg"
: (request.transfer == SourceTransfer::kPq ? "pq" : (tone_map_here ? "srgb" : "auto"));
// The operator only matters while a tone-map pass runs, and it must go back to
// auto when one does not; see applied_tone_mapping_ in mpv_player.h.
//
// Restricted to the undescribed SDR target, which is where it was measured.
// Player-side mapping onto an HDR output aims at a PQ target instead, and
// nothing has been measured there yet - that needs the external display - so
// it keeps mpv's own choice until it can be judged the same way.
//
// mobius's shape is governed by tone-mapping-param, its transition point: below
// it the curve is 1:1, above it rolls off. Left at mpv's default 0.3 because
// that measured best, not by omission. Raising it trades highlight shoulder for
// in-range luminance, and against libplacebo's rendering of the same chart
// (400/700/1000 -> 238.5/253.8/254.8, 100 nits -> 134.0) the default is closest
// on both counts, with higher values moving away on each:
//
// param 100 nits 400->1000 span
// 0.30 179.0 17.1
// 0.45 184.4 12.1
// 0.60 185.0 7.2
//
// It also does not touch the cost this operator carries. On real 1000-nit
// footage mobius sits 0.027 dxy and ~12% darker than BT.2390 whatever the
// transition point is (0.30/0.38/0.45 measured identical), because that
// difference is gamut handling rather than the tone curve, and a dark scene's
// pixels fall below the transition point in every case.
//
// Not pinned explicitly: the option has no accepted "unset" token - `default`
// is rejected - so writing it would leave a mobius-specific value applied to
// whatever operator runs next, including BT.2390 on the unmeasured HDR-output
// path. Recorded here instead so an upstream default change is diagnosable.
const char* operator_name = (tone_map_here && !enabled) ? "mobius" : "auto";
// playback-restart drives a re-apply and fires on every seek, so most calls
// here ask for the state mpv already holds. The plane's own half already
// short-circuits an identical request; this is the other half. Without it a
// seek costs four property round-trips and a log line saying nothing changed,
// and holds the sequence long enough to defer a real request behind it.
//
// kApplied, because that is the truth the caller acts on: these four values
// *are* in force, so a surface description committed against them stays
// honest. The queue has to keep draining from here exactly as it does on the
// applied path, or a coalesced request behind this one never runs. Unlike that
// path the call is a real recursion rather than a fresh stack, which is fine
// because the plugin coalesces reapplies into a single pending flag: the queue
// holds the one in flight plus at most one waiting.
// output_state_known_ first: the comparison is only meaningful while the cache
// is a record of what mpv holds. A forced-SDR reset that was itself refused
// partway leaves it a record of what was *asked* for, and skipping on that
// would report kApplied for a colour space mpv is not in - which the caller
// then commits an image description against.
if (output_state_known_ && applied_target_trc_ == curve && applied_target_prim_ == primaries &&
applied_tone_mapping_ == operator_name && applied_target_peak_ == peak) {
if (request.callback) request.callback(HdrOutputResult::kApplied, 0);
RunPendingHdrOutput();
return;
}
// The values that decide who tone-maps and against what, none of which is
// visible on screen: two very different curves both look like working video.
// Logged next to the plane's own decisions so a capture can be matched to the
// state that produced it.
g_message(
"MPV: output colour target peak=%s prim=%s trc=%s tone-mapping=%s", peak.c_str(), primaries, curve,
operator_name);
// Dependencies first, peak last, matching the order kResetOrder uses. The peak
// is what decides whether a tone-map pass runs at all, so everything that pass
// depends on is in place before it is named.
auto changes = std::make_shared<std::vector<PropertyChange>>();
changes->push_back({"target-trc", curve, applied_target_trc_});
changes->push_back({"target-prim", primaries, applied_target_prim_});
changes->push_back({"tone-mapping", operator_name, applied_tone_mapping_});
changes->push_back({"target-peak", peak, applied_target_peak_});
ApplyPropertySequence(changes, 0, [this, changes, callback = std::move(request.callback)](int error) {
const bool ok = plezy::mpv_common::SetPropertyStatusSucceeded(error);
// Only a fully applied set becomes the new rollback target; a failed one was
// already unwound, and the unwinding updated these itself if it had to force
// SDR.
if (ok && !disposed_) {
for (const PropertyChange& change : *changes) {
RecordAppliedOutputProperty(change.name, change.value);
}
// A clean apply is the one outcome that leaves mpv exactly where the cache
// says, so it is what re-earns the short-circuit's trust after an unwind
// gave up halfway.
output_state_known_ = true;
}
// This request's own outcome, to this request's own caller. The result names
// what mpv is actually in now, which is what decides whether the caller's
// committed surface description is still true.
//
// kRestored says "mpv is where it was", which only reassures the caller while
// where it was is known. After an unwind that gave up halfway it is not: a
// sequence refused on its very first write unwinds nothing, so it reports the
// untouched kRestored while mpv sits in the half-reset state nobody can name.
// The caller would read that as "your description still holds" and put an
// undescribed plane back on screen. Downgrading to kUnknown is the honest
// answer, and a clean apply - the one thing that re-earns the trust - is
// reported as kApplied above regardless.
const HdrOutputResult result =
ok ? HdrOutputResult::kApplied : (output_state_known_ ? hdr_unwind_result_ : HdrOutputResult::kUnknown);
if (callback) callback(result, error);
// Whatever arrived while this ran runs now — never alongside.
RunPendingHdrOutput();
});
}
void MpvPlayer::SetHDREnabled(bool enabled, StatusCallback callback) {
SetPropertyAsync(
"target-colorspace-hint", plezy::mpv_common::TargetColorspaceHint(enabled),
[this, enabled, callback = std::move(callback)](int error) mutable {
if (plezy::mpv_common::SetPropertyStatusSucceeded(error) && !disposed_) {
hdr_enabled_ = enabled;
}
if (callback) callback(error);
});
}
} // namespace mpv