Qt QML + libmpv: Production-Ready Cross-Platform Video Rendering
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:
MpvObject(lives on the GUI thread) — manages the event loop, options, and parameters.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
- 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
emitPositionThrottledto cap telemetry signals. - Listen to sceneGraphInvalidated: If the window minimizes, docks, or undergoes layout resizing, the GPU context can be dropped. Connecting to
sceneGraphInvalidatedallows you to set the active render handlempv_glto null immediately, preventing access violation crashes on the render thread. - 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.