diff --git a/CMakeLists.txt b/CMakeLists.txt index 0bf20ab..2d17758 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,6 +20,7 @@ option(HYPRLAND_GLOBAL_SHORTCUTS "Hyprland Global Shortcuts" ON) option(HYPRLAND_FOCUS_GRAB "Hyprland Focus Grabbing" ON) option(SERVICE_STATUS_NOTIFIER "StatusNotifierItem service" ON) option(SERVICE_PIPEWIRE "PipeWire service" ON) +option(SERVICE_MPRIS "Mpris service" ON) message(STATUS "Quickshell configuration") message(STATUS " NVIDIA workarounds: ${NVIDIA_COMPAT}") @@ -34,6 +35,7 @@ message(STATUS " X11: ${X11}") message(STATUS " Services") message(STATUS " StatusNotifier: ${SERVICE_STATUS_NOTIFIER}") message(STATUS " PipeWire: ${SERVICE_PIPEWIRE}") +message(STATUS " Mpris: ${SERVICE_MPRIS}") message(STATUS " Hyprland: ${HYPRLAND}") if (HYPRLAND) message(STATUS " Focus Grabbing: ${HYPRLAND_FOCUS_GRAB}") @@ -89,7 +91,7 @@ if (WAYLAND) list(APPEND QT_FPDEPS WaylandClient) endif() -if (SERVICE_STATUS_NOTIFIER) +if (SERVICE_STATUS_NOTIFIER OR SERVICE_MPRIS) set(DBUS ON) endif() diff --git a/src/dbus/dbusmenu/dbusmenu.hpp b/src/dbus/dbusmenu/dbusmenu.hpp index b07919a..ab485c4 100644 --- a/src/dbus/dbusmenu/dbusmenu.hpp +++ b/src/dbus/dbusmenu/dbusmenu.hpp @@ -188,9 +188,9 @@ public: dbus::DBusPropertyGroup properties; dbus::DBusProperty version {this->properties, "Version"}; - dbus::DBusProperty textDirection {this->properties, "TextDirection"}; + dbus::DBusProperty textDirection {this->properties, "TextDirection", "", false}; dbus::DBusProperty status {this->properties, "Status"}; - dbus::DBusProperty iconThemePath {this->properties, "IconThemePath"}; + dbus::DBusProperty iconThemePath {this->properties, "IconThemePath", {}, false}; void prepareToShow(qint32 item, bool sendOpened); void updateLayout(qint32 parent, qint32 depth); diff --git a/src/dbus/properties.cpp b/src/dbus/properties.cpp index 1e5e0bd..7dac84a 100644 --- a/src/dbus/properties.cpp +++ b/src/dbus/properties.cpp @@ -112,6 +112,8 @@ void asyncReadPropertyInternal( } void AbstractDBusProperty::tryUpdate(const QVariant& variant) { + this->mExists = true; + auto error = this->read(variant); if (error.isValid()) { qCWarning(logDbusProperties).noquote() @@ -159,6 +161,44 @@ void AbstractDBusProperty::update() { } } +void AbstractDBusProperty::write() { + if (this->group == nullptr) { + qFatal(logDbusProperties) << "Tried to write dbus property" << this->name + << "which is not attached to a group"; + } else { + const QString propStr = this->toString(); + + if (this->group->interface == nullptr) { + qFatal(logDbusProperties).noquote() + << "Tried to write property" << propStr << "of a disconnected interface"; + } + + qCDebug(logDbusProperties).noquote() << "Writing property" << propStr; + + auto pendingCall = this->group->propertyInterface->Set( + this->group->interface->interface(), + this->name, + QDBusVariant(this->serialize()) + ); + + auto* call = new QDBusPendingCallWatcher(pendingCall, this); + + auto responseCallback = [propStr](QDBusPendingCallWatcher* call) { + const QDBusPendingReply<> reply = *call; + + if (reply.isError()) { + qCWarning(logDbusProperties).noquote() << "Error writing property" << propStr; + qCWarning(logDbusProperties) << reply.error(); + } + delete call; + }; + + QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); + } +} + +bool AbstractDBusProperty::exists() const { return this->mExists; } + QString AbstractDBusProperty::toString() const { const QString group = this->group == nullptr ? "{ NO GROUP }" : this->group->toString(); return group + ':' + this->name; @@ -232,7 +272,7 @@ void DBusPropertyGroup::updateAllViaGetAll() { } else { qCDebug(logDbusProperties).noquote() << "Received GetAll property set for" << this->toString(); - this->updatePropertySet(reply.value()); + this->updatePropertySet(reply.value(), true); } delete call; @@ -242,7 +282,7 @@ void DBusPropertyGroup::updateAllViaGetAll() { QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); } -void DBusPropertyGroup::updatePropertySet(const QVariantMap& properties) { +void DBusPropertyGroup::updatePropertySet(const QVariantMap& properties, bool complainMissing) { for (const auto [name, value]: properties.asKeyValueRange()) { auto prop = std::find_if( this->properties.begin(), @@ -251,11 +291,21 @@ void DBusPropertyGroup::updatePropertySet(const QVariantMap& properties) { ); if (prop == this->properties.end()) { - qCDebug(logDbusProperties) << "Ignoring untracked property update" << name << "for" << this; + qCDebug(logDbusProperties) << "Ignoring untracked property update" << name << "for" + << this->toString(); } else { (*prop)->tryUpdate(value); } } + + if (complainMissing) { + for (const auto* prop: this->properties) { + if (prop->required && !properties.contains(prop->name)) { + qCWarning(logDbusProperties) + << prop->name << "missing from property set for" << this->toString(); + } + } + } } QString DBusPropertyGroup::toString() const { @@ -291,7 +341,7 @@ void DBusPropertyGroup::onPropertiesChanged( } } - this->updatePropertySet(changedProperties); + this->updatePropertySet(changedProperties, false); } } // namespace qs::dbus diff --git a/src/dbus/properties.hpp b/src/dbus/properties.hpp index 3aac07f..e24d23f 100644 --- a/src/dbus/properties.hpp +++ b/src/dbus/properties.hpp @@ -79,22 +79,31 @@ class AbstractDBusProperty: public QObject { Q_OBJECT; public: - explicit AbstractDBusProperty(QString name, const QMetaType& type, QObject* parent = nullptr) + explicit AbstractDBusProperty( + QString name, + const QMetaType& type, + bool required, + QObject* parent = nullptr + ) : QObject(parent) , name(std::move(name)) - , type(type) {} + , type(type) + , required(required) {} + [[nodiscard]] bool exists() const; [[nodiscard]] QString toString() const; [[nodiscard]] virtual QString valueString() = 0; public slots: void update(); + void write(); signals: void changed(); protected: virtual QDBusError read(const QVariant& variant) = 0; + virtual QVariant serialize() = 0; private: void tryUpdate(const QVariant& variant); @@ -103,6 +112,8 @@ private: QString name; QMetaType type; + bool required; + bool mExists = false; friend class DBusPropertyGroup; }; @@ -133,7 +144,7 @@ private slots: ); private: - void updatePropertySet(const QVariantMap& properties); + void updatePropertySet(const QVariantMap& properties, bool complainMissing); DBusPropertiesInterface* propertyInterface = nullptr; QDBusAbstractInterface* interface = nullptr; @@ -145,17 +156,23 @@ private: template class DBusProperty: public AbstractDBusProperty { public: - explicit DBusProperty(QString name, QObject* parent = nullptr, T value = T()) - : AbstractDBusProperty(std::move(name), QMetaType::fromType(), parent) + explicit DBusProperty( + QString name, + T value = T(), + bool required = true, + QObject* parent = nullptr + ) + : AbstractDBusProperty(std::move(name), QMetaType::fromType(), required, parent) , value(std::move(value)) {} explicit DBusProperty( DBusPropertyGroup& group, QString name, - QObject* parent = nullptr, - T value = T() + T value = T(), + bool required = true, + QObject* parent = nullptr ) - : DBusProperty(std::move(name), parent, std::move(value)) { + : DBusProperty(std::move(name), std::move(value), required, parent) { group.attachProperty(this); } @@ -165,7 +182,7 @@ public: return str; } - [[nodiscard]] T get() const { return this->value; } + [[nodiscard]] const T& get() const { return this->value; } void set(T value) { this->value = std::move(value); @@ -183,6 +200,8 @@ protected: return result.error; } + QVariant serialize() override { return QVariant::fromValue(this->value); } + private: T value; diff --git a/src/services/CMakeLists.txt b/src/services/CMakeLists.txt index 091a7ec..4915762 100644 --- a/src/services/CMakeLists.txt +++ b/src/services/CMakeLists.txt @@ -5,3 +5,7 @@ endif() if (SERVICE_PIPEWIRE) add_subdirectory(pipewire) endif() + +if (SERVICE_MPRIS) + add_subdirectory(mpris) +endif() diff --git a/src/services/mpris/CMakeLists.txt b/src/services/mpris/CMakeLists.txt new file mode 100644 index 0000000..3ee9606 --- /dev/null +++ b/src/services/mpris/CMakeLists.txt @@ -0,0 +1,39 @@ +set_source_files_properties(org.mpris.MediaPlayer2.Player.xml PROPERTIES + CLASSNAME DBusMprisPlayer + NO_NAMESPACE TRUE +) + +qt_add_dbus_interface(DBUS_INTERFACES + org.mpris.MediaPlayer2.Player.xml + dbus_player +) + +set_source_files_properties(org.mpris.MediaPlayer2.xml PROPERTIES + CLASSNAME DBusMprisPlayerApp + NO_NAMESPACE TRUE +) + +qt_add_dbus_interface(DBUS_INTERFACES + org.mpris.MediaPlayer2.xml + dbus_player_app +) + +qt_add_library(quickshell-service-mpris STATIC + player.cpp + watcher.cpp + ${DBUS_INTERFACES} +) + +# dbus headers +target_include_directories(quickshell-service-mpris PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) + +qt_add_qml_module(quickshell-service-mpris + URI Quickshell.Services.Mpris + VERSION 0.1 +) + +target_link_libraries(quickshell-service-mpris PRIVATE ${QT_DEPS} quickshell-dbus) +target_link_libraries(quickshell PRIVATE quickshell-service-mprisplugin) + +qs_pch(quickshell-service-mpris) +qs_pch(quickshell-service-mprisplugin) diff --git a/src/services/mpris/module.md b/src/services/mpris/module.md new file mode 100644 index 0000000..e2256e8 --- /dev/null +++ b/src/services/mpris/module.md @@ -0,0 +1,7 @@ +name = "Quickshell.Services.Mpris" +description = "Mpris Service" +headers = [ + "player.hpp", + "watcher.hpp", +] +----- diff --git a/src/services/mpris/org.mpris.MediaPlayer2.Player.xml b/src/services/mpris/org.mpris.MediaPlayer2.Player.xml new file mode 100644 index 0000000..a009523 --- /dev/null +++ b/src/services/mpris/org.mpris.MediaPlayer2.Player.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/services/mpris/org.mpris.MediaPlayer2.xml b/src/services/mpris/org.mpris.MediaPlayer2.xml new file mode 100644 index 0000000..fa880d0 --- /dev/null +++ b/src/services/mpris/org.mpris.MediaPlayer2.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/services/mpris/player.cpp b/src/services/mpris/player.cpp new file mode 100644 index 0000000..3b0c746 --- /dev/null +++ b/src/services/mpris/player.cpp @@ -0,0 +1,427 @@ +#include "player.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../dbus/properties.hpp" +#include "dbus_player.h" +#include "dbus_player_app.h" + +using namespace qs::dbus; + +namespace qs::service::mpris { + +Q_LOGGING_CATEGORY(logMprisPlayer, "quickshell.service.mp.player", QtWarningMsg); + +QString MprisPlaybackState::toString(MprisPlaybackState::Enum status) { + switch (status) { + case MprisPlaybackState::Stopped: return "Stopped"; + case MprisPlaybackState::Playing: return "Playing"; + case MprisPlaybackState::Paused: return "Paused"; + default: return "Unknown Status"; + } +} + +QString MprisLoopState::toString(MprisLoopState::Enum status) { + switch (status) { + case MprisLoopState::None: return "None"; + case MprisLoopState::Track: return "Track"; + case MprisLoopState::Playlist: return "Playlist"; + default: return "Unknown Status"; + } +} + +MprisPlayer::MprisPlayer(const QString& address, QObject* parent): QObject(parent) { + this->app = new DBusMprisPlayerApp( + address, + "/org/mpris/MediaPlayer2", + QDBusConnection::sessionBus(), + this + ); + + this->player = + new DBusMprisPlayer(address, "/org/mpris/MediaPlayer2", QDBusConnection::sessionBus(), this); + + if (!this->player->isValid() || !this->app->isValid()) { + qCWarning(logMprisPlayer) << "Cannot create MprisPlayer for" << address; + return; + } + + // clang-format off + QObject::connect(&this->pCanQuit, &AbstractDBusProperty::changed, this, &MprisPlayer::canQuitChanged); + QObject::connect(&this->pCanRaise, &AbstractDBusProperty::changed, this, &MprisPlayer::canRaiseChanged); + QObject::connect(&this->pCanSetFullscreen, &AbstractDBusProperty::changed, this, &MprisPlayer::canSetFullscreenChanged); + QObject::connect(&this->pIdentity, &AbstractDBusProperty::changed, this, &MprisPlayer::identityChanged); + QObject::connect(&this->pFullscreen, &AbstractDBusProperty::changed, this, &MprisPlayer::fullscreenChanged); + QObject::connect(&this->pSupportedUriSchemes, &AbstractDBusProperty::changed, this, &MprisPlayer::supportedUriSchemesChanged); + QObject::connect(&this->pSupportedMimeTypes, &AbstractDBusProperty::changed, this, &MprisPlayer::supportedMimeTypesChanged); + + QObject::connect(&this->pCanControl, &AbstractDBusProperty::changed, this, &MprisPlayer::canControlChanged); + QObject::connect(&this->pCanSeek, &AbstractDBusProperty::changed, this, &MprisPlayer::canSeekChanged); + QObject::connect(&this->pCanGoNext, &AbstractDBusProperty::changed, this, &MprisPlayer::canGoNextChanged); + QObject::connect(&this->pCanGoPrevious, &AbstractDBusProperty::changed, this, &MprisPlayer::canGoPreviousChanged); + QObject::connect(&this->pCanPlay, &AbstractDBusProperty::changed, this, &MprisPlayer::canPlayChanged); + QObject::connect(&this->pCanPause, &AbstractDBusProperty::changed, this, &MprisPlayer::canPauseChanged); + QObject::connect(&this->pPosition, &AbstractDBusProperty::changed, this, &MprisPlayer::onPositionChanged); + QObject::connect(this->player, &DBusMprisPlayer::Seeked, this, &MprisPlayer::onSeek); + QObject::connect(&this->pVolume, &AbstractDBusProperty::changed, this, &MprisPlayer::volumeChanged); + QObject::connect(&this->pMetadata, &AbstractDBusProperty::changed, this, &MprisPlayer::onMetadataChanged); + QObject::connect(&this->pPlaybackStatus, &AbstractDBusProperty::changed, this, &MprisPlayer::onPlaybackStatusChanged); + QObject::connect(&this->pLoopStatus, &AbstractDBusProperty::changed, this, &MprisPlayer::onLoopStatusChanged); + QObject::connect(&this->pRate, &AbstractDBusProperty::changed, this, &MprisPlayer::rateChanged); + QObject::connect(&this->pMinRate, &AbstractDBusProperty::changed, this, &MprisPlayer::minRateChanged); + QObject::connect(&this->pMaxRate, &AbstractDBusProperty::changed, this, &MprisPlayer::maxRateChanged); + QObject::connect(&this->pShuffle, &AbstractDBusProperty::changed, this, &MprisPlayer::shuffleChanged); + + QObject::connect(&this->playerProperties, &DBusPropertyGroup::getAllFinished, this, &MprisPlayer::onGetAllFinished); + + // Ensure user triggered position updates can update length. + QObject::connect(this, &MprisPlayer::positionChanged, this, &MprisPlayer::onExportedPositionChanged); + // clang-format on + + this->appProperties.setInterface(this->app); + this->playerProperties.setInterface(this->player); + this->appProperties.updateAllViaGetAll(); + this->playerProperties.updateAllViaGetAll(); +} + +void MprisPlayer::raise() { + if (!this->canRaise()) { + qWarning() << "Cannot call raise() on" << this << "because canRaise is false."; + return; + } + + this->app->Raise(); +} + +void MprisPlayer::quit() { + if (!this->canQuit()) { + qWarning() << "Cannot call quit() on" << this << "because canQuit is false."; + return; + } + + this->app->Quit(); +} + +void MprisPlayer::openUri(const QString& uri) { this->player->OpenUri(uri); } + +void MprisPlayer::next() { + if (!this->canGoNext()) { + qWarning() << "Cannot call next() on" << this << "because canGoNext is false."; + return; + } + + this->player->Next(); +} + +void MprisPlayer::previous() { + if (!this->canGoPrevious()) { + qWarning() << "Cannot call previous() on" << this << "because canGoPrevious is false."; + return; + } + + this->player->Previous(); +} + +void MprisPlayer::seek(qreal offset) { + if (!this->canSeek()) { + qWarning() << "Cannot call seek() on" << this << "because canSeek is false."; + return; + } + + auto target = static_cast(offset * 1000) * 1000; + this->player->Seek(target); +} + +bool MprisPlayer::isValid() const { return this->player->isValid(); } +QString MprisPlayer::address() const { return this->player->service(); } + +bool MprisPlayer::canControl() const { return this->pCanControl.get(); } +bool MprisPlayer::canPlay() const { return this->canControl() && this->pCanPlay.get(); } +bool MprisPlayer::canPause() const { return this->canControl() && this->pCanPause.get(); } +bool MprisPlayer::canSeek() const { return this->canControl() && this->pCanSeek.get(); } +bool MprisPlayer::canGoNext() const { return this->canControl() && this->pCanGoNext.get(); } +bool MprisPlayer::canGoPrevious() const { return this->canControl() && this->pCanGoPrevious.get(); } +bool MprisPlayer::canQuit() const { return this->pCanQuit.get(); } +bool MprisPlayer::canRaise() const { return this->pCanRaise.get(); } +bool MprisPlayer::canSetFullscreen() const { return this->pCanSetFullscreen.get(); } + +QString MprisPlayer::identity() const { return this->pIdentity.get(); } + +qlonglong MprisPlayer::positionMs() const { + if (!this->positionSupported()) return 0; // unsupported + if (this->mPlaybackState == MprisPlaybackState::Stopped) return 0; + + auto paused = this->mPlaybackState == MprisPlaybackState::Paused; + auto time = paused ? this->pausedTime : QDateTime::currentDateTime(); + auto offset = time - this->lastPositionTimestamp; + auto rateMul = static_cast(this->pRate.get() * 1000); + offset = (offset * rateMul) / 1000; + + return (this->pPosition.get() / 1000) + offset.count(); +} + +qreal MprisPlayer::position() const { + if (!this->positionSupported()) return 0; // unsupported + if (this->mPlaybackState == MprisPlaybackState::Stopped) return 0; + + return static_cast(this->positionMs()) / 1000.0; // NOLINT +} + +bool MprisPlayer::positionSupported() const { return this->pPosition.exists(); } + +void MprisPlayer::setPosition(qreal position) { + if (this->pPosition.get() == -1) { + qWarning() << "Cannot set position of" << this << "because position is not supported."; + return; + } + + if (!this->canSeek()) { + qWarning() << "Cannot set position of" << this << "because canSeek is false."; + return; + } + + auto target = static_cast(position * 1000) * 1000; + this->pPosition.set(target); + + if (!this->mTrackId.isEmpty()) { + this->player->SetPosition(QDBusObjectPath(this->mTrackId), target); + return; + } else { + auto pos = this->positionMs() * 1000; + this->player->Seek(target - pos); + } +} + +void MprisPlayer::onPositionChanged() { + const bool firstChange = !this->lastPositionTimestamp.isValid(); + this->lastPositionTimestamp = QDateTime::currentDateTimeUtc(); + emit this->positionChanged(); + if (firstChange) emit this->positionSupportedChanged(); +} + +void MprisPlayer::onExportedPositionChanged() { + if (!this->lengthSupported()) emit this->lengthChanged(); +} + +void MprisPlayer::onSeek(qlonglong time) { this->pPosition.set(time); } + +qreal MprisPlayer::length() const { + if (this->mLength == -1) { + return this->position(); // unsupported + } else { + return static_cast(this->mLength / 1000) / 1000; // NOLINT + } +} + +bool MprisPlayer::lengthSupported() const { return this->mLength != -1; } + +qreal MprisPlayer::volume() const { return this->pVolume.get(); } +bool MprisPlayer::volumeSupported() const { return this->pVolume.exists(); } + +void MprisPlayer::setVolume(qreal volume) { + if (!this->canControl()) { + qWarning() << "Cannot set volume of" << this << "because canControl is false."; + return; + } + + if (!this->volumeSupported()) { + qWarning() << "Cannot set volume of" << this << "because volume is not supported."; + return; + } + + this->pVolume.set(volume); + this->pVolume.write(); +} + +QVariantMap MprisPlayer::metadata() const { return this->pMetadata.get(); } + +void MprisPlayer::onMetadataChanged() { + auto lengthVariant = this->pMetadata.get().value("mpris:length"); + qlonglong length = -1; + if (lengthVariant.isValid() && lengthVariant.canConvert()) { + length = lengthVariant.value(); + } + + if (length != this->mLength) { + this->mLength = length; + emit this->lengthChanged(); + } + + auto trackidVariant = this->pMetadata.get().value("mpris:trackid"); + if (trackidVariant.isValid() && trackidVariant.canConvert()) { + this->mTrackId = trackidVariant.value(); + this->onSeek(0); + } + + emit this->metadataChanged(); +} + +MprisPlaybackState::Enum MprisPlayer::playbackState() const { return this->mPlaybackState; } + +void MprisPlayer::setPlaybackState(MprisPlaybackState::Enum playbackState) { + if (playbackState == this->mPlaybackState) return; + + switch (playbackState) { + case MprisPlaybackState::Stopped: + if (!this->canControl()) { + qWarning() << "Cannot set playbackState of" << this + << "to Stopped because canControl is false."; + return; + } + + this->player->Stop(); + break; + case MprisPlaybackState::Playing: + if (!this->canPlay()) { + qWarning() << "Cannot set playbackState of" << this << "to Playing because canPlay is false."; + return; + } + + this->player->Play(); + break; + case MprisPlaybackState::Paused: + if (!this->canPause()) { + qWarning() << "Cannot set playbackState of" << this << "to Paused because canPause is false."; + return; + } + + this->player->Pause(); + break; + default: + qWarning() << "Cannot set playbackState of" << this << "to unknown value" << playbackState; + return; + } +} + +void MprisPlayer::onPlaybackStatusChanged() { + const auto& status = this->pPlaybackStatus.get(); + + if (status == "Playing") { + this->mPlaybackState = MprisPlaybackState::Playing; + } else if (status == "Paused") { + this->mPlaybackState = MprisPlaybackState::Paused; + } else if (status == "Stopped") { + this->mPlaybackState = MprisPlaybackState::Stopped; + } else { + this->mPlaybackState = MprisPlaybackState::Stopped; + qWarning() << "Received unexpected PlaybackStatus for" << this << status; + } + + emit this->playbackStateChanged(); +} + +MprisLoopState::Enum MprisPlayer::loopState() const { return this->mLoopState; } +bool MprisPlayer::loopSupported() const { return this->pLoopStatus.exists(); } + +void MprisPlayer::setLoopState(MprisLoopState::Enum loopState) { + if (!this->canControl()) { + qWarning() << "Cannot set loopState of" << this << "because canControl is false."; + return; + } + + if (!this->loopSupported()) { + qWarning() << "Cannot set loopState of" << this << "because loop state is not supported."; + return; + } + + if (loopState == this->mLoopState) return; + + QString loopStatusStr; + switch (loopState) { + case MprisLoopState::None: loopStatusStr = "None"; break; + case MprisLoopState::Track: loopStatusStr = "Track"; break; + case MprisLoopState::Playlist: loopStatusStr = "Playlist"; break; + default: + qWarning() << "Cannot set loopState of" << this << "to unknown value" << loopState; + return; + } + + this->pLoopStatus.set(loopStatusStr); + this->pLoopStatus.write(); +} + +void MprisPlayer::onLoopStatusChanged() { + const auto& status = this->pLoopStatus.get(); + + if (status == "None") { + this->mLoopState = MprisLoopState::None; + } else if (status == "Track") { + this->mLoopState = MprisLoopState::Track; + } else if (status == "Playlist") { + this->mLoopState = MprisLoopState::Playlist; + } else { + this->mLoopState = MprisLoopState::None; + qWarning() << "Received unexpected LoopStatus for" << this << status; + } + + emit this->loopStateChanged(); +} + +qreal MprisPlayer::rate() const { return this->pRate.get(); } +qreal MprisPlayer::minRate() const { return this->pMinRate.get(); } +qreal MprisPlayer::maxRate() const { return this->pMaxRate.get(); } + +void MprisPlayer::setRate(qreal rate) { + if (rate == this->pRate.get()) return; + + if (rate < this->pMinRate.get() || rate > this->pMaxRate.get()) { + qWarning() << "Cannot set rate for" << this << "to" << rate + << "which is outside of minRate and maxRate" << this->pMinRate.get() + << this->pMaxRate.get(); + return; + } + + this->pRate.set(rate); + this->pRate.write(); +} + +bool MprisPlayer::shuffle() const { return this->pShuffle.get(); } +bool MprisPlayer::shuffleSupported() const { return this->pShuffle.exists(); } + +void MprisPlayer::setShuffle(bool shuffle) { + if (!this->shuffleSupported()) { + qWarning() << "Cannot set shuffle for" << this << "because shuffle is not supported."; + return; + } + + if (!this->canControl()) { + qWarning() << "Cannot set shuffle state of" << this << "because canControl is false."; + return; + } + + this->pShuffle.set(shuffle); + this->pShuffle.write(); +} + +bool MprisPlayer::fullscreen() const { return this->pFullscreen.get(); } + +void MprisPlayer::setFullscreen(bool fullscreen) { + if (!this->canSetFullscreen()) { + qWarning() << "Cannot set fullscreen for" << this << "because canSetFullscreen is false."; + return; + } + + this->pFullscreen.set(fullscreen); + this->pFullscreen.write(); +} + +QList MprisPlayer::supportedUriSchemes() const { return this->pSupportedUriSchemes.get(); } +QList MprisPlayer::supportedMimeTypes() const { return this->pSupportedMimeTypes.get(); } + +void MprisPlayer::onGetAllFinished() { + if (this->volumeSupported()) emit this->volumeSupportedChanged(); + if (this->loopSupported()) emit this->loopSupportedChanged(); + if (this->shuffleSupported()) emit this->shuffleSupportedChanged(); + emit this->ready(); +} + +} // namespace qs::service::mpris diff --git a/src/services/mpris/player.hpp b/src/services/mpris/player.hpp new file mode 100644 index 0000000..0b18d78 --- /dev/null +++ b/src/services/mpris/player.hpp @@ -0,0 +1,324 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "../../core/doc.hpp" +#include "../../dbus/properties.hpp" +#include "dbus_player.h" +#include "dbus_player_app.h" + +namespace qs::service::mpris { + +class MprisPlaybackState: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum { + Stopped = 0, + Playing = 1, + Paused = 2, + }; + Q_ENUM(Enum); + + Q_INVOKABLE static QString toString(MprisPlaybackState::Enum status); +}; + +class MprisLoopState: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum { + None = 0, + Track = 1, + Playlist = 2, + }; + Q_ENUM(Enum); + + Q_INVOKABLE static QString toString(MprisLoopState::Enum status); +}; + +///! A media player exposed over MPRIS. +/// A media player exposed over MPRIS. +/// +/// > [!WARNING] Support for various functionality and general compliance to +/// > the MPRIS specification varies wildly by player. +/// > Always check the associated `canXyz` and `xyzSupported` properties if available. +/// +/// > [!INFO] The TrackList and Playlist interfaces were not implemented as we could not +/// > find any media players using them to test against. +class MprisPlayer: public QObject { + Q_OBJECT; + // clang-format off + Q_PROPERTY(bool canControl READ canControl NOTIFY canControlChanged); + Q_PROPERTY(bool canPlay READ canPlay NOTIFY canPlayChanged); + Q_PROPERTY(bool canPause READ canPause NOTIFY canPauseChanged); + Q_PROPERTY(bool canSeek READ canSeek NOTIFY canSeekChanged); + Q_PROPERTY(bool canGoNext READ canGoNext NOTIFY canGoNextChanged); + Q_PROPERTY(bool canGoPrevious READ canGoPrevious NOTIFY canGoPreviousChanged); + Q_PROPERTY(bool canQuit READ canQuit NOTIFY canQuitChanged); + Q_PROPERTY(bool canRaise READ canRaise NOTIFY canRaiseChanged); + Q_PROPERTY(bool canSetFullscreen READ canSetFullscreen NOTIFY canSetFullscreenChanged); + /// The human readable name of the media player. + Q_PROPERTY(QString identity READ identity NOTIFY identityChanged); + /// The current position in the playing track, as seconds, with millisecond precision, + /// or `0` if `positionSupported` is false. + /// + /// May only be written to if `canSeek` and `positionSupported` are true. + /// + /// > [!WARNING] To avoid excessive property updates wasting CPU while `position` is not + /// > actively monitored, `position` usually will not update reactively, unless a nonlinear + /// > change in position occurs, however reading it will always return the current position. + /// > + /// > If you want to actively monitor the position, the simplest way it to emit the `positionChanged` + /// > signal manually for the duration you are monitoring it, Using a [FrameAnimation] if you need + /// > the value to update smoothly, such as on a slider, or a [Timer] if not, as shown below. + /// > + /// > ```qml {filename="Using a FrameAnimation"} + /// > FrameAnimation { + /// > // only emit the signal when the position is actually changing. + /// > running: player.playbackState == MprisPlaybackState.Playing + /// > // emit the positionChanged signal every frame. + /// > onTriggered: player.positionChanged() + /// > } + /// > ``` + /// > + /// > ```qml {filename="Using a Timer"} + /// > Timer { + /// > // only emit the signal when the position is actually changing. + /// > running: player.playbackState == MprisPlaybackState.Playing + /// > // Make sure the position updates at least once per second. + /// > interval: 1000 + /// > repeat: true + /// > // emit the positionChanged signal every second. + /// > onTriggered: player.positionChanged() + /// > } + /// > ``` + /// + /// [FrameAnimation]: https://doc.qt.io/qt-6/qml-qtquick-frameanimation.html + /// [Timer]: https://doc.qt.io/qt-6/qml-qtqml-timer.html + Q_PROPERTY(qreal position READ position WRITE setPosition NOTIFY positionChanged); + Q_PROPERTY(bool positionSupported READ positionSupported NOTIFY positionSupportedChanged); + /// The length of the playing track, as seconds, with millisecond precision, + /// or the value of `position` if `lengthSupported` is false. + Q_PROPERTY(qreal length READ length NOTIFY lengthChanged); + Q_PROPERTY(bool lengthSupported READ lengthSupported NOTIFY lengthSupportedChanged); + /// The volume of the playing track from 0.0 to 1.0, or 1.0 if `volumeSupported` is false. + /// + /// May only be written to if `canControl` and `volumeSupported` are true. + Q_PROPERTY(qreal volume READ volume WRITE setVolume NOTIFY volumeChanged); + Q_PROPERTY(bool volumeSupported READ volumeSupported NOTIFY volumeSupportedChanged); + /// Metadata of the current track. + /// + /// A map of common properties is available [here](https://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata). + /// Do not count on any of them actually being present. + Q_PROPERTY(QVariantMap metadata READ metadata NOTIFY metadataChanged); + /// The playback state of the media player. + /// + /// - If `canPlay` is false, you cannot assign the `Playing` state. + /// - If `canPause` is false, you cannot assign the `Paused` state. + /// - If `canControl` is false, you cannot assign the `Stopped` state. + /// (or any of the others, though their repsective properties will also be false) + Q_PROPERTY(MprisPlaybackState::Enum playbackState READ playbackState WRITE setPlaybackState NOTIFY playbackStateChanged); + /// The loop state of the media player, or `None` if `loopSupported` is false. + /// + /// May only be written to if `canControl` and `loopSupported` are true. + Q_PROPERTY(MprisLoopState::Enum loopState READ loopState WRITE setLoopState NOTIFY loopStateChanged); + Q_PROPERTY(bool loopSupported READ loopSupported NOTIFY loopSupportedChanged); + /// The speed the song is playing at, as a multiplier. + /// + /// Only values between `minRate` and `maxRate` (inclusive) may be written to the property. + /// Additionally, It is recommended that you only write common values such as `0.25`, `0.5`, `1.0`, `2.0` + /// to the property, as media players are free to ignore the value, and are more likely to + /// accept common ones. + Q_PROPERTY(qreal rate READ rate WRITE setRate NOTIFY rateChanged); + Q_PROPERTY(qreal minRate READ minRate NOTIFY minRateChanged); + Q_PROPERTY(qreal maxRate READ maxRate NOTIFY maxRateChanged); + /// If the play queue is currently being shuffled, or false if `shuffleSupported` is false. + /// + /// May only be written if `canControl` and `shuffleSupported` are true. + Q_PROPERTY(bool shuffle READ shuffle WRITE setShuffle NOTIFY shuffleChanged); + Q_PROPERTY(bool shuffleSupported READ shuffleSupported NOTIFY shuffleSupportedChanged); + /// If the player is currently shown in fullscreen. + /// + /// May only be written to if `canSetFullscreen` is true. + Q_PROPERTY(bool fullscreen READ fullscreen WRITE setFullscreen NOTIFY fullscreenChanged); + /// Uri schemes supported by `openUri`. + Q_PROPERTY(QList supportedUriSchemes READ supportedUriSchemes NOTIFY supportedUriSchemesChanged); + /// Mime types supported by `openUri`. + Q_PROPERTY(QList supportedMimeTypes READ supportedMimeTypes NOTIFY supportedMimeTypesChanged); + // clang-format on + QML_ELEMENT; + QML_UNCREATABLE("MprisPlayers can only be acquired from Mpris"); + +public: + explicit MprisPlayer(const QString& address, QObject* parent = nullptr); + + /// Bring the media player to the front of the window stack. + /// + /// May only be called if `canRaise` is true. + Q_INVOKABLE void raise(); + /// Quit the media player. + /// + /// May only be called if `canQuit` is true. + Q_INVOKABLE void quit(); + /// Open the given URI in the media player. + /// + /// Many players will silently ignore this, especially if the uri + /// does not match `supportedUriSchemes` and `supportedMimeTypes`. + Q_INVOKABLE void openUri(const QString& uri); + /// Play the next song. + /// + /// May only be called if `canGoNext` is true. + Q_INVOKABLE void next(); + /// Play the previous song, or go back to the beginning of the current one. + /// + /// May only be called if `canGoPrevious` is true. + Q_INVOKABLE void previous(); + /// Change `position` by an offset. + /// + /// Even if `positionSupported` is false and you cannot set `position`, + /// this function may work. + /// + /// May only be called if `canSeek` is true. + Q_INVOKABLE void seek(qreal offset); + + [[nodiscard]] bool isValid() const; + [[nodiscard]] QString address() const; + + [[nodiscard]] bool canControl() const; + [[nodiscard]] bool canSeek() const; + [[nodiscard]] bool canGoNext() const; + [[nodiscard]] bool canGoPrevious() const; + [[nodiscard]] bool canPlay() const; + [[nodiscard]] bool canPause() const; + [[nodiscard]] bool canQuit() const; + [[nodiscard]] bool canRaise() const; + [[nodiscard]] bool canSetFullscreen() const; + + [[nodiscard]] QString identity() const; + + [[nodiscard]] qlonglong positionMs() const; + [[nodiscard]] qreal position() const; + [[nodiscard]] bool positionSupported() const; + void setPosition(qreal position); + + [[nodiscard]] qreal length() const; + [[nodiscard]] bool lengthSupported() const; + + [[nodiscard]] qreal volume() const; + [[nodiscard]] bool volumeSupported() const; + void setVolume(qreal volume); + + [[nodiscard]] QVariantMap metadata() const; + + [[nodiscard]] MprisPlaybackState::Enum playbackState() const; + void setPlaybackState(MprisPlaybackState::Enum playbackState); + + [[nodiscard]] MprisLoopState::Enum loopState() const; + [[nodiscard]] bool loopSupported() const; + void setLoopState(MprisLoopState::Enum loopState); + + [[nodiscard]] qreal rate() const; + [[nodiscard]] qreal minRate() const; + [[nodiscard]] qreal maxRate() const; + void setRate(qreal rate); + + [[nodiscard]] bool shuffle() const; + [[nodiscard]] bool shuffleSupported() const; + void setShuffle(bool shuffle); + + [[nodiscard]] bool fullscreen() const; + void setFullscreen(bool fullscreen); + + [[nodiscard]] QList supportedUriSchemes() const; + [[nodiscard]] QList supportedMimeTypes() const; + +signals: + QSDOC_HIDE void ready(); + void canControlChanged(); + void canPlayChanged(); + void canPauseChanged(); + void canSeekChanged(); + void canGoNextChanged(); + void canGoPreviousChanged(); + void canQuitChanged(); + void canRaiseChanged(); + void canSetFullscreenChanged(); + void identityChanged(); + void positionChanged(); + void positionSupportedChanged(); + void lengthChanged(); + void lengthSupportedChanged(); + void volumeChanged(); + void volumeSupportedChanged(); + void metadataChanged(); + void playbackStateChanged(); + void loopStateChanged(); + void loopSupportedChanged(); + void rateChanged(); + void minRateChanged(); + void maxRateChanged(); + void shuffleChanged(); + void shuffleSupportedChanged(); + void fullscreenChanged(); + void supportedUriSchemesChanged(); + void supportedMimeTypesChanged(); + +private slots: + void onGetAllFinished(); + void onPositionChanged(); + void onExportedPositionChanged(); + void onSeek(qlonglong time); + void onMetadataChanged(); + void onPlaybackStatusChanged(); + void onLoopStatusChanged(); + +private: + // clang-format off + dbus::DBusPropertyGroup appProperties; + dbus::DBusProperty pIdentity {this->appProperties, "Identity"}; + dbus::DBusProperty pCanQuit {this->appProperties, "CanQuit"}; + dbus::DBusProperty pCanRaise {this->appProperties, "CanRaise"}; + dbus::DBusProperty pFullscreen {this->appProperties, "Fullscreen", false, false}; + dbus::DBusProperty pCanSetFullscreen {this->appProperties, "CanSetFullscreen", false, false}; + dbus::DBusProperty> pSupportedUriSchemes {this->appProperties, "SupportedUriSchemes"}; + dbus::DBusProperty> pSupportedMimeTypes {this->appProperties, "SupportedMimeTypes"}; + + dbus::DBusPropertyGroup playerProperties; + dbus::DBusProperty pCanControl {this->playerProperties, "CanControl"}; + dbus::DBusProperty pCanPlay {this->playerProperties, "CanPlay"}; + dbus::DBusProperty pCanPause {this->playerProperties, "CanPause"}; + dbus::DBusProperty pCanSeek {this->playerProperties, "CanSeek"}; + dbus::DBusProperty pCanGoNext {this->playerProperties, "CanGoNext"}; + dbus::DBusProperty pCanGoPrevious {this->playerProperties, "CanGoPrevious"}; + dbus::DBusProperty pPosition {this->playerProperties, "Position", 0, false}; // "required" + dbus::DBusProperty pVolume {this->playerProperties, "Volume", 1, false}; // "required" + dbus::DBusProperty pMetadata {this->playerProperties, "Metadata"}; + dbus::DBusProperty pPlaybackStatus {this->playerProperties, "PlaybackStatus"}; + dbus::DBusProperty pLoopStatus {this->playerProperties, "LoopStatus", "", false}; + dbus::DBusProperty pRate {this->playerProperties, "Rate", 1, false}; // "required" + dbus::DBusProperty pMinRate {this->playerProperties, "MinimumRate", 1, false}; // "required" + dbus::DBusProperty pMaxRate {this->playerProperties, "MaximumRate", 1, false}; // "required" + dbus::DBusProperty pShuffle {this->playerProperties, "Shuffle", false, false}; + // clang-format on + + MprisPlaybackState::Enum mPlaybackState = MprisPlaybackState::Stopped; + MprisLoopState::Enum mLoopState = MprisLoopState::None; + QDateTime lastPositionTimestamp; + QDateTime pausedTime; + qlonglong mLength = -1; + + DBusMprisPlayerApp* app = nullptr; + DBusMprisPlayer* player = nullptr; + QString mTrackId; +}; + +} // namespace qs::service::mpris diff --git a/src/services/mpris/watcher.cpp b/src/services/mpris/watcher.cpp new file mode 100644 index 0000000..1e10766 --- /dev/null +++ b/src/services/mpris/watcher.cpp @@ -0,0 +1,126 @@ +#include "watcher.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "player.hpp" + +namespace qs::service::mpris { + +Q_LOGGING_CATEGORY(logMprisWatcher, "quickshell.service.mpris.watcher", QtWarningMsg); + +MprisWatcher::MprisWatcher(QObject* parent): QObject(parent) { + qCDebug(logMprisWatcher) << "Starting MprisWatcher"; + + auto bus = QDBusConnection::sessionBus(); + + if (!bus.isConnected()) { + qCWarning(logMprisWatcher) << "Could not connect to DBus. Mpris service will not work."; + return; + } + + // clang-format off + QObject::connect(&this->serviceWatcher, &QDBusServiceWatcher::serviceRegistered, this, &MprisWatcher::onServiceRegistered); + QObject::connect(&this->serviceWatcher, &QDBusServiceWatcher::serviceUnregistered, this, &MprisWatcher::onServiceUnregistered); + // clang-format on + + this->serviceWatcher.setWatchMode( + QDBusServiceWatcher::WatchForUnregistration | QDBusServiceWatcher::WatchForRegistration + ); + + this->serviceWatcher.addWatchedService("org.mpris.MediaPlayer2*"); + this->serviceWatcher.setConnection(bus); + + this->registerExisting(); +} + +void MprisWatcher::registerExisting() { + const QStringList& list = QDBusConnection::sessionBus().interface()->registeredServiceNames(); + for (const QString& service: list) { + if (service.startsWith("org.mpris.MediaPlayer2")) { + qCDebug(logMprisWatcher).noquote() << "Found Mpris service" << service; + this->registerPlayer(service); + } + } +} + +void MprisWatcher::onServiceRegistered(const QString& service) { + if (service.startsWith("org.mpris.MediaPlayer2")) { + qCDebug(logMprisWatcher).noquote() << "Mpris service " << service << " registered."; + this->registerPlayer(service); + } else { + qCWarning(logMprisWatcher) << "Got a registration event for untracked service" << service; + } +} + +void MprisWatcher::onServiceUnregistered(const QString& service) { + if (auto* player = this->mPlayers.value(service)) { + player->deleteLater(); + this->mPlayers.remove(service); + qCDebug(logMprisWatcher) << "Unregistered MprisPlayer" << service; + } else { + qCWarning(logMprisWatcher) << "Got service unregister event for untracked service" << service; + } +} + +void MprisWatcher::onPlayerReady() { + auto* player = qobject_cast(this->sender()); + this->readyPlayers.push_back(player); + emit this->playersChanged(); +} + +void MprisWatcher::onPlayerDestroyed(QObject* object) { + auto* player = static_cast(object); // NOLINT + + if (this->readyPlayers.removeOne(player)) { + emit this->playersChanged(); + } +} + +QQmlListProperty MprisWatcher::players() { + return QQmlListProperty( + this, + nullptr, + &MprisWatcher::playersCount, + &MprisWatcher::playerAt + ); +} + +qsizetype MprisWatcher::playersCount(QQmlListProperty* property) { + return static_cast(property->object)->readyPlayers.count(); // NOLINT +} + +MprisPlayer* MprisWatcher::playerAt(QQmlListProperty* property, qsizetype index) { + return static_cast(property->object)->readyPlayers.at(index); // NOLINT +} + +void MprisWatcher::registerPlayer(const QString& address) { + if (this->mPlayers.contains(address)) { + qCDebug(logMprisWatcher) << "Skipping duplicate registration of MprisPlayer" << address; + return; + } + + auto* player = new MprisPlayer(address, this); + if (!player->isValid()) { + qCWarning(logMprisWatcher) << "Ignoring invalid MprisPlayer registration of" << address; + delete player; + return; + } + + this->mPlayers.insert(address, player); + QObject::connect(player, &MprisPlayer::ready, this, &MprisWatcher::onPlayerReady); + QObject::connect(player, &QObject::destroyed, this, &MprisWatcher::onPlayerDestroyed); + + qCDebug(logMprisWatcher) << "Registered MprisPlayer" << address; +} + +} // namespace qs::service::mpris diff --git a/src/services/mpris/watcher.hpp b/src/services/mpris/watcher.hpp new file mode 100644 index 0000000..a1e4df7 --- /dev/null +++ b/src/services/mpris/watcher.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "player.hpp" + +namespace qs::service::mpris { + +///! Provides access to MprisPlayers. +class MprisWatcher: public QObject { + Q_OBJECT; + QML_NAMED_ELEMENT(Mpris); + QML_SINGLETON; + /// All connected MPRIS players. + Q_PROPERTY(QQmlListProperty players READ players NOTIFY playersChanged); + +public: + explicit MprisWatcher(QObject* parent = nullptr); + + [[nodiscard]] QQmlListProperty players(); + +signals: + void playersChanged(); + +private slots: + void onServiceRegistered(const QString& service); + void onServiceUnregistered(const QString& service); + void onPlayerReady(); + void onPlayerDestroyed(QObject* object); + +private: + static qsizetype playersCount(QQmlListProperty* property); + static MprisPlayer* playerAt(QQmlListProperty* property, qsizetype index); + + void registerExisting(); + void registerPlayer(const QString& address); + + QDBusServiceWatcher serviceWatcher; + QHash mPlayers; + QList readyPlayers; +}; + +} // namespace qs::service::mpris diff --git a/src/services/status_notifier/item.hpp b/src/services/status_notifier/item.hpp index 9dbf02f..aa41190 100644 --- a/src/services/status_notifier/item.hpp +++ b/src/services/status_notifier/item.hpp @@ -54,14 +54,14 @@ public: dbus::DBusProperty status {this->properties, "Status"}; dbus::DBusProperty category {this->properties, "Category"}; dbus::DBusProperty windowId {this->properties, "WindowId"}; - dbus::DBusProperty iconThemePath {this->properties, "IconThemePath"}; - dbus::DBusProperty iconName {this->properties, "IconName"}; - dbus::DBusProperty iconPixmaps {this->properties, "IconPixmap"}; + dbus::DBusProperty iconThemePath {this->properties, "IconThemePath", "", false}; + dbus::DBusProperty iconName {this->properties, "IconName", "", false}; // IconPixmap may be set + dbus::DBusProperty iconPixmaps {this->properties, "IconPixmap", {}, false}; // IconName may be set dbus::DBusProperty overlayIconName {this->properties, "OverlayIconName"}; dbus::DBusProperty overlayIconPixmaps {this->properties, "OverlayIconPixmap"}; dbus::DBusProperty attentionIconName {this->properties, "AttentionIconName"}; dbus::DBusProperty attentionIconPixmaps {this->properties, "AttentionIconPixmap"}; - dbus::DBusProperty attentionMovieName {this->properties, "AttentionMovieName"}; + dbus::DBusProperty attentionMovieName {this->properties, "AttentionMovieName", "", false}; dbus::DBusProperty tooltip {this->properties, "ToolTip"}; dbus::DBusProperty isMenu {this->properties, "ItemIsMenu"}; dbus::DBusProperty menuPath {this->properties, "Menu"};