· 3 min read · ★ Recommended · by Hemant Kumar
Qt QML + libmpv: Production-Ready Cross-Platform Video Rendering

Qt QML + libmpv: Production-Ready Cross-Platform Video Rendering

qt qml libmpv cpp opengl cmake android windows

Embedding high-performance, hardware-accelerated video rendering into a cross-platform app is a classic headache. If you want a custom UI with buttons, smooth overlays, and animations on top of the video, you can’t just overlay a native window.

The clean solution on Qt 6 is to link Qt QML with libmpv using a QQuickFramebufferObject (FBO). This renders video frames offscreen directly into an OpenGL texture that QML treats as a normal visual item.

This post provides a complete, copy-pasteable reference for setting up Qt Quick and libmpv for Windows, Android, and Linux. It covers the folder layouts for pre-compiled binaries, the CMake magic for cross-compilation, and the full C++ and QML source code.


The Directory Layout

To keep the build simple and predictable across machines, store your pre-built platform libraries under a central libs folder. If you are retrieving binaries from a registry like github/hemantkarya/libmpv-bins, arrange them in this exact layout:

my_qt_project/
├── CMakeLists.txt
├── main.cpp
├── libs/
│   ├── windows/
│   │   ├── x86_64/
│   │   │   ├── include/
│   │   │   │   └── mpv/
│   │   │   │       ├── client.h
│   │   │   │       └── render_gl.h
│   │   │   ├── bin/
│   │   │   │   └── libmpv-2.dll      # The runtime DLL
│   │   │   └── lib/
│   │   │       └── mpv.lib            # Import library for linking
│   │   └── x86/
│   │       └── ...
│   └── android/
│       ├── arm64-v8a/
│       │   ├── include/
│       │   │   └── mpv/
│       │   │       ├── client.h
│       │   │       └── render_gl.h
│       │   └── lib/
│       │       └── libmpv.so          # Shared library
│       └── armeabi-v7a/
│           └── ...
└── src/
    ├── player/
    │   ├── mpvobject.h
    │   └── mpvobject.cpp
    └── main.cpp

CMakeLists.txt

Your CMake setup needs to locate headers and link libraries differently depending on the target system. On Windows, it copies the DLL to the build output directory so the executable can launch. On Android, it maps ABI-specific binaries and injects them using QT_ANDROID_EXTRA_LIBS to pack them inside the .apk.

cmake_minimum_required(VERSION 3.21)
project(MpvQuickPlayer LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Qt 6 Core, Gui, Quick, and OpenGL are required
find_package(Qt6 REQUIRED COMPONENTS Core Gui Quick OpenGL)

set(LIBS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/libs")
set(APP_TARGET MpvQuickPlayer)

set(APP_SOURCES
    main.cpp
    src/player/mpvobject.cpp
)

qt_add_executable(${APP_TARGET} ${APP_SOURCES})

# ── Link libmpv based on platform ──

if(ANDROID)
    if(NOT ANDROID_ABI)
        message(FATAL_ERROR "ANDROID_ABI must be specified for Android builds.")
    endif()

    set(MPV_ROOT "${LIBS_DIR}/android/${ANDROID_ABI}")
    set(MPV_INC "${MPV_ROOT}/include")
    set(MPV_LIB "${MPV_ROOT}/lib/libmpv.so")

    if(EXISTS "${MPV_LIB}")
        add_library(mpv SHARED IMPORTED)
        set_target_properties(mpv PROPERTIES
            IMPORTED_LOCATION "${MPV_LIB}"
            INTERFACE_INCLUDE_DIRECTORIES "${MPV_INC}"
        )
        # Force Qt to pack the .so inside the Android APK
        set_target_properties(${APP_TARGET} PROPERTIES
            QT_ANDROID_EXTRA_LIBS "${MPV_LIB}"
        )
    endif()

elseif(WIN32)
    # Check if building 64-bit or 32-bit
    if(CMAKE_SIZEOF_VOID_P EQUAL 8)
        set(MPV_ROOT "${LIBS_DIR}/windows/x86_64")
    else()
        set(MPV_ROOT "${LIBS_DIR}/windows/x86")
    endif()

    set(MPV_INC "${MPV_ROOT}/include")
    set(MPV_BIN "${MPV_ROOT}/bin")
    set(MPV_LIB_DIR "${MPV_ROOT}/lib")

    # Find the link library (.lib or .a) and the runtime DLL (.dll)
    find_file(MPV_DLL_PATH
        NAMES libmpv-2.dll mpv-2.dll libmpv.dll mpv.dll
        PATHS "${MPV_BIN}" "${MPV_LIB_DIR}"
        NO_DEFAULT_PATH
    )
    find_library(MPV_IMPORT_LIB
        NAMES mpv libmpv
        PATHS "${MPV_LIB_DIR}" "${MPV_BIN}"
        NO_DEFAULT_PATH
    )

    if(MPV_DLL_PATH AND MPV_IMPORT_LIB)
        add_library(mpv SHARED IMPORTED)
        set_target_properties(mpv PROPERTIES
            IMPORTED_LOCATION "${MPV_DLL_PATH}"
            IMPORTED_IMPLIB "${MPV_IMPORT_LIB}"
            INTERFACE_INCLUDE_DIRECTORIES "${MPV_INC}"
        )

        # Copy the DLL to the folder containing the compiled executable
        add_custom_command(TARGET ${APP_TARGET} POST_BUILD
            COMMAND ${CMAKE_COMMAND} -E copy_if_different
                "${MPV_DLL_PATH}" "$<TARGET_FILE_DIR:${APP_TARGET}>"
            COMMENT "Copying libmpv DLL to build folder"
        )
    else()
        message(WARNING "libmpv libraries not found in ${MPV_ROOT}")
    endif()

else()
    # On Linux, look for the system-installed package via pkg-config
    find_package(PkgConfig REQUIRED)
    pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv)
    if(TARGET PkgConfig::MPV)
        add_library(mpv INTERFACE IMPORTED)
        target_link_libraries(mpv INTERFACE PkgConfig::MPV)
    endif()
endif()

# ── Link Targets ──

target_link_libraries(${APP_TARGET} PRIVATE
    Qt6::Core
    Qt6::Gui
    Qt6::Quick
    Qt6::OpenGL
    mpv
)

The C++ Core Integration

Qt Quick runs its rendering on a dedicated thread. To prevent blocking the main GUI thread while drawing frames, we split our implementation into two classes:

  1. MpvObject (lives on the GUI thread) — manages the event loop, options, and parameters.
  2. MpvRenderer (lives on the Render thread) — creates the OpenGL context and draws frames directly to the FBO.

src/player/mpvobject.h

#ifndef MPVOBJECT_H
#define MPVOBJECT_H

#include <QQuickFramebufferObject>
#include <QElapsedTimer>
#include <QVariant>
#include <memory>
#include <atomic>

#include <mpv/client.h>
#include <mpv/render_gl.h>

// A thread-safe bridge to indicate whether the MpvObject is alive.
// Since the rendering thread destructs asynchronously, checking
// this prevents null dereferencing or double-frees.
struct MpvLifetimeState {
    std::atomic<class MpvObject*> obj{nullptr};
};

class MpvObject : public QQuickFramebufferObject
{
    Q_OBJECT
    QML_ELEMENT

    Q_PROPERTY(double position READ getPosition NOTIFY positionChanged)
    Q_PROPERTY(double duration READ getDuration NOTIFY durationChanged)
    Q_PROPERTY(bool playing READ isPlaying WRITE setPlaying NOTIFY playingChanged)
    Q_PROPERTY(int volume READ getVolume WRITE setVolume NOTIFY volumeChanged)

public:
    explicit MpvObject(QQuickItem *parent = nullptr);
    ~MpvObject() override;

    Renderer *createRenderer() const override;

    std::shared_ptr<MpvLifetimeState> lifetime() const { return m_lifetime; }
    void triggerWakeup();

    double getPosition() const { return m_position; }
    double getDuration() const { return m_duration; }
    bool isPlaying() const { return m_playing; }
    int getVolume() const { return m_volume; }

    void setPlaying(bool play);
    void setVolume(int volume);

    mpv_handle *mpv = nullptr;
    mpv_render_context *mpv_gl = nullptr;
    std::atomic<bool> m_renderShouldSkip{true};

public slots:
    void loadFile(const QString &path);
    void seek(double seconds);

signals:
    void positionChanged();
    void durationChanged();
    void playingChanged();
    void volumeChanged();
    void playbackError(const QString &reason);
    void fileLoaded();

private slots:
    void handleMpvEvents();
    void doUpdate();
    void onRendererReady();

private:
    void processMpvEvent(mpv_event *event);
    void emitPositionThrottled(double newPos);

    double m_position = 0.0;
    double m_duration = 0.0;
    bool m_playing = false;
    int m_volume = 100;

    // Used to throttle positioning events
    QElapsedTimer m_posTimer;
    qint64 m_lastPosEmitMs = -1;
    static constexpr qint64 kPosEmitIntervalMs = 50; // Max 20fps for UI updates

    std::atomic<bool> m_wakeupPending{false};
    std::atomic<bool> m_redrawPending{false};
    std::shared_ptr<MpvLifetimeState> m_lifetime;
};

#endif // MPVOBJECT_H

src/player/mpvobject.cpp

#include "mpvobject.h"
#include <QQuickWindow>
#include <QOpenGLContext>
#include <QOpenGLFunctions>
#include <QOpenGLFramebufferObject>
#include <QQuickOpenGLUtils>
#include <QMetaObject>
#include <QDebug>

namespace {

// Lookup OpenGL pointers inside the active Qt context
static void *get_proc_address_mpv(void * /*ctx*/, const char *name)
{
    QOpenGLContext *glctx = QOpenGLContext::currentContext();
    if (!glctx) return nullptr;
    return reinterpret_cast<void *>(glctx->getProcAddress(QByteArray(name)));
}

// Triggers when a property or frame updates on a background thread inside mpv
static void on_mpv_wakeup(void *ctx)
{
    auto *state = static_cast<MpvLifetimeState *>(ctx);
    if (!state) return;
    if (MpvObject *obj = state->obj.load()) {
        obj->triggerWakeup();
    }
}

} // namespace

// ──────────────────────────────────────────────────────────────────
// MpvRenderer (Runs on the Render Thread)
// ──────────────────────────────────────────────────────────────────

class MpvRenderer final : public QQuickFramebufferObject::Renderer
{
public:
    explicit MpvRenderer(MpvObject *obj) 
        : m_lifetime(obj->lifetime()) 
    {}

    ~MpvRenderer() override
    {
        if (m_mpv_gl) {
            mpv_render_context_set_update_callback(m_mpv_gl, nullptr, nullptr);
            mpv_render_context_free(m_mpv_gl);
            m_mpv_gl = nullptr;
            
            if (MpvObject *obj = m_lifetime->obj.load()) {
                obj->mpv_gl = nullptr;
            }
        }
    }

    QOpenGLFramebufferObject *createFramebufferObject(const QSize &size) override
    {
        MpvObject *obj = m_lifetime->obj.load();
        if (!m_mpv_gl && obj && obj->mpv) {
            mpv_opengl_init_params glParams{};
            glParams.get_proc_address = get_proc_address_mpv;
            
            int advancedControl = 1;
            mpv_render_param params[] = {
                { MPV_RENDER_PARAM_API_TYPE, const_cast<char *>(MPV_RENDER_API_TYPE_OPENGL) },
                { MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &glParams },
                { MPV_RENDER_PARAM_ADVANCED_CONTROL, &advancedControl },
                { MPV_RENDER_PARAM_INVALID, nullptr }
            };

            int rc = mpv_render_context_create(&m_mpv_gl, obj->mpv, params);
            if (rc < 0) {
                qWarning() << "[mpv] Failed to create render context:" << mpv_error_string(rc);
                m_mpv_gl = nullptr;
            } else {
                obj->mpv_gl = m_mpv_gl;
                mpv_render_context_set_update_callback(m_mpv_gl, MpvObject::onMpvRedraw, m_lifetime.get());
                QMetaObject::invokeMethod(obj, "onRendererReady", Qt::QueuedConnection);
            }
        }

        QOpenGLFramebufferObjectFormat format;
        format.setAttachment(QOpenGLFramebufferObject::NoAttachment);
        return new QOpenGLFramebufferObject(size, format);
    }

    void render() override
    {
        if (!m_mpv_gl) return;
        QOpenGLFramebufferObject *fbo = framebufferObject();
        if (!fbo) return;

        MpvObject *obj = m_lifetime->obj.load();
        bool skip = obj ? obj->m_renderShouldSkip.load(std::memory_order_acquire) : true;

        if (skip) {
            // Fill background with black while waiting or loading
            QOpenGLFunctions *f = QOpenGLContext::currentContext()->functions();
            if (f) {
                f->glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
                f->glClear(GL_COLOR_BUFFER_BIT);
            }
            return;
        }

        uint64_t flags = mpv_render_context_update(m_mpv_gl);
        bool hasNewFrame = (flags & MPV_RENDER_UPDATE_FRAME) != 0;

        mpv_opengl_fbo mpfbo{};
        mpfbo.fbo = static_cast<int>(fbo->handle());
        mpfbo.w = fbo->width();
        mpfbo.h = fbo->height();
        mpfbo.internal_format = 0;

        int flipY = 0;
        int block = 0;
        mpv_render_param params[] = {
            { MPV_RENDER_PARAM_OPENGL_FBO, &mpfbo },
            { MPV_RENDER_PARAM_FLIP_Y, &flipY },
            { MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, &block },
            { MPV_RENDER_PARAM_INVALID, nullptr }
        };

        // Draw the decoded video frame directly into Qt's FBO texture
        mpv_render_context_render(m_mpv_gl, params);

        if (hasNewFrame) {
            mpv_render_context_report_swap(m_mpv_gl);
        }

        // CRITICAL: Restore the OpenGL state to prevent visual glitches in QML elements
        QQuickOpenGLUtils::resetOpenGLState();
    }

private:
    std::shared_ptr<MpvLifetimeState> m_lifetime;
    mpv_render_context *m_mpv_gl = nullptr;
};

// ──────────────────────────────────────────────────────────────────
// MpvObject (Runs on the GUI Main Thread)
// ──────────────────────────────────────────────────────────────────

void MpvObject::onMpvRedraw(void *ctx)
{
    auto *state = static_cast<MpvLifetimeState *>(ctx);
    if (!state) return;
    MpvObject *obj = state->obj.load();
    if (obj && !obj->m_redrawPending.exchange(true)) {
        QMetaObject::invokeMethod(obj, "doUpdate", Qt::QueuedConnection);
    }
}

MpvObject::MpvObject(QQuickItem *parent)
    : QQuickFramebufferObject(parent)
{
    m_lifetime = std::make_shared<MpvLifetimeState>();
    m_lifetime->obj.store(this);
    m_posTimer.start();

    connect(this, &QQuickItem::windowChanged, this, [this](QQuickWindow *win) {
        if (!win) return;
        win->setPersistentGraphics(true);
        win->setPersistentSceneGraph(true);
        
        connect(win, &QQuickWindow::sceneGraphInvalidated, this, [this] {
            // OpenGL context is dying. Dereference target.
            mpv_gl = nullptr;
            m_renderShouldSkip.store(true, std::memory_order_release);
        }, Qt::DirectConnection);

        connect(win, &QQuickWindow::sceneGraphInitialized, this, [this] {
            update();
        }, Qt::QueuedConnection);
    });

    mpv = mpv_create();
    if (!mpv) {
        emit playbackError(QStringLiteral("mpv_create failed"));
        return;
    }

    // Recommended options for modern cross-platform playback
    mpv_set_option_string(mpv, "vo", "libmpv");
    mpv_set_option_string(mpv, "terminal", "no");
    mpv_set_option_string(mpv, "hwdec", "auto-safe"); // Use hardware acceleration
    mpv_set_option_string(mpv, "vd-lavc-dr", "yes");
    mpv_set_option_string(mpv, "cache", "auto");
    mpv_set_option_string(mpv, "keep-open", "always");

#ifdef Q_OS_ANDROID
    // The "fast" profile drops computationally expensive filters to fit mobile CPUs
    mpv_set_option_string(mpv, "profile", "fast");
#endif

    if (mpv_initialize(mpv) < 0) {
        emit playbackError(QStringLiteral("mpv_initialize failed"));
        mpv_destroy(mpv);
        mpv = nullptr;
        return;
    }

    // Tell mpv we want to watch these properties
    mpv_observe_property(mpv, 0, "time-pos", MPV_FORMAT_DOUBLE);
    mpv_observe_property(mpv, 0, "duration", MPV_FORMAT_DOUBLE);
    mpv_observe_property(mpv, 0, "pause", MPV_FORMAT_FLAG);
    mpv_observe_property(mpv, 0, "volume", MPV_FORMAT_DOUBLE);

    mpv_set_wakeup_callback(mpv, on_mpv_wakeup, m_lifetime.get());
    setFlag(ItemHasContents, true);
}

MpvObject::~MpvObject()
{
    if (m_lifetime) {
        m_lifetime->obj.store(nullptr);
    }
    mpv_gl = nullptr;
    if (mpv) {
        mpv_set_wakeup_callback(mpv, nullptr, nullptr);
        mpv_terminate_destroy(mpv);
        mpv = nullptr;
    }
}

QQuickFramebufferObject::Renderer *MpvObject::createRenderer() const
{
    if (!mpv) return nullptr;
    if (window()) {
        window()->setPersistentGraphics(true);
        window()->setPersistentSceneGraph(true);
    }
    return new MpvRenderer(const_cast<MpvObject *>(this));
}

void MpvObject::triggerWakeup()
{
    if (!m_wakeupPending.exchange(true)) {
        QMetaObject::invokeMethod(this, "handleMpvEvents", Qt::QueuedConnection);
    }
}

void MpvObject::doUpdate()
{
    m_redrawPending.store(false);
    update();
}

void MpvObject::onRendererReady()
{
    m_renderShouldSkip.store(false, std::memory_order_release);
}

void MpvObject::handleMpvEvents()
{
    m_wakeupPending.store(false);
    
    // CRITICAL: Always use a 'while' loop to empty the event pipe completely.
    // Standard 'for (int i=0; i<64; i++)' limit structures drop events,
    // causing playback position and play status desyncs.
    while (mpv) {
        mpv_event *event = mpv_wait_event(mpv, 0);
        if (!event || event->event_id == MPV_EVENT_NONE) break;
        processMpvEvent(event);
    }
}

void MpvObject::processMpvEvent(mpv_event *event)
{
    switch (event->event_id) {
    case MPV_EVENT_FILE_LOADED:
        emit fileLoaded();
        break;

    case MPV_EVENT_PROPERTY_CHANGE: {
        auto *prop = static_cast<mpv_event_property *>(event->data);
        if (!prop->data) break;

        QString name = QString::fromUtf8(prop->name);
        if (name == QStringLiteral("time-pos")) {
            double newPos = *static_cast<double *>(prop->data);
            emitPositionThrottled(newPos);
        } else if (name == QStringLiteral("duration")) {
            m_duration = *static_cast<double *>(prop->data);
            emit durationChanged();
        } else if (name == QStringLiteral("pause")) {
            int flag = *static_cast<int *>(prop->data);
            m_playing = !flag;
            emit playingChanged();
        } else if (name == QStringLiteral("volume")) {
            m_volume = static_cast<int>(*static_cast<double *>(prop->data));
            emit volumeChanged();
        }
        break;
    }
    default:
        break;
    }
}

void MpvObject::emitPositionThrottled(double newPos)
{
    m_position = newPos;
    qint64 now = m_posTimer.elapsed();
    if (m_lastPosEmitMs < 0 || (now - m_lastPosEmitMs) >= kPosEmitIntervalMs) {
        m_lastPosEmitMs = now;
        emit positionChanged();
    }
}

void MpvObject::loadFile(const QString &path)
{
    if (!mpv) return;
    QByteArray utf8Path = path.toUtf8();
    const char *cmd[] = { "loadfile", utf8Path.constData(), "replace", nullptr };
    mpv_command_async(mpv, 0, cmd);
}

void MpvObject::setPlaying(bool play)
{
    if (!mpv) return;
    int flag = play ? 0 : 1;
    mpv_set_property_async(mpv, 0, "pause", MPV_FORMAT_FLAG, &flag);
}

void MpvObject::setVolume(int volume)
{
    if (!mpv) return;
    double vol = volume;
    mpv_set_property_async(mpv, 0, "volume", MPV_FORMAT_DOUBLE, &vol);
}

void MpvObject::seek(double seconds)
{
    if (!mpv) return;
    QByteArray seekVal = QByteArray::number(seconds);
    const char *cmd[] = { "seek", seekVal.constData(), "absolute", nullptr };
    mpv_command_async(mpv, 0, cmd);
}

Forcing OpenGL in main.cpp

Qt 6 defaults to Direct3D on Windows and Metal on macOS. Because libmpv requires an OpenGL context in this implementation, we must call QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL) before constructing the QApplication or QGuiApplication.

#include <QApplication>
#include <QQmlApplicationEngine>
#include <QQuickWindow>
#include <QSGRendererInterface>
#include "player/mpvobject.h"

int main(int argc, char *argv[])
{
    // Enable multi-threaded rendering in the scene graph
    qputenv("QSG_RENDER_LOOP", "threaded");

    // Force Qt Quick to use the OpenGL rendering API
    QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL);

    QApplication app(argc, argv);

    // Register our C++ element so QML recognizes the tag
    qmlRegisterType<MpvObject>("MpvPlayer", 1, 0, "MpvObject");

    QQmlApplicationEngine engine;
    const QUrl url(QStringLiteral("qrc:/Main.qml"));
    
    QObject::connect(&engine, &QQmlApplicationEngine::objectCreationFailed,
        &app, []() { QCoreApplication::exit(-1); },
        Qt::QueuedConnection);
        
    engine.load(url);

    return app.exec();
}

Instantiating the Player in QML

Here is a bare-bones QML layout displaying the video canvas with a play button, a seek slider, and a volume controller.

import QtQuick
import QtQuick.Controls
import MpvPlayer

ApplicationWindow {
    id: window
    visible: true
    width: 1280
    height: 720
    title: "Qt QML + libmpv player"
    color: "#0c0c0e"

    function formatTime(s) {
        var m = Math.floor(s / 60);
        var sec = Math.floor(s % 60);
        return (m < 10 ? "0" : "") + m + ":" + (sec < 10 ? "0" : "") + sec;
    }

    MpvObject {
        id: player
        anchors.fill: parent

        Component.onCompleted: {
            // Load local path or streaming media URLs
            loadFile("https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4")
        }

        onPlaybackError: function(reason) {
            console.error("Mpv Error: " + reason)
        }
    }

    // Controls Panel
    Rectangle {
        anchors.bottom: parent.bottom
        width: parent.width
        height: 72
        color: "#d0000000"

        Row {
            anchors.centerIn: parent
            spacing: 16
            verticalAlignment: Qt.AlignVCenter

            Button {
                text: player.playing ? "Pause" : "Play"
                onClicked: player.playing = !player.playing
            }

            Text {
                color: "#ffffff"
                text: formatTime(player.position) + " / " + formatTime(player.duration)
            }

            Slider {
                width: 480
                from: 0
                to: player.duration
                value: player.position
                live: false // Only seek on drag-release
                onMoved: {
                    player.seek(value)
                }
            }

            Text {
                color: "#ffffff"
                text: "Vol:"
            }

            Slider {
                width: 100
                from: 0
                to: 100
                value: player.volume
                onMoved: {
                    player.volume = Math.round(value)
                }
            }
        }
    }
}

3 Rules for Production Stability

  1. Throttle Property Emits: A high-framerate file updates the playback position up to 120 times a second. Emitting these updates instantly to QML triggers heavy layout updates and degrades UI responsiveness. Use a timer inside emitPositionThrottled to cap telemetry signals.
  2. Listen to sceneGraphInvalidated: If the window minimizes, docks, or undergoes layout resizing, the GPU context can be dropped. Connecting to sceneGraphInvalidated allows you to set the active render handle mpv_gl to null immediately, preventing access violation crashes on the render thread.
  3. Empty the Queue completely: Standard API tutorials fetch events in a limited loop. When a new file begins loading, many property events trigger together. An capped event drain will drop properties, causing the UI state to desync. Always drain the event loop using while (mpv) checks.
H
· 449 words · 3 min read