From 00dfb70e1b59b1a491b3d74e4f5010de9bdec71f Mon Sep 17 00:00:00 2001 From: Andrew Rabert Date: Sun, 23 Nov 2025 14:04:54 -0500 Subject: [PATCH] Add MPRIS support --- native/find-webclient.js | 2 + native/jmpInputPlugin.js | 338 +++++++- native/mpvAudioPlayer.js | 6 +- native/mpvVideoPlayer.js | 35 +- native/nativeshell.js | 20 + src/CMakeLists.txt | 1 + src/core/ComponentManager.cpp | 2 + src/input/InputComponent.cpp | 21 + src/input/InputComponent.h | 14 + src/main.cpp | 8 +- src/mpris/CMakeLists.txt | 3 + src/mpris/MprisComponent.cpp | 1274 ++++++++++++++++++++++++++++++ src/mpris/MprisComponent.h | 164 ++++ src/mpris/MprisPlayerAdaptor.cpp | 150 ++++ src/mpris/MprisPlayerAdaptor.h | 77 ++ src/mpris/MprisRootAdaptor.cpp | 68 ++ src/mpris/MprisRootAdaptor.h | 50 ++ src/player/PlayerComponent.cpp | 95 ++- src/player/PlayerComponent.h | 43 +- src/ui/WindowManager.cpp | 2 + src/ui/WindowManager.h | 3 + 21 files changed, 2340 insertions(+), 36 deletions(-) create mode 100644 src/mpris/CMakeLists.txt create mode 100644 src/mpris/MprisComponent.cpp create mode 100644 src/mpris/MprisComponent.h create mode 100644 src/mpris/MprisPlayerAdaptor.cpp create mode 100644 src/mpris/MprisPlayerAdaptor.h create mode 100644 src/mpris/MprisRootAdaptor.cpp create mode 100644 src/mpris/MprisRootAdaptor.h diff --git a/native/find-webclient.js b/native/find-webclient.js index 53b03d9..a89ef88 100644 --- a/native/find-webclient.js +++ b/native/find-webclient.js @@ -140,6 +140,8 @@ document.addEventListener('keydown', (e) => { (async () => { console.log('Auto-connect: starting'); + await window.apiPromise; + const savedServer = window.jmpInfo.settings.main.userWebClient; console.log('Auto-connect: savedServer =', savedServer); diff --git a/native/jmpInputPlugin.js b/native/jmpInputPlugin.js index bb61e0b..883660e 100644 --- a/native/jmpInputPlugin.js +++ b/native/jmpInputPlugin.js @@ -19,38 +19,346 @@ class jmpInputPlugin { this.type = 'input'; this.id = 'jmpInputPlugin'; + this.durationCheckInterval = null; + this.positionUpdateInterval = null; + this.attachedPlayer = null; + (async () => { const api = await window.apiPromise; api.input.hostInput.connect((actions) => { actions.forEach(action => { - if (remap.hasOwnProperty(action)) { - action = remap[action]; + if (action === 'shuffle') { + playbackManager.setQueueShuffleMode('Shuffle'); + } else if (action === 'sorted') { + playbackManager.setQueueShuffleMode('Sorted'); + } else if (action === 'previous') { + const currentPlayer = playbackManager._currentPlayer; + if (currentPlayer && playbackManager.isPlayingAudio(currentPlayer)) { + const currentTime = playbackManager.currentTime(currentPlayer); + const currentIndex = playbackManager.getCurrentPlaylistIndex(currentPlayer); + + if (currentTime >= 5 * 1000 || currentIndex <= 0) { + playbackManager.seekPercent(0, currentPlayer); + } else { + playbackManager.previousTrack(currentPlayer); + } + } else if (currentPlayer) { + playbackManager.previousTrack(currentPlayer); + } + } else { + if (remap.hasOwnProperty(action)) { + action = remap[action]; + } + inputManager.handleCommand(action, {}); } - inputManager.handleCommand(action, {}); }); }); - // Listen for fullscreen setting changes and trigger fullscreenchange event - let lastFullscreenState = window.jmpInfo.settings.main.fullscreen; - window.jmpInfo.settingsUpdate.push(function(section) { - if (section === 'main') { - const currentFullscreenState = window.jmpInfo.settings.main.fullscreen; - if (currentFullscreenState !== lastFullscreenState) { - lastFullscreenState = currentFullscreenState; - console.log('[jmpInputPlugin] Fullscreen setting changed, triggering fullscreenchange event'); - const currentPlayer = playbackManager._currentPlayer; - if (currentPlayer) { - window.Events.trigger(currentPlayer, 'fullscreenchange'); + const updateQueueState = function() { + try { + if (!api || !api.player) { + return; + } + + const playlist = playbackManager._playQueueManager?.getPlaylist(); + if (!playlist || !Array.isArray(playlist)) { + return; + } + + const currentIndex = playbackManager._playQueueManager?.getCurrentPlaylistIndex(); + if (currentIndex === undefined || currentIndex === null || currentIndex < 0) { + return; + } + + const canNext = currentIndex < playlist.length - 1; + + const state = playbackManager.getPlayerState(); + const isMusic = state?.NowPlayingItem?.MediaType === 'Audio'; + const canPrevious = isMusic ? true : currentIndex > 0; + + api.player.notifyQueueChange(canNext, canPrevious); + } catch (e) { + console.error('PlayerMedia: Error in updateQueueState:', e); + } + }; + + let globalListenersAttached = false; + let lastReportedPosition = 0; + + if (!globalListenersAttached) { + let lastFullscreenState = window.jmpInfo.settings.main.fullscreen; + window.jmpInfo.settingsUpdate.push(function(section) { + if (section === 'main') { + const currentFullscreenState = window.jmpInfo.settings.main.fullscreen; + if (currentFullscreenState !== lastFullscreenState) { + lastFullscreenState = currentFullscreenState; + const currentPlayer = playbackManager._currentPlayer; + if (currentPlayer) { + window.Events.trigger(currentPlayer, 'fullscreenchange'); + } } } + }); + + if (window.api && window.api.player) { + window.api.player.playbackRateChanged.connect(function(rate) { + const currentPlayer = playbackManager._currentPlayer; + + if (currentPlayer && currentPlayer._playRate !== undefined) { + currentPlayer._playRate = rate; + } + + if (window.Events && currentPlayer) { + window.Events.trigger(currentPlayer, 'ratechange'); + } + }); + } else { + console.log('SystemMedia: Cannot attach playbackRateChanged listener - api:', !!window.api, 'player:', !!window.api?.player); + } + + if (window.api && window.api.input) { + window.api.input.volumeChanged.connect(function(volume) { + const currentPlayer = playbackManager._currentPlayer; + if (currentPlayer && typeof currentPlayer.setVolume === 'function') { + currentPlayer.setVolume(volume); + } + }); + + window.api.input.rateChanged.connect(function(rate) { + const currentPlayer = playbackManager._currentPlayer; + if (currentPlayer && typeof currentPlayer.setPlaybackRate === 'function') { + currentPlayer.setPlaybackRate(rate); + } + }); + + // WORKAROUND: MPRIS spec has no "Buffering" playback state + // During seek buffering, we set Rate to 0.0 to stop MPRIS clients from + // auto-incrementing position. Once buffering completes, we restore Rate to 1.0. + + window.api.input.positionSeek.connect(function(positionMs) { + const currentPlayer = playbackManager._currentPlayer; + if (currentPlayer) { + const duration = playbackManager.duration(); + if (duration) { + const percent = (positionMs * 10000) / duration * 100; + playbackManager.seekPercent(percent, currentPlayer); + } + } + + api.player.notifyPosition(Math.floor(positionMs)); + lastReportedPosition = positionMs; + + api.player.notifyRateChange(0.0); + }); + } + + window.Events.on(playbackManager, 'playlistitemremove', updateQueueState); + window.Events.on(playbackManager, 'playlistitemadd', updateQueueState); + window.Events.on(playbackManager, 'playlistitemchange', updateQueueState); + + window.Events.on(playbackManager, 'playbackstop', function(e, playbackStopInfo) { + updateQueueState(); + + const isNavigating = !!(playbackStopInfo && playbackStopInfo.nextMediaType); + api.player.notifyPlaybackStop(isNavigating); + }); + + globalListenersAttached = true; + } + + window.Events.on(playbackManager, 'playbackstart', (e, player) => { + if (!player) return; + + const state = playbackManager.getPlayerState(); + if (state && state.NowPlayingItem) { + let serverAddress = ''; + if (window.ApiClient && typeof window.ApiClient.serverAddress === 'function') { + serverAddress = window.ApiClient.serverAddress(); + } + + api.player.notifyMetadata(state.NowPlayingItem, serverAddress || ''); + + const initialPos = playbackManager.currentTime(); + if (initialPos !== undefined && initialPos !== null) { + api.player.notifyPosition(Math.floor(initialPos)); + lastReportedPosition = initialPos; + } + + if (player && typeof player.getVolume === 'function') { + const volume = player.getVolume(); + api.player.notifyVolumeChange(volume / 100.0); + } + } + + let lastDuration = 0; + const checkDuration = function() { + const duration = playbackManager.duration(); + if (duration && duration !== lastDuration) { + lastDuration = duration; + const durationMs = Math.floor(duration / 10000); + api.player.notifyDurationChange(durationMs); + } + }; + + if (player !== this.attachedPlayer) { + if (this.attachedPlayer) { + window.Events.off(this.attachedPlayer, 'shufflequeuemodechange'); + window.Events.off(this.attachedPlayer, 'repeatmodechange'); + window.Events.off(this.attachedPlayer, 'playing'); + window.Events.off(this.attachedPlayer, 'pause'); + window.Events.off(this.attachedPlayer, 'playbackstop'); + window.Events.off(this.attachedPlayer, 'volumechange'); + window.Events.off(this.attachedPlayer, 'ratechange'); + window.Events.off(this.attachedPlayer, 'timeupdate'); + } + + this.attachedPlayer = player; + + window.Events.on(player, 'shufflequeuemodechange', () => { + const mode = playbackManager.getQueueShuffleMode(); + const enabled = (mode === 'Shuffle'); + api.player.notifyShuffleChange(enabled); + }); + + window.Events.on(player, 'repeatmodechange', () => { + const mode = playbackManager.getRepeatMode(); + api.player.notifyRepeatChange(mode); + }); + + window.Events.on(player, 'playing', () => { + api.player.notifyPlaybackState('Playing'); + + updateQueueState(); + + api.player.notifyRateChange(1.0); + + if (this.durationCheckInterval) { + clearInterval(this.durationCheckInterval); + } + checkDuration(); + this.durationCheckInterval = setInterval(checkDuration, 1000); + + const initialPos = playbackManager.currentTime(); + if (initialPos !== undefined && initialPos !== null) { + api.player.notifyPosition(Math.floor(initialPos)); + } + + if (this.positionUpdateInterval) { + clearInterval(this.positionUpdateInterval); + } + this.positionUpdateInterval = setInterval(() => { + const positionMs = playbackManager.currentTime(); + if (positionMs !== undefined && positionMs !== null) { + const positionDiff = Math.abs(positionMs - lastReportedPosition); + if (lastReportedPosition > 0 && positionDiff > 2000) { + api.player.notifyRateChange(0.0); + api.player.notifySeek(Math.floor(positionMs)); + } else { + api.player.notifyPosition(Math.floor(positionMs)); + } + lastReportedPosition = positionMs; + } + }, 500); + }); + + window.Events.on(player, 'pause', () => { + api.player.notifyPlaybackState('Paused'); + api.player.notifyRateChange(0.0); + + if (this.durationCheckInterval) { + clearInterval(this.durationCheckInterval); + this.durationCheckInterval = null; + } + + const currentPos = playbackManager.currentTime(); + if (currentPos !== undefined && currentPos !== null) { + lastReportedPosition = currentPos; + } + }); + + window.Events.on(player, 'playbackstop', () => { + if (this.durationCheckInterval) { + clearInterval(this.durationCheckInterval); + this.durationCheckInterval = null; + } + + if (this.positionUpdateInterval) { + clearInterval(this.positionUpdateInterval); + this.positionUpdateInterval = null; + } + + lastDuration = 0; + lastReportedPosition = 0; + }); + + window.Events.on(player, 'volumechange', () => { + if (player && typeof player.getVolume === 'function') { + const volume = player.getVolume(); + api.player.notifyVolumeChange(volume / 100.0); + } + }); + + window.Events.on(player, 'ratechange', () => { + if (player && typeof player.getPlaybackRate === 'function') { + const rate = player.getPlaybackRate(); + api.player.notifyRateChange(rate); + } + }); + + window.Events.on(player, 'timeupdate', () => { + const positionMs = playbackManager.currentTime(); + if (positionMs !== undefined && positionMs !== null) { + const positionDiff = Math.abs(positionMs - lastReportedPosition); + if (lastReportedPosition > 0 && positionDiff > 2000) { + // Player-initiated seek - set rate to 0 during buffering + api.player.notifyRateChange(0.0); + api.player.notifySeek(Math.floor(positionMs)); + } else if (positionDiff > 100) { + api.player.notifyPosition(Math.floor(positionMs)); + } + lastReportedPosition = positionMs; + } + }); } }); api.system.hello("jmpInputPlugin"); })(); } + + destroy() { + if (this.durationCheckInterval) { + clearInterval(this.durationCheckInterval); + this.durationCheckInterval = null; + } + + if (this.positionUpdateInterval) { + clearInterval(this.positionUpdateInterval); + this.positionUpdateInterval = null; + } + + if (this.attachedPlayer) { + window.Events.off(this.attachedPlayer, 'shufflequeuemodechange'); + window.Events.off(this.attachedPlayer, 'repeatmodechange'); + window.Events.off(this.attachedPlayer, 'playing'); + window.Events.off(this.attachedPlayer, 'pause'); + window.Events.off(this.attachedPlayer, 'playbackstop'); + window.Events.off(this.attachedPlayer, 'volumechange'); + window.Events.off(this.attachedPlayer, 'timeupdate'); + this.attachedPlayer = null; + } + + if (window.playbackManager) { + window.Events.off(window.playbackManager, 'playlistitemremove'); + window.Events.off(window.playbackManager, 'playlistitemadd'); + window.Events.off(window.playbackManager, 'playlistitemchange'); + window.Events.off(window.playbackManager, 'playbackstop'); + } + + console.log('SystemMedia: jmpInputPlugin destroyed and cleaned up'); + } } window._jmpInputPlugin = jmpInputPlugin; -})(); \ No newline at end of file +})(); diff --git a/native/mpvAudioPlayer.js b/native/mpvAudioPlayer.js index b575b0e..991bcc9 100644 --- a/native/mpvAudioPlayer.js +++ b/native/mpvAudioPlayer.js @@ -262,6 +262,10 @@ class mpvAudioPlayer { setPlaybackRate(value) { this._playRate = value; window.api.player.setPlaybackRate(value * 1000); + + if (window.api && window.api.player) { + window.api.player.notifyRateChange(value); + } } getPlaybackRate() { @@ -348,4 +352,4 @@ function getSupportedFeatures() { } window._mpvAudioPlayer = mpvAudioPlayer; -})(); \ No newline at end of file +})(); diff --git a/native/mpvVideoPlayer.js b/native/mpvVideoPlayer.js index ec5dd47..c3154aa 100644 --- a/native/mpvVideoPlayer.js +++ b/native/mpvVideoPlayer.js @@ -612,6 +612,20 @@ return this._supportedFeatures.includes(feature); } + isFullscreen() { + // Check native window fullscreen state + if (window.jmpInfo && window.jmpInfo.settings && window.jmpInfo.settings.main) { + return window.jmpInfo.settings.main.fullscreen === true; + } + return false; + } + + toggleFullscreen() { + if (window.api && window.api.input) { + window.api.input.executeActions(['host:fullscreen']); + } + } + // Save this for when playback stops, because querying the time at that point might return 0 currentTime(val) { if (val != null) { @@ -690,6 +704,10 @@ let playSpeed = +value; //this comes as a string from player force int for now this._playRate = playSpeed; window.api.player.setPlaybackRate(playSpeed * 1000); + + if (window.api && window.api.player) { + window.api.player.notifyRateChange(playSpeed); + } } getPlaybackRate() { @@ -879,22 +897,7 @@ setAspectRatio(value) { window.jmpInfo.settings.video.aspect = value; } - - isFullscreen() { - // Check native window fullscreen state from settings (universal source of truth) - if (window.jmpInfo && window.jmpInfo.settings && window.jmpInfo.settings.main) { - return window.jmpInfo.settings.main.fullscreen === true; - } - return false; - } - - toggleFullscreen() { - // Toggle native window fullscreen via host command - if (window.api && window.api.input) { - window.api.input.executeActions(['host:fullscreen']); - } - } - } +} /* eslint-enable indent */ window._mpvVideoPlayer = mpvVideoPlayer; diff --git a/native/nativeshell.js b/native/nativeshell.js index 73ad2f0..80456ee 100644 --- a/native/nativeshell.js +++ b/native/nativeshell.js @@ -554,3 +554,23 @@ async function showSettingsModal() { }); closeContainer.appendChild(close); } + +let lastFullscreenState = window.jmpInfo.settings.main.fullscreen; + +window.jmpInfo.settingsUpdate.push(function(section) { + if (section === 'main') { + const currentFullscreenState = window.jmpInfo.settings.main.fullscreen; + if (currentFullscreenState !== lastFullscreenState) { + lastFullscreenState = currentFullscreenState; + + if (window.api && window.api.player) { + window.api.player.notifyFullscreenChange(currentFullscreenState); + console.log('Player fullscreen notified'); + } + + if (window.Events && window.playbackManager && window.playbackManager._currentPlayer) { + window.Events.trigger(window.playbackManager._currentPlayer, 'fullscreenchange'); + } + } + } +}); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 43f38bc..fcf21e1 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -22,6 +22,7 @@ add_subdirectory(system) add_subdirectory(settings) add_subdirectory(power) add_subdirectory(taskbar) +add_subdirectory(mpris) get_property(ALL_SRCS GLOBAL PROPERTY SRCS_LIST) set(MAIN_SRCS main.cpp) diff --git a/src/core/ComponentManager.cpp b/src/core/ComponentManager.cpp index 332bf8c..47c2390 100644 --- a/src/core/ComponentManager.cpp +++ b/src/core/ComponentManager.cpp @@ -13,6 +13,7 @@ #include "settings/SettingsComponent.h" #include "taskbar/TaskbarComponent.h" #include "ui/WindowManager.h" +#include "mpris/MprisComponent.h" #if KONVERGO_OPENELEC #include "system/openelec/OESystemComponent.h" @@ -60,6 +61,7 @@ void ComponentManager::initialize() registerComponent(&PowerComponent::Get()); registerComponent(&TaskbarComponent::Get()); registerComponent(&WindowManager::Get()); + registerComponent(&MprisComponent::Get()); #if KONVERGO_OPENELEC registerComponent(&OESystemComponent::Get()); diff --git a/src/input/InputComponent.cpp b/src/input/InputComponent.cpp index 05b82bf..f2a96ee 100644 --- a/src/input/InputComponent.cpp +++ b/src/input/InputComponent.cpp @@ -258,6 +258,27 @@ void InputComponent::sendAction(const QString action) emit hostInput(actionsToSend); } +///////////////////////////////////////////////////////////////////////////////////////// +void InputComponent::setVolume(int volume) +{ + qDebug() << "InputComponent: setVolume" << volume; + emit volumeChanged(volume); +} + +///////////////////////////////////////////////////////////////////////////////////////// +void InputComponent::seekTo(qint64 position) +{ + qDebug() << "InputComponent: seekTo" << position << "ms"; + emit positionSeek(position); +} + +///////////////////////////////////////////////////////////////////////////////////////// +void InputComponent::setRate(double rate) +{ + qDebug() << "InputComponent: setRate" << rate; + emit rateChanged(rate); +} + ///////////////////////////////////////////////////////////////////////////////////////// void InputComponent::registerHostCommand(const QString& command, QObject* receiver, const char* slot) { diff --git a/src/input/InputComponent.h b/src/input/InputComponent.h index 7533910..0b78838 100644 --- a/src/input/InputComponent.h +++ b/src/input/InputComponent.h @@ -108,6 +108,11 @@ public: void cancelAutoRepeat(); void sendAction(const QString action); + // Volume, seek, and rate control + Q_INVOKABLE void setVolume(int volume); + Q_INVOKABLE void seekTo(qint64 position); + Q_INVOKABLE void setRate(double rate); + signals: // Always emitted when any input arrives void receivedInput(); @@ -116,6 +121,15 @@ signals: // in the keymap, such as "host:fullscreen". void hostInput(const QStringList& actions); + // Emitted when volume should be changed (0-100) + void volumeChanged(int volume); + + // Emitted when position should be changed (milliseconds) + void positionSeek(qint64 position); + + // Emitted when playback rate should be changed (1.0 = normal speed) + void rateChanged(double rate); + private Q_SLOTS: void remapInput(const QString& source, const QString& keycode, InputBase::InputkeyState keyState); diff --git a/src/main.cpp b/src/main.cpp index 9ab57d5..235434f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -92,7 +92,8 @@ void ShowLicenseInfo() ///////////////////////////////////////////////////////////////////////////////////////// QStringList g_qtFlags = { - "--enable-gpu-rasterization" + "--enable-gpu-rasterization", + "--disable-features=MediaSessionService" }; ///////////////////////////////////////////////////////////////////////////////////////// @@ -238,6 +239,11 @@ int main(int argc, char *argv[]) webEngineDataDir = d.absolutePath() + "/QtWebEngine"; } +#ifdef Q_OS_LINUX + // Disable QtWebEngine's automatic MPRIS registration - we handle it ourselves + qputenv("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-features=MediaSessionService,HardwareMediaKeyHandling"); +#endif + QtWebEngineQuick::initialize(); QApplication app(newArgc, newArgv); diff --git a/src/mpris/CMakeLists.txt b/src/mpris/CMakeLists.txt new file mode 100644 index 0000000..2e26b0a --- /dev/null +++ b/src/mpris/CMakeLists.txt @@ -0,0 +1,3 @@ +add_sources(MprisComponent.cpp MprisComponent.h) +add_sources(MprisRootAdaptor.cpp MprisRootAdaptor.h) +add_sources(MprisPlayerAdaptor.cpp MprisPlayerAdaptor.h) diff --git a/src/mpris/MprisComponent.cpp b/src/mpris/MprisComponent.cpp new file mode 100644 index 0000000..5eb3920 --- /dev/null +++ b/src/mpris/MprisComponent.cpp @@ -0,0 +1,1274 @@ +#include "MprisComponent.h" +#include "MprisRootAdaptor.h" +#include "MprisPlayerAdaptor.h" +#include "player/PlayerComponent.h" +#include "input/InputComponent.h" +#include "core/Globals.h" +#include "ui/WindowManager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MPRIS_SERVICE_NAME "org.mpris.MediaPlayer2.jellyfinmediaplayer" +#define MPRIS_OBJECT_PATH "/org/mpris/MediaPlayer2" + +MprisComponent::MprisComponent(QObject* parent) + : ComponentBase(parent) + , m_enabled(false) + , m_player(nullptr) + , m_playbackStatus("Stopped") + , m_position(0) + , m_duration(0) + , m_positionTimer(nullptr) + , m_canGoNext(false) + , m_canGoPrevious(false) + , m_shuffle(false) + , m_loopStatus("None") + , m_rate(1.0) + , m_volume(1.0) + , m_seekPending(false) + , m_expectedPosition(0) + , m_isNavigating(false) + , m_albumArtDir("/tmp/jellyfin-mpris") + , m_playerState(PlayerComponent::State::finished) + , m_albumArtManager(nullptr) + , m_pendingArtReply(nullptr) +{ + // Create album art directory + QDir dir; + dir.mkpath(m_albumArtDir); + + m_albumArtManager = new QNetworkAccessManager(this); +} + +MprisComponent::~MprisComponent() +{ + if (m_enabled) + { + disconnectPlayerSignals(); + cleanupAlbumArt(); + QDBusConnection::sessionBus().unregisterService(MPRIS_SERVICE_NAME); + QDBusConnection::sessionBus().unregisterObject(MPRIS_OBJECT_PATH); + } +} + +bool MprisComponent::componentInitialize() +{ + if (!QDBusConnection::sessionBus().isConnected()) + { + qDebug() << "D-Bus session bus not available, MPRIS disabled"; + return true; + } + + qDebug() << "Attempting to register MPRIS service:" << MPRIS_SERVICE_NAME; + if (!QDBusConnection::sessionBus().registerService(MPRIS_SERVICE_NAME)) + { + qWarning() << "Failed to register MPRIS service:" + << QDBusConnection::sessionBus().lastError().message(); + return true; + } + qDebug() << "MPRIS service registered successfully"; + + m_rootAdaptor = std::make_unique(this); + m_playerAdaptor = std::make_unique(this); + + if (!QDBusConnection::sessionBus().registerObject(MPRIS_OBJECT_PATH, this)) + { + qWarning() << "Failed to register MPRIS object:" + << QDBusConnection::sessionBus().lastError().message(); + QDBusConnection::sessionBus().unregisterService(MPRIS_SERVICE_NAME); + return true; + } + + m_enabled = true; + qDebug() << "MPRIS interface registered successfully"; + + return true; +} + +void MprisComponent::componentPostInitialize() +{ + if (!m_enabled) + return; + + m_player = &PlayerComponent::Get(); + connectPlayerSignals(); + + // Connect to PlayerComponent media integration signals + connect(m_player, &PlayerComponent::shuffleChanged, this, &MprisComponent::notifyShuffleChange); + connect(m_player, &PlayerComponent::repeatChanged, this, &MprisComponent::notifyRepeatChange); + connect(m_player, &PlayerComponent::fullscreenChanged, this, &MprisComponent::notifyFullscreenChange); + connect(m_player, &PlayerComponent::rateChanged, this, &MprisComponent::notifyRateChange); + connect(m_player, &PlayerComponent::queueChanged, this, &MprisComponent::notifyQueueChange); + connect(m_player, &PlayerComponent::playbackStopped, this, &MprisComponent::notifyPlaybackStop); + connect(m_player, &PlayerComponent::durationChanged, this, &MprisComponent::notifyDurationChange); + connect(m_player, &PlayerComponent::playbackStateChanged, this, &MprisComponent::notifyPlaybackState); + connect(m_player, &PlayerComponent::positionChanged, this, &MprisComponent::notifyPosition); + connect(m_player, &PlayerComponent::seekPerformed, this, &MprisComponent::notifySeek); + connect(m_player, &PlayerComponent::metadataChanged, this, &MprisComponent::notifyMetadata); + connect(m_player, &PlayerComponent::volumeChanged, this, &MprisComponent::notifyVolumeChange); + + m_positionTimer = new QTimer(this); + m_positionTimer->setInterval(500); + connect(m_positionTimer, &QTimer::timeout, [this]() { + if (m_playbackStatus == "Playing") + { + } + }); +} + +void MprisComponent::connectPlayerSignals() +{ + if (!m_player) + return; +} + +void MprisComponent::disconnectPlayerSignals() +{ + if (!m_player) + return; + + disconnect(m_player, nullptr, this, nullptr); +} + +QString MprisComponent::playbackStatus() const +{ + return m_playbackStatus; +} + +double MprisComponent::volume() const +{ + return m_volume; +} + +qint64 MprisComponent::position() const +{ + return m_position; +} + +QString MprisComponent::loopStatus() const +{ + return m_loopStatus; +} + +double MprisComponent::rate() const +{ + return m_rate; +} + +bool MprisComponent::shuffle() const +{ + return (m_currentMediaType == "Audio") ? m_shuffle : false; +} + +bool MprisComponent::canGoNext() const +{ + return m_canGoNext && m_playbackStatus != "Stopped"; +} + +bool MprisComponent::canGoPrevious() const +{ + return m_canGoPrevious && m_playbackStatus != "Stopped"; +} + +bool MprisComponent::canPlay() const +{ + return m_playbackStatus != "Stopped"; +} + +bool MprisComponent::canPause() const +{ + return m_playbackStatus == "Playing"; +} + +bool MprisComponent::canSeek() const +{ + if (m_playbackStatus == "Stopped") + return false; + + bool result = m_duration > 0; + qDebug() << "MPRIS: canSeek() called - duration:" << m_duration << "result:" << result; + return result; +} + +bool MprisComponent::canControl() const +{ + if (m_playbackStatus == "Stopped") + return false; + + return m_playerState == PlayerComponent::State::paused || + m_playerState == PlayerComponent::State::playing || + m_playerState == PlayerComponent::State::buffering; +} + +void MprisComponent::Raise() +{ + QQuickWindow* window = Globals::MainWindow(); + if (window) + { + window->raise(); + window->requestActivate(); + } +} + +void MprisComponent::Quit() +{ +} + +bool MprisComponent::fullscreen() const +{ + return WindowManager::Get().isFullScreen(); +} + +void MprisComponent::setFullscreen(bool value) +{ + bool currentState = WindowManager::Get().isFullScreen(); + if (currentState != value) + { + WindowManager::Get().setFullScreen(value); + qDebug() << "MPRIS: Fullscreen changed to:" << value; + emitPropertyChange("org.mpris.MediaPlayer2", "Fullscreen", value); + } +} + +void MprisComponent::Next() +{ + qDebug() << "MPRIS: Next track requested"; + InputComponent::Get().sendAction("next"); +} + +void MprisComponent::Previous() +{ + qDebug() << "MPRIS: Previous track requested"; + InputComponent::Get().sendAction("previous"); +} + +void MprisComponent::Pause() +{ + qDebug() << "MPRIS: Pause requested"; + InputComponent::Get().sendAction("pause"); +} + +void MprisComponent::PlayPause() +{ + qDebug() << "MPRIS: PlayPause requested"; + InputComponent::Get().sendAction("play_pause"); +} + +void MprisComponent::Stop() +{ + qDebug() << "MPRIS: Stop requested"; + InputComponent::Get().sendAction("stop"); +} + +void MprisComponent::Play() +{ + qDebug() << "MPRIS: Play requested"; + InputComponent::Get().sendAction("play"); +} + +void MprisComponent::Seek(qint64 offset) +{ + if (!canSeek()) + return; + + qint64 newPos = m_position + offset; + if (newPos < 0) + newPos = 0; + if (newPos > m_duration) + newPos = m_duration; + + m_seekPending = true; + m_expectedPosition = newPos; + + qDebug() << "MPRIS: Seek requested to" << (newPos / 1000) << "ms"; + InputComponent::Get().seekTo(newPos / 1000); +} + +void MprisComponent::SetPosition(const QDBusObjectPath& trackId, qint64 position) +{ + if (!canSeek()) + return; + + if (trackId.path() != m_currentTrackId) + return; + + if (position >= 0 && position <= m_duration) + { + m_seekPending = true; + m_expectedPosition = position; + + qDebug() << "MPRIS: SetPosition requested to" << (position / 1000) << "ms"; + InputComponent::Get().seekTo(position / 1000); + } +} + +void MprisComponent::OpenUri(const QString& uri) +{ +} + +void MprisComponent::setVolume(double volume) +{ + m_volume = qBound(0.0, volume, 1.0); + + int vol = static_cast(m_volume * 100); + InputComponent::Get().setVolume(vol); +} + +void MprisComponent::setLoopStatus(const QString& value) +{ + if (value == "None" || value == "Track" || value == "Playlist") + { + QString action; + if (value == "None") + action = "repeatnone"; + else if (value == "Track") + action = "repeatone"; + else if (value == "Playlist") + action = "repeatall"; + + InputComponent::Get().sendAction(action); + + qDebug() << "MPRIS: Loop status change requested:" << value << "via action:" << action; + } +} + +void MprisComponent::setRate(double value) +{ + value = qBound(0.25, value, 2.0); + + qDebug() << "MPRIS: Playback rate change requested:" << value; + InputComponent::Get().setRate(value); +} + +void MprisComponent::setShuffle(bool value) +{ + if (m_currentMediaType != "Audio") + return; + + QString action = value ? "shuffle" : "sorted"; + InputComponent::Get().sendAction(action); + + qDebug() << "MPRIS: Shuffle change requested:" << value << "via action:" << action; +} + +///////////////////////////////////////////////////////////////////////////////////////// +void MprisComponent::notifyShuffleChange(bool enabled) +{ + qDebug() << "MPRIS: Received shuffle change notification from web client:" << enabled; + if (m_shuffle != enabled) + { + m_shuffle = enabled; + emitPropertyChange("org.mpris.MediaPlayer2.Player", "Shuffle", shuffle()); + } +} + +///////////////////////////////////////////////////////////////////////////////////////// +void MprisComponent::notifyRepeatChange(const QString& mode) +{ + qDebug() << "MPRIS: Received repeat change notification from web client:" << mode; + + QString loopStatus; + if (mode == "RepeatAll") + loopStatus = "Playlist"; + else if (mode == "RepeatOne") + loopStatus = "Track"; + else + loopStatus = "None"; + + if (m_loopStatus != loopStatus) + { + m_loopStatus = loopStatus; + emitPropertyChange("org.mpris.MediaPlayer2.Player", "LoopStatus", loopStatus); + } +} + +///////////////////////////////////////////////////////////////////////////////////////// +void MprisComponent::notifyFullscreenChange(bool isFullscreen) +{ + qDebug() << "MPRIS: Received fullscreen change notification from web client:" << isFullscreen; + emitPropertyChange("org.mpris.MediaPlayer2", "Fullscreen", isFullscreen); +} + +void MprisComponent::notifyRateChange(double rate) +{ + qDebug() << "MPRIS: Received playback rate change notification from web client:" << rate; + if (m_rate != rate) + { + m_rate = rate; + emitPropertyChange("org.mpris.MediaPlayer2.Player", "Rate", rate); + } +} + +void MprisComponent::notifyPlaybackStop(bool isNavigating) +{ + qDebug() << "MPRIS: Playback stop notification - isNavigating:" << isNavigating; + m_isNavigating = isNavigating; + + notifyPlaybackState("Stopped"); +} + +void MprisComponent::notifyDurationChange(qint64 durationMs) +{ + qDebug() << "MPRIS: Duration change notification from JS:" << durationMs << "ms"; + + qint64 durationMicroseconds = durationMs * 1000; + + if (m_duration != durationMicroseconds) + { + m_duration = durationMicroseconds; + m_metadata["mpris:length"] = QVariant::fromValue(m_duration); + + if (m_playbackStatus == "Playing") + { + QVariantMap properties; + properties["Metadata"] = m_metadata; + properties["CanSeek"] = canSeek(); + emitMultiplePropertyChanges("org.mpris.MediaPlayer2.Player", properties); + } + } +} + +void MprisComponent::notifyQueueChange(bool canNext, bool canPrevious) +{ + qDebug() << "MPRIS: Queue change - canNext:" << canNext << "canPrevious:" << canPrevious + << "status:" << m_playbackStatus << "state:" << (int)m_playerState; + + bool changed = false; + + if (m_canGoNext != canNext) + { + m_canGoNext = canNext; + changed = true; + } + + if (m_canGoPrevious != canPrevious) + { + m_canGoPrevious = canPrevious; + changed = true; + } + + if (changed) + { + QVariantMap properties; + properties["CanGoNext"] = m_canGoNext; + properties["CanGoPrevious"] = m_canGoPrevious; + + if (!canNext && !canPrevious && m_playbackStatus == "Stopped") + { + qDebug() << "MPRIS: Queue empty and stopped, clearing metadata"; + m_metadata.clear(); + m_currentTrackId.clear(); + cleanupAlbumArt(); + properties["Metadata"] = m_metadata; + properties["Position"] = (qint64)0; + } + + emitMultiplePropertyChanges("org.mpris.MediaPlayer2.Player", properties); + } +} + +void MprisComponent::notifyPlaybackState(const QString& state) +{ + qDebug() << "MPRIS: Playback state change from JS:" << state; + + if (state == "Playing") + { + m_playbackStatus = "Playing"; + m_playerState = PlayerComponent::State::playing; + updateNavigationCapabilities(); + bool seekable = canSeek(); + + QVariantMap properties; + properties["PlaybackStatus"] = "Playing"; + properties["CanPlay"] = canPlay(); + properties["CanPause"] = canPause(); + properties["CanSeek"] = seekable; + properties["CanControl"] = canControl(); + properties["Rate"] = m_rate; + properties["Metadata"] = m_metadata; + emitMultiplePropertyChanges("org.mpris.MediaPlayer2.Player", properties); + + if (m_positionTimer) + m_positionTimer->start(); + } + else if (state == "Paused") + { + m_playbackStatus = "Paused"; + m_playerState = PlayerComponent::State::paused; + updateNavigationCapabilities(); + + QVariantMap properties; + properties["PlaybackStatus"] = "Paused"; + properties["CanPlay"] = canPlay(); + properties["CanPause"] = canPause(); + properties["CanControl"] = canControl(); + emitMultiplePropertyChanges("org.mpris.MediaPlayer2.Player", properties); + + if (m_positionTimer) + m_positionTimer->stop(); + } + else if (state == "Stopped") + { + if (m_positionTimer) + m_positionTimer->stop(); + + m_position = 0; + m_duration = 0; + + m_playbackStatus = "Stopped"; + m_playerState = PlayerComponent::State::finished; + + if (!m_isNavigating) + { + qDebug() << "MPRIS: Stopped without navigation, clearing metadata"; + m_metadata.clear(); + m_currentTrackId.clear(); + cleanupAlbumArt(); + } + else + { + qDebug() << "MPRIS: Stopped during navigation, keeping metadata for transition"; + } + + m_isNavigating = false; + + updateNavigationCapabilities(); + bool seekable = canSeek(); + + QVariantMap properties; + properties["PlaybackStatus"] = "Stopped"; + properties["CanPlay"] = canPlay(); + properties["CanPause"] = canPause(); + properties["CanSeek"] = seekable; + properties["CanControl"] = canControl(); + properties["Position"] = (qint64)0; + properties["Metadata"] = m_metadata; + emitMultiplePropertyChanges("org.mpris.MediaPlayer2.Player", properties); + } +} + +void MprisComponent::notifyPosition(qint64 positionMs) +{ + m_position = positionMs * 1000; + + if (m_seekPending && m_playerAdaptor) { + m_seekPending = false; + qDebug() << "MPRIS: MPRIS-initiated seek completed, emitting Seeked signal at" << m_position << "us"; + Q_EMIT m_playerAdaptor->Seeked(m_position); + } +} + +void MprisComponent::notifySeek(qint64 positionMs) +{ + m_position = positionMs * 1000; + + if (m_playerAdaptor) { + qDebug() << "MPRIS: Player-initiated seek, emitting Seeked signal at" << m_position << "us"; + Q_EMIT m_playerAdaptor->Seeked(m_position); + } + + m_seekPending = false; +} + +void MprisComponent::notifyMetadata(const QVariantMap& metadata, const QString& baseUrl) +{ + qDebug() << "MPRIS: Metadata update from JS"; + updateMetadata(metadata, QUrl(baseUrl)); +} + +void MprisComponent::notifyVolumeChange(double volume) +{ + double newVolume = qBound(0.0, volume, 1.0); + if (m_volume != newVolume) + { + m_volume = newVolume; + emitPropertyChange("org.mpris.MediaPlayer2.Player", "Volume", m_volume); + } +} + +void MprisComponent::onPlayerPlaying() +{ + qDebug() << "MPRIS: Player playing"; + + m_playbackStatus = "Playing"; + updateNavigationCapabilities(); + bool seekable = canSeek(); + + QVariantMap properties; + properties["PlaybackStatus"] = "Playing"; + properties["CanPlay"] = canPlay(); + properties["CanPause"] = canPause(); + properties["CanSeek"] = seekable; + properties["CanControl"] = canControl(); + properties["Rate"] = m_rate; + properties["Metadata"] = m_metadata; + emitMultiplePropertyChanges("org.mpris.MediaPlayer2.Player", properties); + + if (m_positionTimer) + m_positionTimer->start(); +} + +void MprisComponent::onPlayerPaused() +{ + qDebug() << "MPRIS: Player paused"; + updatePlaybackStatus("Paused"); + if (m_positionTimer) + m_positionTimer->stop(); +} + +void MprisComponent::onPlayerStopped() +{ + qDebug() << "MPRIS: Player stopped"; + if (m_positionTimer) + m_positionTimer->stop(); + + m_position = 0; + m_duration = 0; + + m_playbackStatus = "Stopped"; + updateNavigationCapabilities(); + bool seekable = canSeek(); + + QVariantMap properties; + properties["PlaybackStatus"] = "Stopped"; + properties["CanPlay"] = canPlay(); + properties["CanPause"] = canPause(); + properties["CanSeek"] = seekable; + properties["CanControl"] = canControl(); + properties["Position"] = (qint64)0; + emitMultiplePropertyChanges("org.mpris.MediaPlayer2.Player", properties); +} + +void MprisComponent::onPlayerFinished() +{ + updatePlaybackStatus("Stopped"); + if (m_positionTimer) + m_positionTimer->stop(); + + qDebug() << "MPRIS: Playback finished, clearing metadata"; + m_position = 0; + m_duration = 0; + m_metadata.clear(); + m_currentTrackId.clear(); + m_canGoNext = false; + m_canGoPrevious = false; + + cleanupAlbumArt(); + + QVariantMap properties; + properties["Metadata"] = m_metadata; + properties["Position"] = (qint64)m_position; + properties["CanGoNext"] = false; + properties["CanGoPrevious"] = false; + properties["CanControl"] = canControl(); + emitMultiplePropertyChanges("org.mpris.MediaPlayer2.Player", properties); +} + +void MprisComponent::onPlayerStateChanged(PlayerComponent::State newState, PlayerComponent::State oldState) +{ + qDebug() << "MPRIS: State changed from" << (int)oldState << "to" << (int)newState; + m_playerState = newState; + + if (newState == PlayerComponent::State::finished) + { + if (!m_canGoNext) + { + qDebug() << "MPRIS: Finished with no next item, clearing metadata"; + m_metadata.clear(); + m_currentTrackId.clear(); + cleanupAlbumArt(); + + QVariantMap properties; + properties["Metadata"] = m_metadata; + emitMultiplePropertyChanges("org.mpris.MediaPlayer2.Player", properties); + } + else + { + qDebug() << "MPRIS: Finished but next item available, keeping metadata"; + } + } + else if (newState == PlayerComponent::State::canceled) + { + if (!m_isNavigating) + { + qDebug() << "MPRIS: Canceled (exiting playback), clearing metadata"; + m_metadata.clear(); + m_currentTrackId.clear(); + m_canGoNext = false; + m_canGoPrevious = false; + cleanupAlbumArt(); + + QVariantMap properties; + properties["Metadata"] = m_metadata; + properties["CanGoNext"] = false; + properties["CanGoPrevious"] = false; + emitMultiplePropertyChanges("org.mpris.MediaPlayer2.Player", properties); + } + else + { + qDebug() << "MPRIS: Canceled during navigation, keeping metadata (will be updated by queueMedia)"; + } + + m_isNavigating = false; + } + else if (newState == PlayerComponent::State::error) + { + qDebug() << "MPRIS: Clearing metadata due to error state"; + m_metadata.clear(); + m_currentTrackId.clear(); + cleanupAlbumArt(); + + QVariantMap properties; + properties["Metadata"] = m_metadata; + emitMultiplePropertyChanges("org.mpris.MediaPlayer2.Player", properties); + } +} + +void MprisComponent::onPlayerPositionUpdate(quint64 position) +{ + m_position = position * 1000; + + if (m_seekPending && m_playerAdaptor) { + m_seekPending = false; + Q_EMIT m_playerAdaptor->Seeked(m_position); + } +} + +void MprisComponent::onPlayerDurationChanged(qint64 duration) +{ + qint64 mpvDuration = duration * 1000; + + qDebug() << "MPRIS: MPV duration:" << mpvDuration << "Jellyfin duration:" << m_duration; + + m_duration = mpvDuration; + + m_metadata["mpris:length"] = QVariant::fromValue(m_duration); + + qDebug() << "MPRIS: Duration updated, CanSeek:" << canSeek(); + + if (m_playbackStatus == "Playing") + { + QVariantMap properties; + properties["Metadata"] = m_metadata; + properties["CanSeek"] = canSeek(); + emitMultiplePropertyChanges("org.mpris.MediaPlayer2.Player", properties); + } +} + +void MprisComponent::onPlayerMetaData(const QVariantMap& metadata, const QUrl& baseUrl) +{ + qDebug() << "MPRIS: Received metadata from PlayerComponent:" << metadata.value("Name").toString(); + updateMetadata(metadata, baseUrl); +} + +void MprisComponent::onPlayerVolumeChanged() +{ + emitPropertyChange("org.mpris.MediaPlayer2.Player", "Volume", volume()); +} + +void MprisComponent::onShuffleModeChanged(bool shuffleEnabled) +{ + if (m_currentMediaType != "Audio") + return; + + m_shuffle = shuffleEnabled; + qDebug() << "MPRIS: Shuffle mode changed from web client to:" << shuffleEnabled; + emitPropertyChange("org.mpris.MediaPlayer2.Player", "Shuffle", shuffle()); +} + +void MprisComponent::onRepeatModeChanged(const QString& repeatMode) +{ + if (m_currentMediaType != "Audio") + return; + + QString mprisLoopStatus; + if (repeatMode == "RepeatNone") + mprisLoopStatus = "None"; + else if (repeatMode == "RepeatOne") + mprisLoopStatus = "Track"; + else if (repeatMode == "RepeatAll") + mprisLoopStatus = "Playlist"; + else { + qWarning() << "MPRIS: Unknown repeat mode from web client:" << repeatMode; + return; + } + + m_loopStatus = mprisLoopStatus; + qDebug() << "MPRIS: Repeat mode changed from web client:" << repeatMode << "-> MPRIS:" << mprisLoopStatus; + emitPropertyChange("org.mpris.MediaPlayer2.Player", "LoopStatus", loopStatus()); +} + +void MprisComponent::updatePlaybackStatus(const QString& status) +{ + if (m_playbackStatus != status) + { + m_playbackStatus = status; + + updateNavigationCapabilities(); + bool seekable = canSeek(); + qDebug() << "MPRIS: Emitting CanSeek on status change:" << seekable << "status:" << status << "duration:" << m_duration; + + QVariantMap properties; + properties["PlaybackStatus"] = status; + properties["CanPlay"] = canPlay(); + properties["CanPause"] = canPause(); + properties["CanSeek"] = seekable; + properties["CanControl"] = canControl(); + properties["Rate"] = m_rate; + emitMultiplePropertyChanges("org.mpris.MediaPlayer2.Player", properties); + } +} + +void MprisComponent::updateMetadata(const QVariantMap& jellyfinMeta, const QUrl& baseUrl) +{ + QVariantMap mprisMeta; + + m_currentTrackId = generateTrackId(); + mprisMeta["mpris:trackid"] = QVariant::fromValue(QDBusObjectPath(m_currentTrackId)); + + QString mediaType = jellyfinMeta.value("MediaType").toString(); + QString itemType = jellyfinMeta.value("Type").toString(); + + m_currentMediaType = mediaType; + + if (jellyfinMeta.contains("Name")) + mprisMeta["xesam:title"] = jellyfinMeta["Name"]; + + if (jellyfinMeta.contains("RunTimeTicks")) + { + qint64 runTimeTicks = jellyfinMeta["RunTimeTicks"].toLongLong(); + qint64 durationMicroseconds = runTimeTicks / 10; + m_duration = durationMicroseconds; + qDebug() << "MPRIS: Got duration from Jellyfin metadata:" << m_duration << "us"; + } + + if (m_duration > 0) + mprisMeta["mpris:length"] = QVariant::fromValue(m_duration); + + if (mediaType == "Audio" || itemType == "Audio") + { + if (jellyfinMeta.contains("Artists")) + { + QStringList artists = jellyfinMeta["Artists"].toStringList(); + if (!artists.isEmpty()) + mprisMeta["xesam:artist"] = QVariant::fromValue(artists); + } + + if (jellyfinMeta.contains("AlbumArtist")) + mprisMeta["xesam:albumArtist"] = QVariant::fromValue(QStringList{jellyfinMeta["AlbumArtist"].toString()}); + + if (jellyfinMeta.contains("Album")) + mprisMeta["xesam:album"] = jellyfinMeta["Album"]; + + if (jellyfinMeta.contains("IndexNumber")) + mprisMeta["xesam:trackNumber"] = jellyfinMeta["IndexNumber"]; + + if (jellyfinMeta.contains("ProductionYear")) + mprisMeta["xesam:contentCreated"] = jellyfinMeta["ProductionYear"].toString(); + } + else if (itemType == "Episode") + { + QString title = jellyfinMeta.value("Name").toString(); + QString series = jellyfinMeta.value("SeriesName").toString(); + int episode = jellyfinMeta.value("IndexNumber", 0).toInt(); + + mprisMeta["xesam:title"] = title; + + if (!series.isEmpty()) + mprisMeta["xesam:artist"] = QVariant::fromValue(QStringList{series}); + + if (jellyfinMeta.contains("SeasonName")) + mprisMeta["xesam:album"] = jellyfinMeta["SeasonName"].toString(); + + if (episode > 0) + mprisMeta["xesam:trackNumber"] = episode; + + if (jellyfinMeta.contains("ProductionYear")) + mprisMeta["xesam:contentCreated"] = jellyfinMeta["ProductionYear"].toString(); + } + else if (itemType == "Movie") + { + if (jellyfinMeta.contains("ProductionYear")) + mprisMeta["xesam:contentCreated"] = jellyfinMeta["ProductionYear"].toString(); + } + + QString artUrl = extractArtworkUrl(jellyfinMeta, baseUrl); + if (!artUrl.isEmpty()) + { + QString localArtUrl = handleAlbumArt(artUrl); + if (!localArtUrl.isEmpty()) + { + mprisMeta["mpris:artUrl"] = localArtUrl; + } + } + + mprisMeta["mpris:length"] = QVariant::fromValue(m_duration); + + m_metadata = mprisMeta; + + updateNavigationCapabilities(); + + bool seekable = canSeek(); + qDebug() << "MPRIS: Emitting metadata, CanSeek:" << seekable << "duration:" << m_duration; + + QVariantMap properties; + properties["Metadata"] = m_metadata; + + static bool lastCanSeek = false; + if (seekable != lastCanSeek) + { + properties["CanSeek"] = seekable; + lastCanSeek = seekable; + } + + if (m_currentMediaType == "Audio") { + static bool lastShuffle = false; + static QString lastLoopStatus = ""; + + bool currentShuffle = shuffle(); + QString currentLoopStatus = loopStatus(); + + if (currentShuffle != lastShuffle) + { + properties["Shuffle"] = currentShuffle; + lastShuffle = currentShuffle; + } + + if (currentLoopStatus != lastLoopStatus) + { + properties["LoopStatus"] = currentLoopStatus; + lastLoopStatus = currentLoopStatus; + } + } + + emitMultiplePropertyChanges("org.mpris.MediaPlayer2.Player", properties); +} + +void MprisComponent::emitPropertyChange(const QString& interface, + const QString& property, + const QVariant& value) +{ + QVariantMap changedProps; + changedProps[property] = value; + + QDBusMessage signal = QDBusMessage::createSignal( + MPRIS_OBJECT_PATH, + "org.freedesktop.DBus.Properties", + "PropertiesChanged" + ); + + signal << interface << changedProps << QStringList(); + QDBusConnection::sessionBus().send(signal); +} + +void MprisComponent::emitMultiplePropertyChanges(const QString& interface, + const QVariantMap& properties) +{ + QDBusMessage signal = QDBusMessage::createSignal( + MPRIS_OBJECT_PATH, + "org.freedesktop.DBus.Properties", + "PropertiesChanged" + ); + + signal << interface << properties << QStringList(); + QDBusConnection::sessionBus().send(signal); +} + +QString MprisComponent::generateTrackId() const +{ + static int trackCounter = 0; + return QString("/org/mpris/MediaPlayer2/Track/%1").arg(++trackCounter); +} + +QString MprisComponent::handleAlbumArt(const QString& artUrl) +{ + if (artUrl.isEmpty()) + return QString(); + + if (artUrl.startsWith("file://")) + { + if (!m_currentArtPath.isEmpty()) + cleanupAlbumArt(); + return artUrl; + } + + if (artUrl.startsWith("http://") || artUrl.startsWith("https://")) + { + QCryptographicHash hash(QCryptographicHash::Md5); + hash.addData(artUrl.toUtf8()); + QString filename = QString("%1/%2.jpg").arg(m_albumArtDir) + .arg(hash.result().toHex().constData()); + + if (QFile::exists(filename)) + { + if (m_currentArtPath != filename && !m_currentArtPath.isEmpty()) + cleanupAlbumArt(); + + m_currentArtPath = filename; + return QUrl::fromLocalFile(filename).toString(); + } + + if (m_pendingArtReply && m_pendingArtUrl == artUrl) + { + return QString(); + } + + if (!m_currentArtPath.isEmpty() || m_pendingArtReply) + cleanupAlbumArt(); + + m_pendingArtPath = filename; + m_pendingArtUrl = artUrl; + + QNetworkRequest request; + request.setUrl(QUrl(artUrl)); + request.setRawHeader("User-Agent", "JellyfinMediaPlayer/1.0"); + + m_pendingArtReply = m_albumArtManager->get(request); + + connect(m_pendingArtReply, &QNetworkReply::finished, this, &MprisComponent::onAlbumArtDownloaded); + + return QString(); + } + + return QString(); +} + +void MprisComponent::cleanupAlbumArt() +{ + if (m_pendingArtReply) + { + m_pendingArtReply->abort(); + m_pendingArtReply->deleteLater(); + m_pendingArtReply = nullptr; + m_pendingArtPath.clear(); + m_pendingArtUrl.clear(); + } + + if (!m_currentArtPath.isEmpty()) + { + QFile::remove(m_currentArtPath); + m_currentArtPath.clear(); + } +} + +void MprisComponent::onAlbumArtDownloaded() +{ + QNetworkReply* reply = qobject_cast(sender()); + if (!reply) + return; + + if (reply == m_pendingArtReply) + m_pendingArtReply = nullptr; + + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) + { + if (reply->error() != QNetworkReply::OperationCanceledError) + qDebug() << "MPRIS: Album art download failed:" << reply->errorString(); + m_pendingArtPath.clear(); + return; + } + + QByteArray imageData = reply->readAll(); + + if (imageData.size() > 0) + { + QFile file(m_pendingArtPath); + if (file.open(QIODevice::WriteOnly)) + { + file.write(imageData); + file.close(); + + file.setPermissions(QFile::ReadOwner | QFile::WriteOwner); + + m_currentArtPath = m_pendingArtPath; + QString artUrl = QUrl::fromLocalFile(m_currentArtPath).toString(); + + if (!m_metadata.isEmpty()) + { + m_metadata["mpris:artUrl"] = artUrl; + emitPropertyChange("org.mpris.MediaPlayer2.Player", "Metadata", m_metadata); + } + + qDebug() << "MPRIS: Album art downloaded successfully:" << m_currentArtPath; + } + else + { + qDebug() << "MPRIS: Failed to write album art to:" << m_pendingArtPath; + } + } + else + { + qDebug() << "MPRIS: Album art validation failed - empty data"; + } + + m_pendingArtPath.clear(); +} + +QString MprisComponent::extractArtworkUrl(const QVariantMap& metadata, const QUrl& baseUrl) +{ + if (baseUrl.isEmpty()) + return QString(); + + QString mediaType = metadata.value("MediaType").toString(); + QString itemType = metadata.value("Type").toString(); + + QUrl artUrl = baseUrl; + QUrlQuery query; + + if (mediaType == "Audio" || itemType == "Audio") + { + if (metadata.contains("AlbumId") && metadata.contains("AlbumPrimaryImageTag")) + { + QString albumId = metadata["AlbumId"].toString(); + QString imageTag = metadata["AlbumPrimaryImageTag"].toString(); + + if (!albumId.isEmpty() && !imageTag.isEmpty()) + { + artUrl.setPath(QString("/Items/%1/Images/Primary").arg(albumId)); + query.addQueryItem("tag", imageTag); + query.addQueryItem("maxWidth", "512"); + artUrl.setQuery(query); + return artUrl.toString(); + } + } + + auto imageTags = metadata["ImageTags"].toMap(); + if (imageTags.contains("Primary") && metadata.contains("Id")) + { + QString itemId = metadata["Id"].toString(); + QString imageTag = imageTags["Primary"].toString(); + + if (!itemId.isEmpty() && !imageTag.isEmpty()) + { + artUrl.setPath(QString("/Items/%1/Images/Primary").arg(itemId)); + query.addQueryItem("tag", imageTag); + query.addQueryItem("maxWidth", "512"); + artUrl.setQuery(query); + return artUrl.toString(); + } + } + } + else if (itemType == "Episode") + { + if (metadata.contains("SeriesId") && metadata.contains("SeriesPrimaryImageTag")) + { + QString seriesId = metadata["SeriesId"].toString(); + QString imageTag = metadata["SeriesPrimaryImageTag"].toString(); + + if (!seriesId.isEmpty() && !imageTag.isEmpty()) + { + artUrl.setPath(QString("/Items/%1/Images/Primary").arg(seriesId)); + query.addQueryItem("tag", imageTag); + query.addQueryItem("maxWidth", "512"); + artUrl.setQuery(query); + return artUrl.toString(); + } + } + + if (metadata.contains("SeasonId") && metadata.contains("SeasonPrimaryImageTag")) + { + QString seasonId = metadata["SeasonId"].toString(); + QString imageTag = metadata["SeasonPrimaryImageTag"].toString(); + + if (!seasonId.isEmpty() && !imageTag.isEmpty()) + { + artUrl.setPath(QString("/Items/%1/Images/Primary").arg(seasonId)); + query.addQueryItem("tag", imageTag); + query.addQueryItem("maxWidth", "512"); + artUrl.setQuery(query); + return artUrl.toString(); + } + } + + auto imageTags = metadata["ImageTags"].toMap(); + if (imageTags.contains("Primary") && metadata.contains("Id")) + { + QString itemId = metadata["Id"].toString(); + QString imageTag = imageTags["Primary"].toString(); + + if (!itemId.isEmpty() && !imageTag.isEmpty()) + { + artUrl.setPath(QString("/Items/%1/Images/Primary").arg(itemId)); + query.addQueryItem("tag", imageTag); + query.addQueryItem("maxWidth", "512"); + artUrl.setQuery(query); + return artUrl.toString(); + } + } + } + else if (itemType == "Movie") + { + auto imageTags = metadata["ImageTags"].toMap(); + if (imageTags.contains("Primary") && metadata.contains("Id")) + { + QString itemId = metadata["Id"].toString(); + QString imageTag = imageTags["Primary"].toString(); + + if (!itemId.isEmpty() && !imageTag.isEmpty()) + { + artUrl.setPath(QString("/Items/%1/Images/Primary").arg(itemId)); + query.addQueryItem("tag", imageTag); + query.addQueryItem("maxWidth", "512"); + artUrl.setQuery(query); + return artUrl.toString(); + } + } + + if (imageTags.contains("Backdrop") && metadata.contains("Id")) + { + QString itemId = metadata["Id"].toString(); + QString imageTag = imageTags["Backdrop"].toString(); + + if (!itemId.isEmpty() && !imageTag.isEmpty()) + { + artUrl.setPath(QString("/Items/%1/Images/Backdrop/0").arg(itemId)); + query.addQueryItem("tag", imageTag); + query.addQueryItem("maxWidth", "512"); + artUrl.setQuery(query); + return artUrl.toString(); + } + } + } + else + { + auto imageTags = metadata["ImageTags"].toMap(); + if (imageTags.contains("Primary") && metadata.contains("Id")) + { + QString itemId = metadata["Id"].toString(); + QString imageTag = imageTags["Primary"].toString(); + + if (!itemId.isEmpty() && !imageTag.isEmpty()) + { + artUrl.setPath(QString("/Items/%1/Images/Primary").arg(itemId)); + query.addQueryItem("tag", imageTag); + query.addQueryItem("maxWidth", "512"); + artUrl.setQuery(query); + return artUrl.toString(); + } + } + } + + return QString(); +} + +void MprisComponent::updateNavigationCapabilities() +{ +} \ No newline at end of file diff --git a/src/mpris/MprisComponent.h b/src/mpris/MprisComponent.h new file mode 100644 index 0000000..d85773f --- /dev/null +++ b/src/mpris/MprisComponent.h @@ -0,0 +1,164 @@ +#ifndef MPRISCOMPONENT_H +#define MPRISCOMPONENT_H + +#include +#include +#include +#include +#include +#include + +#include "ComponentManager.h" +#include "player/PlayerComponent.h" + +class QNetworkAccessManager; +class QNetworkReply; + +#include "MprisRootAdaptor.h" +#include "MprisPlayerAdaptor.h" + +class MprisComponent : public ComponentBase +{ + Q_OBJECT + DEFINE_SINGLETON(MprisComponent); + +public: + explicit MprisComponent(QObject* parent = nullptr); + ~MprisComponent() override; + + const char* componentName() override { return "mpris"; } + bool componentExport() override { return false; } + bool componentInitialize() override; + void componentPostInitialize() override; + + bool canQuit() const { return false; } + bool canRaise() const { return true; } + bool canSetFullscreen() const { return true; } + bool fullscreen() const; + void setFullscreen(bool value); + bool hasTrackList() const { return false; } + QString identity() const { return "Jellyfin Media Player"; } + QString desktopEntry() const { return "jellyfinmediaplayer"; } + QStringList supportedUriSchemes() const { return QStringList(); } + QStringList supportedMimeTypes() const { return QStringList(); } + + QString playbackStatus() const; + QString loopStatus() const; + double rate() const; + bool shuffle() const; + QVariantMap metadata() const { return m_metadata; } + double volume() const; + qint64 position() const; + double minimumRate() const { return 0.25; } + double maximumRate() const { return 2.0; } + bool canGoNext() const; + bool canGoPrevious() const; + bool canPlay() const; + bool canPause() const; + bool canSeek() const; + bool canControl() const; + + // Notification slots - connected to PlayerComponent signals + void notifyShuffleChange(bool enabled); + void notifyRepeatChange(const QString& mode); + void notifyFullscreenChange(bool isFullscreen); + void notifyRateChange(double rate); + void notifyQueueChange(bool canNext, bool canPrevious); + void notifyPlaybackStop(bool isNavigating); + void notifyDurationChange(qint64 durationMs); + void notifyPlaybackState(const QString& state); + void notifyPosition(qint64 positionMs); + void notifySeek(qint64 positionMs); + void notifyMetadata(const QVariantMap& metadata, const QString& baseUrl); + void notifyVolumeChange(double volume); + +public Q_SLOTS: + // MPRIS D-Bus method handlers - called from D-Bus clients + void Raise(); + void Quit(); + + void Next(); + void Previous(); + void Pause(); + void PlayPause(); + void Stop(); + void Play(); + void Seek(qint64 offset); + void SetPosition(const QDBusObjectPath& trackId, qint64 position); + void OpenUri(const QString& uri); + + void setVolume(double volume); + void setLoopStatus(const QString& value); + void setRate(double value); + void setShuffle(bool value); + +private Q_SLOTS: + void onPlayerPlaying(); + void onPlayerPaused(); + void onPlayerStopped(); + void onPlayerFinished(); + void onPlayerStateChanged(PlayerComponent::State newState, PlayerComponent::State oldState); + void onPlayerPositionUpdate(quint64 position); + void onPlayerDurationChanged(qint64 duration); + void onPlayerMetaData(const QVariantMap& metadata, const QUrl& baseUrl); + void onPlayerVolumeChanged(); + void onShuffleModeChanged(bool shuffleEnabled); + void onRepeatModeChanged(const QString& repeatMode); + + void onAlbumArtDownloaded(); + +Q_SIGNALS: + void propertiesChanged(const QString& interface, + const QVariantMap& changedProperties, + const QStringList& invalidatedProperties); + +private: + void updatePlaybackStatus(const QString& status); + void updateMetadata(const QVariantMap& jellyfinMeta, const QUrl& baseUrl = QUrl()); + void emitPropertyChange(const QString& interface, const QString& property, const QVariant& value); + void emitMultiplePropertyChanges(const QString& interface, const QVariantMap& properties); + void connectPlayerSignals(); + void disconnectPlayerSignals(); + QString generateTrackId() const; + QString handleAlbumArt(const QString& artUrl); + void cleanupAlbumArt(); + QString extractArtworkUrl(const QVariantMap& metadata, const QUrl& baseUrl); + void updateNavigationCapabilities(); + + bool m_enabled; + PlayerComponent* m_player; + std::unique_ptr m_rootAdaptor; + std::unique_ptr m_playerAdaptor; + + QString m_playbackStatus; + QVariantMap m_metadata; + qint64 m_position; + qint64 m_duration; + QString m_currentTrackId; + QString m_currentMediaType; + + QTimer* m_positionTimer; + bool m_canGoNext; + bool m_canGoPrevious; + + bool m_shuffle; + QString m_loopStatus; + double m_rate; + double m_volume; + + bool m_seekPending; + qint64 m_expectedPosition; + + bool m_isNavigating; + + QString m_currentArtPath; + QString m_albumArtDir; + QString m_pendingArtPath; + QString m_pendingArtUrl; + PlayerComponent::State m_playerState; + + QNetworkAccessManager* m_albumArtManager; + QNetworkReply* m_pendingArtReply; +}; + +#endif // MPRISCOMPONENT_H \ No newline at end of file diff --git a/src/mpris/MprisPlayerAdaptor.cpp b/src/mpris/MprisPlayerAdaptor.cpp new file mode 100644 index 0000000..cd4a32c --- /dev/null +++ b/src/mpris/MprisPlayerAdaptor.cpp @@ -0,0 +1,150 @@ +#include "MprisPlayerAdaptor.h" +#include "MprisComponent.h" + +MprisPlayerAdaptor::MprisPlayerAdaptor(MprisComponent* parent) + : QDBusAbstractAdaptor(parent) + , m_component(parent) +{ +} + +QString MprisPlayerAdaptor::playbackStatus() const +{ + return m_component->playbackStatus(); +} + +QString MprisPlayerAdaptor::loopStatus() const +{ + return m_component->loopStatus(); +} + +void MprisPlayerAdaptor::setLoopStatus(const QString& value) +{ + m_component->setLoopStatus(value); +} + +double MprisPlayerAdaptor::rate() const +{ + return m_component->rate(); +} + +void MprisPlayerAdaptor::setRate(double value) +{ + m_component->setRate(value); +} + +bool MprisPlayerAdaptor::shuffle() const +{ + return m_component->shuffle(); +} + +void MprisPlayerAdaptor::setShuffle(bool value) +{ + m_component->setShuffle(value); +} + +QVariantMap MprisPlayerAdaptor::metadata() const +{ + return m_component->metadata(); +} + +double MprisPlayerAdaptor::volume() const +{ + return m_component->volume(); +} + +void MprisPlayerAdaptor::setVolume(double value) +{ + m_component->setVolume(value); +} + +qint64 MprisPlayerAdaptor::position() const +{ + return m_component->position(); +} + +double MprisPlayerAdaptor::minimumRate() const +{ + return m_component->minimumRate(); +} + +double MprisPlayerAdaptor::maximumRate() const +{ + return m_component->maximumRate(); +} + +bool MprisPlayerAdaptor::canGoNext() const +{ + return m_component->canGoNext(); +} + +bool MprisPlayerAdaptor::canGoPrevious() const +{ + return m_component->canGoPrevious(); +} + +bool MprisPlayerAdaptor::canPlay() const +{ + return m_component->canPlay(); +} + +bool MprisPlayerAdaptor::canPause() const +{ + return m_component->canPause(); +} + +bool MprisPlayerAdaptor::canSeek() const +{ + return m_component->canSeek(); +} + +bool MprisPlayerAdaptor::canControl() const +{ + return m_component->canControl(); +} + +void MprisPlayerAdaptor::Next() +{ + m_component->Next(); +} + +void MprisPlayerAdaptor::Previous() +{ + m_component->Previous(); +} + +void MprisPlayerAdaptor::Pause() +{ + m_component->Pause(); +} + +void MprisPlayerAdaptor::PlayPause() +{ + m_component->PlayPause(); +} + +void MprisPlayerAdaptor::Stop() +{ + m_component->Stop(); +} + +void MprisPlayerAdaptor::Play() +{ + m_component->Play(); +} + +void MprisPlayerAdaptor::Seek(qint64 offset) +{ + m_component->Seek(offset); + Q_EMIT Seeked(m_component->position()); +} + +void MprisPlayerAdaptor::SetPosition(const QDBusObjectPath& trackId, qint64 position) +{ + m_component->SetPosition(trackId, position); + Q_EMIT Seeked(m_component->position()); +} + +void MprisPlayerAdaptor::OpenUri(const QString& uri) +{ + m_component->OpenUri(uri); +} \ No newline at end of file diff --git a/src/mpris/MprisPlayerAdaptor.h b/src/mpris/MprisPlayerAdaptor.h new file mode 100644 index 0000000..9ad455f --- /dev/null +++ b/src/mpris/MprisPlayerAdaptor.h @@ -0,0 +1,77 @@ +#ifndef MPRISPLAYERADAPTOR_H +#define MPRISPLAYERADAPTOR_H + +#include +#include +#include + +class MprisComponent; + +class MprisPlayerAdaptor : public QDBusAbstractAdaptor +{ + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.mpris.MediaPlayer2.Player") + + // Properties + Q_PROPERTY(QString PlaybackStatus READ playbackStatus) + Q_PROPERTY(QString LoopStatus READ loopStatus WRITE setLoopStatus) + Q_PROPERTY(double Rate READ rate WRITE setRate) + Q_PROPERTY(bool Shuffle READ shuffle WRITE setShuffle) + Q_PROPERTY(QVariantMap Metadata READ metadata) + Q_PROPERTY(double Volume READ volume WRITE setVolume) + Q_PROPERTY(qint64 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(MprisComponent* parent); + ~MprisPlayerAdaptor() override = default; + + // Properties + QString playbackStatus() const; + QString loopStatus() const; + void setLoopStatus(const QString& value); + double rate() const; + void setRate(double value); + bool shuffle() const; + void setShuffle(bool value); + QVariantMap metadata() const; + double volume() const; + void setVolume(double value); + qint64 position() const; + double minimumRate() const; + double maximumRate() const; + bool canGoNext() const; + bool canGoPrevious() const; + bool canPlay() const; + bool canPause() const; + bool canSeek() const; + bool canControl() const; + +public Q_SLOTS: + // Methods + void Next(); + void Previous(); + void Pause(); + void PlayPause(); + void Stop(); + void Play(); + void Seek(qint64 offset); + void SetPosition(const QDBusObjectPath& trackId, qint64 position); + void OpenUri(const QString& uri); + +Q_SIGNALS: + // Signals + void Seeked(qint64 position); + +private: + MprisComponent* m_component; +}; + +#endif // MPRISPLAYERADAPTOR_H \ No newline at end of file diff --git a/src/mpris/MprisRootAdaptor.cpp b/src/mpris/MprisRootAdaptor.cpp new file mode 100644 index 0000000..299198b --- /dev/null +++ b/src/mpris/MprisRootAdaptor.cpp @@ -0,0 +1,68 @@ +#include "MprisRootAdaptor.h" +#include "MprisComponent.h" + +MprisRootAdaptor::MprisRootAdaptor(MprisComponent* parent) + : QDBusAbstractAdaptor(parent) + , m_component(parent) +{ +} + +bool MprisRootAdaptor::canQuit() const +{ + return m_component->canQuit(); +} + +bool MprisRootAdaptor::canRaise() const +{ + return m_component->canRaise(); +} + +bool MprisRootAdaptor::canSetFullscreen() const +{ + return m_component->canSetFullscreen(); +} + +bool MprisRootAdaptor::fullscreen() const +{ + return m_component->fullscreen(); +} + +void MprisRootAdaptor::setFullscreen(bool value) +{ + m_component->setFullscreen(value); +} + +bool MprisRootAdaptor::hasTrackList() const +{ + return m_component->hasTrackList(); +} + +QString MprisRootAdaptor::identity() const +{ + return m_component->identity(); +} + +QString MprisRootAdaptor::desktopEntry() const +{ + return m_component->desktopEntry(); +} + +QStringList MprisRootAdaptor::supportedUriSchemes() const +{ + return m_component->supportedUriSchemes(); +} + +QStringList MprisRootAdaptor::supportedMimeTypes() const +{ + return m_component->supportedMimeTypes(); +} + +void MprisRootAdaptor::Raise() +{ + m_component->Raise(); +} + +void MprisRootAdaptor::Quit() +{ + m_component->Quit(); +} \ No newline at end of file diff --git a/src/mpris/MprisRootAdaptor.h b/src/mpris/MprisRootAdaptor.h new file mode 100644 index 0000000..08d8561 --- /dev/null +++ b/src/mpris/MprisRootAdaptor.h @@ -0,0 +1,50 @@ +#ifndef MPRISROOTADAPTOR_H +#define MPRISROOTADAPTOR_H + +#include +#include + +class MprisComponent; + +class MprisRootAdaptor : public QDBusAbstractAdaptor +{ + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.mpris.MediaPlayer2") + + // Properties + Q_PROPERTY(bool CanQuit READ canQuit) + Q_PROPERTY(bool CanRaise READ canRaise) + Q_PROPERTY(bool CanSetFullscreen READ canSetFullscreen) + Q_PROPERTY(bool Fullscreen READ fullscreen WRITE setFullscreen) + 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(MprisComponent* parent); + ~MprisRootAdaptor() override = default; + + // Properties + bool canQuit() const; + bool canRaise() const; + bool canSetFullscreen() const; + bool fullscreen() const; + void setFullscreen(bool value); + bool hasTrackList() const; + QString identity() const; + QString desktopEntry() const; + QStringList supportedUriSchemes() const; + QStringList supportedMimeTypes() const; + +public Q_SLOTS: + // Methods + void Raise(); + void Quit(); + +private: + MprisComponent* m_component; +}; + +#endif // MPRISROOTADAPTOR_H \ No newline at end of file diff --git a/src/player/PlayerComponent.cpp b/src/player/PlayerComponent.cpp index e1686a0..2ba7ca0 100644 --- a/src/player/PlayerComponent.cpp +++ b/src/player/PlayerComponent.cpp @@ -738,6 +738,78 @@ void PlayerComponent::play() m_mpv->command( args); } +/////////////////////////////////////////////////////////////////////////////////////////////////// +void PlayerComponent::notifyShuffleChange(bool enabled) +{ + emit shuffleChanged(enabled); +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +void PlayerComponent::notifyRepeatChange(const QString& mode) +{ + emit repeatChanged(mode); +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +void PlayerComponent::notifyFullscreenChange(bool isFullscreen) +{ + emit fullscreenChanged(isFullscreen); +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +void PlayerComponent::notifyRateChange(double rate) +{ + emit rateChanged(rate); +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +void PlayerComponent::notifyQueueChange(bool canNext, bool canPrevious) +{ + emit queueChanged(canNext, canPrevious); +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +void PlayerComponent::notifyPlaybackStop(bool isNavigating) +{ + emit playbackStopped(isNavigating); +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +void PlayerComponent::notifyDurationChange(qint64 durationMs) +{ + emit durationChanged(durationMs); +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +void PlayerComponent::notifyPlaybackState(const QString& state) +{ + emit playbackStateChanged(state); +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +void PlayerComponent::notifyPosition(qint64 positionMs) +{ + emit positionChanged(positionMs); +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +void PlayerComponent::notifySeek(qint64 positionMs) +{ + emit seekPerformed(positionMs); +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +void PlayerComponent::notifyMetadata(const QVariantMap& metadata, const QString& baseUrl) +{ + emit metadataChanged(metadata, baseUrl); +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +void PlayerComponent::notifyVolumeChange(double volume) +{ + emit volumeChanged(volume); +} + /////////////////////////////////////////////////////////////////////////////////////////////////// void PlayerComponent::stop() { @@ -1031,7 +1103,9 @@ void PlayerComponent::setPlaybackRate(int rate) qWarning() << "PlayerComponent::setPlaybackRate: mpv not initialized yet"; return; } - m_mpv->setProperty( "speed", rate / 1000.0); + double speed = rate / 1000.0; + m_mpv->setProperty( "speed", speed); + emit playbackRateChanged(speed); } ///////////////////////////////////////////////////////////////////////////////////////// @@ -1764,3 +1838,22 @@ QString PlayerComponent::videoInformation() const return infoStr; } +///////////////////////////////////////////////////////////////////////////////////////// +void PlayerComponent::setWebPlaylist(const QVariantList& playlist, const QString& currentItemId) +{ + m_webPlaylist = playlist; + m_currentWebPlaylistItemId = currentItemId; + emit webPlaylistChanged(playlist, currentItemId); +} + +///////////////////////////////////////////////////////////////////////////////////////// +QVariantList PlayerComponent::getWebPlaylist() const +{ + return m_webPlaylist; +} + +///////////////////////////////////////////////////////////////////////////////////////// +QString PlayerComponent::getCurrentWebPlaylistItemId() const +{ + return m_currentWebPlaylistItemId; +} diff --git a/src/player/PlayerComponent.h b/src/player/PlayerComponent.h index 7967063..b3542ce 100644 --- a/src/player/PlayerComponent.h +++ b/src/player/PlayerComponent.h @@ -61,7 +61,21 @@ public: Q_INVOKABLE virtual void pause(); Q_INVOKABLE virtual void play(); - + + // OS media integration notifications (called from JavaScript) + Q_INVOKABLE void notifyShuffleChange(bool enabled); + Q_INVOKABLE void notifyRepeatChange(const QString& mode); + Q_INVOKABLE void notifyFullscreenChange(bool isFullscreen); + Q_INVOKABLE void notifyRateChange(double rate); + Q_INVOKABLE void notifyQueueChange(bool canNext, bool canPrevious); + Q_INVOKABLE void notifyPlaybackStop(bool isNavigating); + Q_INVOKABLE void notifyDurationChange(qint64 durationMs); + Q_INVOKABLE void notifyPlaybackState(const QString& state); + Q_INVOKABLE void notifyPosition(qint64 positionMs); + Q_INVOKABLE void notifySeek(qint64 positionMs); + Q_INVOKABLE void notifyMetadata(const QVariantMap& metadata, const QString& baseUrl); + Q_INVOKABLE void notifyVolumeChange(double volume); + // 0-100 volume 0=mute and 100=normal // Ignored if no audio output active (e.g. when no file is playing). Q_INVOKABLE virtual void setVolume(int volume); @@ -123,6 +137,10 @@ public: Q_INVOKABLE qint64 getPosition(); Q_INVOKABLE qint64 getDuration(); + Q_INVOKABLE QVariantList getWebPlaylist() const; + Q_INVOKABLE QString getCurrentWebPlaylistItemId() const; + Q_INVOKABLE void setWebPlaylist(const QVariantList& playlist, const QString& currentItemId); + QRect videoRectangle() { return m_videoRectangle; } void setMpvController(MpvController* controller) { @@ -188,6 +206,7 @@ Q_SIGNALS: void windowVisible(bool visible); // emitted as soon as the duration of the current file is known void updateDuration(qint64 milliseconds); + void playbackRateChanged(double rate); // current position in ms should be triggered 2 times a second // when position updates @@ -198,7 +217,22 @@ Q_SIGNALS: void onMpvEvents(); void onMetaData(const QVariantMap &meta, QUrl baseUrl); - + + void webPlaylistChanged(const QVariantList& playlist, const QString& currentItemId); + + // OS media integration signals (for MPRIS, SMTC, etc.) + void shuffleChanged(bool enabled); + void repeatChanged(const QString& mode); + void fullscreenChanged(bool isFullscreen); + void rateChanged(double rate); + void queueChanged(bool canNext, bool canPrevious); + void playbackStopped(bool isNavigating); + void durationChanged(qint64 durationMs); + void playbackStateChanged(const QString& state); + void positionChanged(qint64 positionMs); + void seekPerformed(qint64 positionMs); + void metadataChanged(const QVariantMap& metadata, const QString& baseUrl); + void volumeChanged(double volume); private: // this is the function actually implemented in the backends. the variantmap contains // a few known keys: @@ -255,6 +289,11 @@ private: QVariant m_currentSubtitleStream; QVariant m_currentAudioStream; QRect m_videoRectangle; + + QVariantList m_webPlaylist; + QString m_currentWebPlaylistItemId; + QTimer* m_playlistTimer; + QVariantList m_queuedItems; }; #endif // PLAYERCOMPONENT_H diff --git a/src/ui/WindowManager.cpp b/src/ui/WindowManager.cpp index 9331bb7..6ed8018 100644 --- a/src/ui/WindowManager.cpp +++ b/src/ui/WindowManager.cpp @@ -254,6 +254,8 @@ void WindowManager::onVisibilityChanged(QWindow::Visibility visibility) { section->setValueNoSave("fullscreen", m_fullscreen); } + + emit fullScreenSwitched(); } // Save geometry when exiting fullscreen diff --git a/src/ui/WindowManager.h b/src/ui/WindowManager.h index e547457..037848b 100644 --- a/src/ui/WindowManager.h +++ b/src/ui/WindowManager.h @@ -49,6 +49,9 @@ public: public slots: void toggleFullscreen(); +signals: + void fullScreenSwitched(); + private slots: void onVisibilityChanged(QWindow::Visibility visibility); void onScreenAdded(QScreen* screen);