From 701253f90e8e3e84d541db761e5bfe6e32d00d47 Mon Sep 17 00:00:00 2001 From: CaioDamascenoAlves Date: Fri, 15 May 2026 19:11:51 -0300 Subject: [PATCH 1/3] Add MPRIS2 support on Linux via QtDBus Registers org.mpris.MediaPlayer2.stremio on the session D-Bus, allowing KDE Connect and other MPRIS2 clients to control playback. - mpris.h / mpris.cpp: MprisRootAdaptor (org.mpris.MediaPlayer2) and MprisPlayerAdaptor (org.mpris.MediaPlayer2.Player) using Qt D-Bus adaptors. Observes mpv properties (pause, core-idle, media-title, duration, volume) via mpvEvent signal for real-time PropertiesChanged notifications. Supports PlayPause, Play, Pause, Stop, Seek, SetPosition and Volume read/write. - stremio.pro: compile mpris.cpp on unix/Linux only. - main.cpp: call mprisSetup() after QML engine loads, finding the MpvObject instance via findChild. Closes the long-standing issue where KDE Connect could not control Stremio on Linux (Flatpak or native build) due to missing MPRIS2. --- main.cpp | 18 ++++++ mpris.cpp | 181 ++++++++++++++++++++++++++++++++++++++++++++++++++++ mpris.h | 107 +++++++++++++++++++++++++++++++ stremio.pro | 5 ++ 4 files changed, 311 insertions(+) create mode 100644 mpris.cpp create mode 100644 mpris.h diff --git a/main.cpp b/main.cpp index d571c4eb..2bb957de 100644 --- a/main.cpp +++ b/main.cpp @@ -25,6 +25,10 @@ typedef QApplication Application; #include "screensaver.h" #include "qclipboardproxy.h" +#ifdef Q_OS_LINUX +#include "mpris.h" +#endif + #else #include #endif @@ -106,6 +110,20 @@ int main(int argc, char **argv) engine->load(QUrl(QStringLiteral("qrc:/main.qml"))); +#ifdef Q_OS_LINUX + { + QObject *root = engine->rootObjects().value(0); + if (root) { + MpvObject *mpv = root->findChild(); + if (mpv) { + mprisSetup(mpv); + } else { + qWarning("MPRIS: MpvObject não encontrado na árvore QML"); + } + } + } +#endif + #ifndef Q_OS_MACOS QObject::connect( &app, &SingleApplication::receivedMessage, &app, &MainApp::processMessage ); #endif diff --git a/mpris.cpp b/mpris.cpp new file mode 100644 index 00000000..6e7a9f59 --- /dev/null +++ b/mpris.cpp @@ -0,0 +1,181 @@ +#include "mpris.h" + +#include +#include +#include + +static const char *MPRIS_OBJECT_PATH = "/org/mpris/MediaPlayer2"; +static const char *PLAYER_IFACE = "org.mpris.MediaPlayer2.Player"; +static const char *SERVICE_NAME = "org.mpris.MediaPlayer2.stremio"; + +// ─── MprisRootAdaptor ───────────────────────────────────────────────────────── + +MprisRootAdaptor::MprisRootAdaptor(QObject *parent) + : QDBusAbstractAdaptor(parent) +{} + +void MprisRootAdaptor::Raise() +{ + QQuickItem *item = qobject_cast(parent()); + if (!item) return; + QWindow *win = item->window(); + if (win) { + win->show(); + win->raise(); + win->requestActivate(); + } +} + +// ─── MprisPlayerAdaptor ─────────────────────────────────────────────────────── + +MprisPlayerAdaptor::MprisPlayerAdaptor(MpvObject *player) + : QDBusAbstractAdaptor(player), m_player(player) +{ + // Observa as propriedades mpv relevantes para MPRIS + player->observeProperty("core-idle"); + player->observeProperty("pause"); + player->observeProperty("media-title"); + player->observeProperty("duration"); + player->observeProperty("volume"); + + connect(player, &MpvObject::mpvEvent, + this, &MprisPlayerAdaptor::onMpvEvent); +} + +QString MprisPlayerAdaptor::playbackStatus() const +{ + if (m_idle) return "Stopped"; + if (m_paused) return "Paused"; + return "Playing"; +} + +QVariantMap MprisPlayerAdaptor::metadata() const +{ + QVariantMap map; + map["mpris:trackid"] = QVariant::fromValue( + QDBusObjectPath("/org/stremio/track/0") + ); + if (!m_title.isEmpty()) + map["xesam:title"] = m_title; + if (m_duration > 0.0) + map["mpris:length"] = qlonglong(m_duration * 1e6); + return map; +} + +double MprisPlayerAdaptor::volume() const +{ + return m_player->getProperty("volume").toDouble() / 100.0; +} + +void MprisPlayerAdaptor::setVolume(double v) +{ + m_player->setProperty("volume", v * 100.0); + emitPropertiesChanged({{"Volume", v}}); +} + +qlonglong MprisPlayerAdaptor::position() const +{ + return qlonglong(m_player->getProperty("time-pos").toDouble() * 1e6); +} + +void MprisPlayerAdaptor::PlayPause() +{ + m_player->command(QVariantList() << "cycle" << "pause"); +} + +void MprisPlayerAdaptor::Play() +{ + m_player->setProperty("pause", false); +} + +void MprisPlayerAdaptor::Pause() +{ + m_player->setProperty("pause", true); +} + +void MprisPlayerAdaptor::Stop() +{ + m_player->command(QVariantList() << "stop"); +} + +void MprisPlayerAdaptor::Seek(qlonglong offsetUs) +{ + m_player->command(QVariantList() << "seek" << double(offsetUs) / 1e6 << "relative"); + emit Seeked(position()); +} + +void MprisPlayerAdaptor::SetPosition(const QDBusObjectPath &, qlonglong positionUs) +{ + m_player->setProperty("time-pos", double(positionUs) / 1e6); + emit Seeked(positionUs); +} + +void MprisPlayerAdaptor::onMpvEvent(const QString &event, const QVariant &value) +{ + if (event == "mpv-prop-change") { + QJsonObject obj = value.toJsonObject(); + QString name = obj["name"].toString(); + QVariant data = obj["data"].toVariant(); + + if (name == "core-idle") { + bool idle = data.toBool(); + if (idle != m_idle) { + m_idle = idle; + if (m_idle) { + m_title.clear(); + m_duration = 0.0; + } + emitPropertiesChanged({{"PlaybackStatus", playbackStatus()}}); + } + } else if (name == "pause") { + bool paused = data.toBool(); + if (paused != m_paused) { + m_paused = paused; + emitPropertiesChanged({{"PlaybackStatus", playbackStatus()}}); + } + } else if (name == "media-title") { + m_title = data.toString(); + emitPropertiesChanged({{"Metadata", QVariant::fromValue(metadata())}}); + } else if (name == "duration") { + m_duration = data.toDouble(); + emitPropertiesChanged({{"Metadata", QVariant::fromValue(metadata())}}); + } else if (name == "volume") { + emitPropertiesChanged({{"Volume", data.toDouble() / 100.0}}); + } + } else if (event == "mpv-event-ended") { + m_idle = true; + m_title.clear(); + m_duration = 0.0; + emitPropertiesChanged({{"PlaybackStatus", QString("Stopped")}}); + } +} + +void MprisPlayerAdaptor::emitPropertiesChanged(const QVariantMap &changed) +{ + auto msg = QDBusMessage::createSignal( + MPRIS_OBJECT_PATH, + "org.freedesktop.DBus.Properties", + "PropertiesChanged" + ); + msg << QString(PLAYER_IFACE) << changed << QStringList(); + QDBusConnection::sessionBus().send(msg); +} + +// ─── Setup ──────────────────────────────────────────────────────────────────── + +void mprisSetup(MpvObject *player) +{ + new MprisRootAdaptor(player); + new MprisPlayerAdaptor(player); + + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.registerObject(MPRIS_OBJECT_PATH, player)) { + qWarning("MPRIS: falha ao registrar objeto D-Bus em %s", MPRIS_OBJECT_PATH); + return; + } + if (!bus.registerService(SERVICE_NAME)) { + qWarning("MPRIS: falha ao registrar serviço %s", SERVICE_NAME); + return; + } + qDebug("MPRIS: registrado com sucesso — KDE Connect pode controlar o Stremio"); +} diff --git a/mpris.h b/mpris.h new file mode 100644 index 00000000..a259a9b9 --- /dev/null +++ b/mpris.h @@ -0,0 +1,107 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "mpv.h" + +// org.mpris.MediaPlayer2 — interface raiz (identidade do player) +class MprisRootAdaptor : public QDBusAbstractAdaptor { + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.mpris.MediaPlayer2") + Q_PROPERTY(bool CanQuit READ canQuit) + Q_PROPERTY(bool CanRaise READ canRaise) + Q_PROPERTY(bool HasTrackList READ hasTrackList) + Q_PROPERTY(QString Identity READ identity) + Q_PROPERTY(QString DesktopEntry READ desktopEntry) + Q_PROPERTY(QStringList SupportedUriSchemes READ supportedUriSchemes) + Q_PROPERTY(QStringList SupportedMimeTypes READ supportedMimeTypes) + +public: + explicit MprisRootAdaptor(QObject *parent); + + bool canQuit() const { return false; } + bool canRaise() const { return true; } + bool hasTrackList() const { return false; } + QString identity() const { return "Stremio"; } + QString desktopEntry() const { return "stremio"; } + QStringList supportedUriSchemes() const { return {}; } + QStringList supportedMimeTypes() const { return {}; } + +public slots: + void Raise(); + void Quit() {} +}; + +// org.mpris.MediaPlayer2.Player — controle de playback +class MprisPlayerAdaptor : public QDBusAbstractAdaptor { + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.mpris.MediaPlayer2.Player") + Q_PROPERTY(QString PlaybackStatus READ playbackStatus) + Q_PROPERTY(QString LoopStatus READ loopStatus) + Q_PROPERTY(double Rate READ rate) + Q_PROPERTY(bool Shuffle READ shuffle) + Q_PROPERTY(QVariantMap Metadata READ metadata) + Q_PROPERTY(double Volume READ volume WRITE setVolume) + Q_PROPERTY(qlonglong Position READ position) + Q_PROPERTY(double MinimumRate READ minimumRate) + Q_PROPERTY(double MaximumRate READ maximumRate) + Q_PROPERTY(bool CanGoNext READ canGoNext) + Q_PROPERTY(bool CanGoPrevious READ canGoPrevious) + Q_PROPERTY(bool CanPlay READ canPlay) + Q_PROPERTY(bool CanPause READ canPause) + Q_PROPERTY(bool CanSeek READ canSeek) + Q_PROPERTY(bool CanControl READ canControl) + +public: + explicit MprisPlayerAdaptor(MpvObject *player); + + QString playbackStatus() const; + QString loopStatus() const { return "None"; } + double rate() const { return 1.0; } + bool shuffle() const { return false; } + QVariantMap metadata() const; + double volume() const; + void setVolume(double v); + qlonglong position() const; + double minimumRate() const { return 1.0; } + double maximumRate() const { return 1.0; } + bool canGoNext() const { return false; } + bool canGoPrevious() const { return false; } + bool canPlay() const { return true; } + bool canPause() const { return true; } + bool canSeek() const { return true; } + bool canControl() const { return true; } + +public slots: + void PlayPause(); + void Play(); + void Pause(); + void Stop(); + void Next() {} + void Previous() {} + void Seek(qlonglong offsetUs); + void SetPosition(const QDBusObjectPath &trackId, qlonglong positionUs); + void OpenUri(const QString &uri) { Q_UNUSED(uri) } + +signals: + void Seeked(qlonglong positionUs); + +private slots: + void onMpvEvent(const QString &event, const QVariant &value); + +private: + void emitPropertiesChanged(const QVariantMap &changed); + + MpvObject *m_player; + bool m_idle = true; + bool m_paused = false; + QString m_title; + double m_duration = 0.0; +}; + +void mprisSetup(MpvObject *player); diff --git a/stremio.pro b/stremio.pro index 5a2f36a6..69f249b8 100644 --- a/stremio.pro +++ b/stremio.pro @@ -70,6 +70,11 @@ SOURCES += main.cpp \ qclipboardproxy.cpp \ verifysig.c +unix:!mac { + SOURCES += mpris.cpp + HEADERS += mpris.h +} + RESOURCES += qml.qrc # Additional import path used to resolve QML modules in Qt Creator's code model From 379473d38a3360387dd92f5023572cf083777f23 Mon Sep 17 00:00:00 2001 From: CaioDamascenoAlves Date: Fri, 15 May 2026 19:16:42 -0300 Subject: [PATCH 2/3] mpris: style fixes before PR - Replace #pragma once with #ifndef include guard to match project style - Remove from header (only needed in .cpp) - Use QQuickWindow* directly in Raise() instead of casting to QWindow* - Add null check for QQuickWindow in Raise() --- mpris.cpp | 11 +++++------ mpris.h | 6 ++++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/mpris.cpp b/mpris.cpp index 6e7a9f59..2f7e1219 100644 --- a/mpris.cpp +++ b/mpris.cpp @@ -18,12 +18,11 @@ void MprisRootAdaptor::Raise() { QQuickItem *item = qobject_cast(parent()); if (!item) return; - QWindow *win = item->window(); - if (win) { - win->show(); - win->raise(); - win->requestActivate(); - } + QQuickWindow *win = item->window(); + if (!win) return; + win->show(); + win->raise(); + win->requestActivate(); } // ─── MprisPlayerAdaptor ─────────────────────────────────────────────────────── diff --git a/mpris.h b/mpris.h index a259a9b9..01172e04 100644 --- a/mpris.h +++ b/mpris.h @@ -1,11 +1,11 @@ -#pragma once +#ifndef MPRIS_H +#define MPRIS_H #include #include #include #include #include -#include #include "mpv.h" @@ -105,3 +105,5 @@ private slots: }; void mprisSetup(MpvObject *player); + +#endif // MPRIS_H From 16dbd8685b724a2413cc212b7ae570aa81d227e7 Mon Sep 17 00:00:00 2001 From: CaioDamascenoAlves Date: Sun, 17 May 2026 14:07:56 -0300 Subject: [PATCH 3/3] Address code review feedback on MPRIS2 implementation - Use idle-active instead of core-idle (core-idle is true while paused) - Track ID is now unique per media item using an incrementing sequence counter - SetPosition validates the track ID to ignore stale seek requests - Emit Metadata alongside PlaybackStatus when clearing state on stop/idle - CMakeLists.txt: include mpris.cpp for UNIX non-Apple builds - DesktopEntry matches the installed .desktop filename (smartcode-stremio) - Translate log messages to English for consistency with the codebase --- CMakeLists.txt | 4 ++++ main.cpp | 2 +- mpris.cpp | 31 +++++++++++++++++++++---------- mpris.h | 9 +++++---- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6cf1c5e2..ca07e8ca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -41,6 +41,10 @@ if(WIN32) set(MPV_LIBRARY_mpv ${CMAKE_CURRENT_SOURCE_DIR}/deps/libmpv/win32/mpv.lib) endif() +if(UNIX AND NOT APPLE) + list(APPEND SOURCES mpris.cpp) +endif() + if(APPLE) list(APPEND SOURCES images/stremio.icns) set_source_files_properties(images/stremio.icns PROPERTIES MACOSX_PACKAGE_LOCATION "Resources") diff --git a/main.cpp b/main.cpp index 2bb957de..8953ba1c 100644 --- a/main.cpp +++ b/main.cpp @@ -118,7 +118,7 @@ int main(int argc, char **argv) if (mpv) { mprisSetup(mpv); } else { - qWarning("MPRIS: MpvObject não encontrado na árvore QML"); + qWarning("MPRIS: MpvObject not found in QML object tree"); } } } diff --git a/mpris.cpp b/mpris.cpp index 2f7e1219..d3a2f70b 100644 --- a/mpris.cpp +++ b/mpris.cpp @@ -30,8 +30,7 @@ void MprisRootAdaptor::Raise() MprisPlayerAdaptor::MprisPlayerAdaptor(MpvObject *player) : QDBusAbstractAdaptor(player), m_player(player) { - // Observa as propriedades mpv relevantes para MPRIS - player->observeProperty("core-idle"); + player->observeProperty("idle-active"); player->observeProperty("pause"); player->observeProperty("media-title"); player->observeProperty("duration"); @@ -52,7 +51,7 @@ QVariantMap MprisPlayerAdaptor::metadata() const { QVariantMap map; map["mpris:trackid"] = QVariant::fromValue( - QDBusObjectPath("/org/stremio/track/0") + QDBusObjectPath(QString("/org/stremio/track/%1").arg(m_trackSeq)) ); if (!m_title.isEmpty()) map["xesam:title"] = m_title; @@ -103,8 +102,11 @@ void MprisPlayerAdaptor::Seek(qlonglong offsetUs) emit Seeked(position()); } -void MprisPlayerAdaptor::SetPosition(const QDBusObjectPath &, qlonglong positionUs) +void MprisPlayerAdaptor::SetPosition(const QDBusObjectPath &trackId, qlonglong positionUs) { + // Ignore stale requests targeting a previous track + QString expected = QString("/org/stremio/track/%1").arg(m_trackSeq); + if (trackId.path() != expected) return; m_player->setProperty("time-pos", double(positionUs) / 1e6); emit Seeked(positionUs); } @@ -116,15 +118,20 @@ void MprisPlayerAdaptor::onMpvEvent(const QString &event, const QVariant &value) QString name = obj["name"].toString(); QVariant data = obj["data"].toVariant(); - if (name == "core-idle") { + if (name == "idle-active") { bool idle = data.toBool(); if (idle != m_idle) { m_idle = idle; if (m_idle) { m_title.clear(); m_duration = 0.0; + emitPropertiesChanged({ + {"PlaybackStatus", playbackStatus()}, + {"Metadata", QVariant::fromValue(metadata())} + }); + } else { + emitPropertiesChanged({{"PlaybackStatus", playbackStatus()}}); } - emitPropertiesChanged({{"PlaybackStatus", playbackStatus()}}); } } else if (name == "pause") { bool paused = data.toBool(); @@ -134,6 +141,7 @@ void MprisPlayerAdaptor::onMpvEvent(const QString &event, const QVariant &value) } } else if (name == "media-title") { m_title = data.toString(); + m_trackSeq++; emitPropertiesChanged({{"Metadata", QVariant::fromValue(metadata())}}); } else if (name == "duration") { m_duration = data.toDouble(); @@ -145,7 +153,10 @@ void MprisPlayerAdaptor::onMpvEvent(const QString &event, const QVariant &value) m_idle = true; m_title.clear(); m_duration = 0.0; - emitPropertiesChanged({{"PlaybackStatus", QString("Stopped")}}); + emitPropertiesChanged({ + {"PlaybackStatus", QString("Stopped")}, + {"Metadata", QVariant::fromValue(metadata())} + }); } } @@ -169,12 +180,12 @@ void mprisSetup(MpvObject *player) QDBusConnection bus = QDBusConnection::sessionBus(); if (!bus.registerObject(MPRIS_OBJECT_PATH, player)) { - qWarning("MPRIS: falha ao registrar objeto D-Bus em %s", MPRIS_OBJECT_PATH); + qWarning("MPRIS: failed to register D-Bus object at %s", MPRIS_OBJECT_PATH); return; } if (!bus.registerService(SERVICE_NAME)) { - qWarning("MPRIS: falha ao registrar serviço %s", SERVICE_NAME); + qWarning("MPRIS: failed to register service %s", SERVICE_NAME); return; } - qDebug("MPRIS: registrado com sucesso — KDE Connect pode controlar o Stremio"); + qDebug("MPRIS: registered successfully — media controllers can now control Stremio"); } diff --git a/mpris.h b/mpris.h index 01172e04..4efd7224 100644 --- a/mpris.h +++ b/mpris.h @@ -28,7 +28,7 @@ class MprisRootAdaptor : public QDBusAbstractAdaptor { bool canRaise() const { return true; } bool hasTrackList() const { return false; } QString identity() const { return "Stremio"; } - QString desktopEntry() const { return "stremio"; } + QString desktopEntry() const { return "smartcode-stremio"; } QStringList supportedUriSchemes() const { return {}; } QStringList supportedMimeTypes() const { return {}; } @@ -98,10 +98,11 @@ private slots: void emitPropertiesChanged(const QVariantMap &changed); MpvObject *m_player; - bool m_idle = true; - bool m_paused = false; + bool m_idle = true; + bool m_paused = false; QString m_title; - double m_duration = 0.0; + double m_duration = 0.0; + int m_trackSeq = 0; }; void mprisSetup(MpvObject *player);