diff --git a/.clang-tidy b/.clang-tidy index ff820f6..6362e66 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -36,6 +36,7 @@ Checks: > -readability-braces-around-statements, -readability-redundant-access-specifiers, -readability-else-after-return, + -readability-container-data-pointer, tidyfox-*, CheckOptions: performance-for-range-copy.WarnOnAllAutoCopies: true diff --git a/CMakeLists.txt b/CMakeLists.txt index 7eb81f5..159acd4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,6 +18,7 @@ option(HYPRLAND "Support hyprland specific features" ON) 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) message(STATUS "Quickshell configuration") message(STATUS " NVIDIA workarounds: ${NVIDIA_COMPAT}") @@ -30,6 +31,7 @@ if (WAYLAND) endif () message(STATUS " Services") message(STATUS " StatusNotifier: ${SERVICE_STATUS_NOTIFIER}") +message(STATUS " PipeWire: ${SERVICE_PIPEWIRE}") message(STATUS " Hyprland: ${HYPRLAND}") if (HYPRLAND) message(STATUS " Focus Grabbing: ${HYPRLAND_FOCUS_GRAB}") diff --git a/default.nix b/default.nix index 3f2029b..514c794 100644 --- a/default.nix +++ b/default.nix @@ -24,6 +24,7 @@ debug ? false, enableWayland ? true, + enablePipewire ? true, nvidiaCompat ? false, svgSupport ? true, # you almost always want this }: buildStdenv.mkDerivation { @@ -46,7 +47,8 @@ qt6.qtdeclarative ] ++ (lib.optionals enableWayland [ qt6.qtwayland wayland ]) - ++ (lib.optionals svgSupport [ qt6.qtsvg ]); + ++ (lib.optionals svgSupport [ qt6.qtsvg ]) + ++ (lib.optionals enablePipewire [ pipewire ]); QTWAYLANDSCANNER = lib.optionalString enableWayland "${qt6.qtwayland}/libexec/qtwaylandscanner"; @@ -62,7 +64,8 @@ cmakeFlags = [ "-DGIT_REVISION=${gitRev}" ] ++ lib.optional (!enableWayland) "-DWAYLAND=OFF" - ++ lib.optional nvidiaCompat "-DNVIDIA_COMPAT=ON"; + ++ lib.optional nvidiaCompat "-DNVIDIA_COMPAT=ON" + ++ lib.optional (!enablePipewire) "-DSERVICE_PIPEWIRE=OFF"; buildPhase = "ninjaBuildPhase"; enableParallelBuilding = true; diff --git a/docs b/docs index 149b784..ff5da84 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 149b784a5a4c40ada67cb9f6af5a5350678ab6d4 +Subproject commit ff5da84a8b258a9b2caaf978ddb6de23635d8903 diff --git a/src/services/CMakeLists.txt b/src/services/CMakeLists.txt index 56d7f66..091a7ec 100644 --- a/src/services/CMakeLists.txt +++ b/src/services/CMakeLists.txt @@ -1,3 +1,7 @@ if (SERVICE_STATUS_NOTIFIER) add_subdirectory(status_notifier) endif() + +if (SERVICE_PIPEWIRE) + add_subdirectory(pipewire) +endif() diff --git a/src/services/pipewire/CMakeLists.txt b/src/services/pipewire/CMakeLists.txt new file mode 100644 index 0000000..4fccdc0 --- /dev/null +++ b/src/services/pipewire/CMakeLists.txt @@ -0,0 +1,24 @@ +find_package(PkgConfig REQUIRED) +pkg_check_modules(pipewire REQUIRED IMPORTED_TARGET libpipewire-0.3) + +qt_add_library(quickshell-service-pipewire STATIC + qml.cpp + core.cpp + connection.cpp + registry.cpp + node.cpp + metadata.cpp + link.cpp +) + +qt_add_qml_module(quickshell-service-pipewire + URI Quickshell.Services.Pipewire + VERSION 0.1 +) + +target_link_libraries(quickshell-service-pipewire PRIVATE ${QT_DEPS} PkgConfig::pipewire) + +qs_pch(quickshell-service-pipewire) +qs_pch(quickshell-service-pipewireplugin) + +target_link_libraries(quickshell PRIVATE quickshell-service-pipewireplugin) diff --git a/src/services/pipewire/connection.cpp b/src/services/pipewire/connection.cpp new file mode 100644 index 0000000..ac4c5e6 --- /dev/null +++ b/src/services/pipewire/connection.cpp @@ -0,0 +1,23 @@ +#include "connection.hpp" + +#include + +namespace qs::service::pipewire { + +PwConnection::PwConnection(QObject* parent): QObject(parent) { + if (this->core.isValid()) { + this->registry.init(this->core); + } +} + +PwConnection* PwConnection::instance() { + static PwConnection* instance = nullptr; // NOLINT + + if (instance == nullptr) { + instance = new PwConnection(); + } + + return instance; +} + +} // namespace qs::service::pipewire diff --git a/src/services/pipewire/connection.hpp b/src/services/pipewire/connection.hpp new file mode 100644 index 0000000..fa27035 --- /dev/null +++ b/src/services/pipewire/connection.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "core.hpp" +#include "metadata.hpp" +#include "registry.hpp" + +namespace qs::service::pipewire { + +class PwConnection: public QObject { + Q_OBJECT; + +public: + explicit PwConnection(QObject* parent = nullptr); + + PwRegistry registry; + PwDefaultsMetadata defaults {&this->registry}; + + static PwConnection* instance(); + +private: + // init/destroy order is important. do not rearrange. + PwCore core; +}; + +} // namespace qs::service::pipewire diff --git a/src/services/pipewire/core.cpp b/src/services/pipewire/core.cpp new file mode 100644 index 0000000..4f99715 --- /dev/null +++ b/src/services/pipewire/core.cpp @@ -0,0 +1,87 @@ +#include "core.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace qs::service::pipewire { + +Q_LOGGING_CATEGORY(logLoop, "quickshell.service.pipewire.loop", QtWarningMsg); + +PwCore::PwCore(QObject* parent): QObject(parent), notifier(QSocketNotifier::Read) { + qCInfo(logLoop) << "Creating pipewire event loop."; + pw_init(nullptr, nullptr); + + this->loop = pw_loop_new(nullptr); + if (this->loop == nullptr) { + qCCritical(logLoop) << "Failed to create pipewire event loop."; + return; + } + + this->context = pw_context_new(this->loop, nullptr, 0); + if (this->context == nullptr) { + qCCritical(logLoop) << "Failed to create pipewire context."; + return; + } + + qCInfo(logLoop) << "Connecting to pipewire server."; + this->core = pw_context_connect(this->context, nullptr, 0); + if (this->core == nullptr) { + qCCritical(logLoop) << "Failed to connect pipewire context. Errno:" << errno; + return; + } + + qCInfo(logLoop) << "Linking pipewire event loop."; + // Tie the pw event loop into qt. + auto fd = pw_loop_get_fd(this->loop); + this->notifier.setSocket(fd); + QObject::connect(&this->notifier, &QSocketNotifier::activated, this, &PwCore::poll); + this->notifier.setEnabled(true); +} + +PwCore::~PwCore() { + qCInfo(logLoop) << "Destroying PwCore."; + + if (this->loop != nullptr) { + if (this->context != nullptr) { + if (this->core != nullptr) { + pw_core_disconnect(this->core); + } + + pw_context_destroy(this->context); + } + + pw_loop_destroy(this->loop); + } +} + +bool PwCore::isValid() const { + // others must init first + return this->core != nullptr; +} + +void PwCore::poll() const { + qCDebug(logLoop) << "Pipewire event loop received new events, iterating."; + // Spin pw event loop. + pw_loop_iterate(this->loop, 0); + qCDebug(logLoop) << "Done iterating pipewire event loop."; +} + +SpaHook::SpaHook() { // NOLINT + spa_zero(this->hook); +} + +void SpaHook::remove() { + spa_hook_remove(&this->hook); + spa_zero(this->hook); +} + +} // namespace qs::service::pipewire diff --git a/src/services/pipewire/core.hpp b/src/services/pipewire/core.hpp new file mode 100644 index 0000000..ebf5c63 --- /dev/null +++ b/src/services/pipewire/core.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace qs::service::pipewire { + +class PwCore: public QObject { + Q_OBJECT; + +public: + explicit PwCore(QObject* parent = nullptr); + ~PwCore() override; + Q_DISABLE_COPY_MOVE(PwCore); + + [[nodiscard]] bool isValid() const; + + pw_loop* loop = nullptr; + pw_context* context = nullptr; + pw_core* core = nullptr; + +private slots: + void poll() const; + +private: + QSocketNotifier notifier; +}; + +template +class PwObject { +public: + explicit PwObject(T* object = nullptr): object(object) {} + ~PwObject() { + pw_proxy_destroy(reinterpret_cast(this->object)); // NOLINT + } + + Q_DISABLE_COPY_MOVE(PwObject); + + T* object; +}; + +class SpaHook { +public: + explicit SpaHook(); + + void remove(); + spa_hook hook; +}; + +} // namespace qs::service::pipewire diff --git a/src/services/pipewire/link.cpp b/src/services/pipewire/link.cpp new file mode 100644 index 0000000..8370446 --- /dev/null +++ b/src/services/pipewire/link.cpp @@ -0,0 +1,184 @@ +#include "link.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "registry.hpp" + +namespace qs::service::pipewire { + +Q_LOGGING_CATEGORY(logLink, "quickshell.service.pipewire.link", QtWarningMsg); + +QString PwLinkState::toString(Enum value) { + return QString(pw_link_state_as_string(static_cast(value))); +} + +void PwLink::bindHooks() { + pw_link_add_listener(this->proxy(), &this->listener.hook, &PwLink::EVENTS, this); +} + +void PwLink::unbindHooks() { + this->listener.remove(); + this->setState(PW_LINK_STATE_UNLINKED); +} + +void PwLink::initProps(const spa_dict* props) { + qCDebug(logLink) << "Parsing initial SPA props of link" << this; + + const spa_dict_item* item = nullptr; + spa_dict_for_each(item, props) { + if (strcmp(item->key, "link.output.node") == 0) { + auto str = QString(item->value); + auto ok = false; + auto value = str.toInt(&ok); + if (ok) this->setOutputNode(value); + else { + qCWarning(logLink) << "Could not parse link.output.node for" << this << ":" << item->value; + } + } else if (strcmp(item->key, "link.input.node") == 0) { + auto str = QString(item->value); + auto ok = false; + auto value = str.toInt(&ok); + if (ok) this->setInputNode(value); + else { + qCWarning(logLink) << "Could not parse link.input.node for" << this << ":" << item->value; + } + } + } +} + +const pw_link_events PwLink::EVENTS = { + .version = PW_VERSION_LINK_EVENTS, + .info = &PwLink::onInfo, +}; + +void PwLink::onInfo(void* data, const struct pw_link_info* info) { + auto* self = static_cast(data); + qCDebug(logLink) << "Got link info update for" << self << "with mask" << info->change_mask; + self->setOutputNode(info->output_node_id); + self->setInputNode(info->input_node_id); + + if ((info->change_mask & PW_LINK_CHANGE_MASK_STATE) != 0) { + self->setState(info->state); + } +} + +quint32 PwLink::outputNode() const { return this->mOutputNode; } +quint32 PwLink::inputNode() const { return this->mInputNode; } +PwLinkState::Enum PwLink::state() const { return static_cast(this->mState); } + +void PwLink::setOutputNode(quint32 outputNode) { + if (outputNode == this->mOutputNode) return; + + if (this->mOutputNode != 0) { + qCWarning(logLink) << "Got unexpected output node update for" << this << "to" << outputNode; + } + + this->mOutputNode = outputNode; + qCDebug(logLink) << "Updated output node of" << this; +} + +void PwLink::setInputNode(quint32 inputNode) { + if (inputNode == this->mInputNode) return; + + if (this->mInputNode != 0) { + qCWarning(logLink) << "Got unexpected input node update for" << this << "to" << inputNode; + } + + this->mInputNode = inputNode; + qCDebug(logLink) << "Updated input node of" << this; +} + +void PwLink::setState(pw_link_state state) { + if (state == this->mState) return; + + this->mState = state; + qCDebug(logLink) << "Updated state of" << this; + emit this->stateChanged(); +} + +QDebug operator<<(QDebug debug, const PwLink* link) { + if (link == nullptr) { + debug << "PwLink(0x0)"; + } else { + auto saver = QDebugStateSaver(debug); + debug.nospace() << "PwLink(" << link->outputNode() << " -> " << link->inputNode() << ", " + << static_cast(link) << ", id="; + link->debugId(debug); + debug << ", state=" << link->state() << ')'; + } + + return debug; +} + +PwLinkGroup::PwLinkGroup(PwLink* firstLink, QObject* parent) + : QObject(parent) + , mOutputNode(firstLink->outputNode()) + , mInputNode(firstLink->inputNode()) { + this->tryAddLink(firstLink); +} + +void PwLinkGroup::ref() { + this->refcount++; + + if (this->refcount == 1) { + this->trackedLink = *this->links.begin(); + this->trackedLink->ref(); + QObject::connect(this->trackedLink, &PwLink::stateChanged, this, &PwLinkGroup::stateChanged); + emit this->stateChanged(); + } +} + +void PwLinkGroup::unref() { + if (this->refcount == 0) return; + this->refcount--; + + if (this->refcount == 0) { + this->trackedLink->unref(); + this->trackedLink = nullptr; + emit this->stateChanged(); + } +} + +quint32 PwLinkGroup::outputNode() const { return this->mOutputNode; } + +quint32 PwLinkGroup::inputNode() const { return this->mInputNode; } + +PwLinkState::Enum PwLinkGroup::state() const { + if (this->trackedLink == nullptr) { + return PwLinkState::Unlinked; + } else { + return this->trackedLink->state(); + } +} + +bool PwLinkGroup::tryAddLink(PwLink* link) { + if (link->outputNode() != this->mOutputNode || link->inputNode() != this->mInputNode) + return false; + + this->links.insert(link->id, link); + QObject::connect(link, &PwBindableObject::destroying, this, &PwLinkGroup::onLinkRemoved); + return true; +} + +void PwLinkGroup::onLinkRemoved(QObject* object) { + auto* link = static_cast(object); // NOLINT + this->links.remove(link->id); + + if (this->links.empty()) { + delete this; + } else if (link == this->trackedLink) { + this->trackedLink = *this->links.begin(); + QObject::connect(this->trackedLink, &PwLink::stateChanged, this, &PwLinkGroup::stateChanged); + emit this->stateChanged(); + } +} + +} // namespace qs::service::pipewire diff --git a/src/services/pipewire/link.hpp b/src/services/pipewire/link.hpp new file mode 100644 index 0000000..e5ff2ce --- /dev/null +++ b/src/services/pipewire/link.hpp @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "registry.hpp" + +namespace qs::service::pipewire { + +class PwLinkState: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum { + Error = PW_LINK_STATE_ERROR, + Unlinked = PW_LINK_STATE_UNLINKED, + Init = PW_LINK_STATE_INIT, + Negotiating = PW_LINK_STATE_NEGOTIATING, + Allocating = PW_LINK_STATE_ALLOCATING, + Paused = PW_LINK_STATE_PAUSED, + Active = PW_LINK_STATE_ACTIVE, + }; + Q_ENUM(Enum); + + Q_INVOKABLE static QString toString(PwLinkState::Enum value); +}; + +constexpr const char TYPE_INTERFACE_Link[] = PW_TYPE_INTERFACE_Link; // NOLINT +class PwLink: public PwBindable { // NOLINT + Q_OBJECT; + +public: + void bindHooks() override; + void unbindHooks() override; + void initProps(const spa_dict* props) override; + + [[nodiscard]] quint32 outputNode() const; + [[nodiscard]] quint32 inputNode() const; + [[nodiscard]] PwLinkState::Enum state() const; + +signals: + void stateChanged(); + +private: + static const pw_link_events EVENTS; + static void onInfo(void* data, const struct pw_link_info* info); + + void setOutputNode(quint32 outputNode); + void setInputNode(quint32 inputNode); + void setState(pw_link_state state); + + SpaHook listener; + + quint32 mOutputNode = 0; + quint32 mInputNode = 0; + pw_link_state mState = PW_LINK_STATE_UNLINKED; +}; + +QDebug operator<<(QDebug debug, const PwLink* link); + +class PwLinkGroup: public QObject { + Q_OBJECT; + +public: + explicit PwLinkGroup(PwLink* firstLink, QObject* parent = nullptr); + + void ref(); + void unref(); + + [[nodiscard]] quint32 outputNode() const; + [[nodiscard]] quint32 inputNode() const; + [[nodiscard]] PwLinkState::Enum state() const; + + QHash links; + + bool tryAddLink(PwLink* link); + +signals: + void stateChanged(); + +private slots: + void onLinkRemoved(QObject* object); + +private: + quint32 mOutputNode = 0; + quint32 mInputNode = 0; + PwLink* trackedLink = nullptr; + quint32 refcount = 0; +}; + +} // namespace qs::service::pipewire diff --git a/src/services/pipewire/metadata.cpp b/src/services/pipewire/metadata.cpp new file mode 100644 index 0000000..3a64a38 --- /dev/null +++ b/src/services/pipewire/metadata.cpp @@ -0,0 +1,146 @@ +#include "metadata.hpp" +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "registry.hpp" + +namespace qs::service::pipewire { + +Q_LOGGING_CATEGORY(logMeta, "quickshell.service.pipewire.metadata", QtWarningMsg); + +void PwMetadata::bindHooks() { + pw_metadata_add_listener(this->proxy(), &this->listener.hook, &PwMetadata::EVENTS, this); +} + +void PwMetadata::unbindHooks() { this->listener.remove(); } + +const pw_metadata_events PwMetadata::EVENTS = { + .version = PW_VERSION_METADATA_EVENTS, + .property = &PwMetadata::onProperty, +}; + +int PwMetadata::onProperty( + void* data, + quint32 subject, + const char* key, + const char* type, + const char* value +) { + auto* self = static_cast(data); + qCDebug(logMeta) << "Received metadata for" << self << "- subject:" << subject + << "key:" << QString(key) << "type:" << QString(type) + << "value:" << QString(value); + + emit self->registry->metadataUpdate(self, subject, key, type, value); + + // ideally we'd dealloc metadata that wasn't picked up but there's no information + // available about if updates can come in later, so I assume they can. + + return 0; // ??? - no docs and no reason for a callback to return an int +} + +PwDefaultsMetadata::PwDefaultsMetadata(PwRegistry* registry) { + QObject::connect( + registry, + &PwRegistry::metadataUpdate, + this, + &PwDefaultsMetadata::onMetadataUpdate + ); +} + +QString PwDefaultsMetadata::defaultSink() const { return this->mDefaultSink; } + +QString PwDefaultsMetadata::defaultSource() const { return this->mDefaultSource; } + +// we don't really care if the metadata objects are destroyed, but try to ref them so we get property updates +void PwDefaultsMetadata::onMetadataUpdate( + PwMetadata* metadata, + quint32 subject, + const char* key, + const char* /*type*/, + const char* value +) { + if (subject != 0) return; + + // non "configured" sinks and sources have lower priority as wireplumber seems to only change + // the "configured" ones. + + bool sink = false; + if (strcmp(key, "default.configured.audio.sink") == 0) { + sink = true; + this->sinkConfigured = true; + } else if ((!this->sinkConfigured && strcmp(key, "default.audio.sink") == 0)) { + sink = true; + } + + if (sink) { + this->defaultSinkHolder.setObject(metadata); + + auto newSink = PwDefaultsMetadata::parseNameSpaJson(value); + qCInfo(logMeta) << "Got default sink" << newSink << "configured:" << this->sinkConfigured; + if (newSink == this->mDefaultSink) return; + + this->mDefaultSink = newSink; + emit this->defaultSinkChanged(); + return; + } + + bool source = false; + if (strcmp(key, "default.configured.audio.source") == 0) { + source = true; + this->sourceConfigured = true; + } else if ((!this->sourceConfigured && strcmp(key, "default.audio.source") == 0)) { + source = true; + } + + if (source) { + this->defaultSourceHolder.setObject(metadata); + + auto newSource = PwDefaultsMetadata::parseNameSpaJson(value); + qCInfo(logMeta) << "Got default source" << newSource << "configured:" << this->sourceConfigured; + if (newSource == this->mDefaultSource) return; + + this->mDefaultSource = newSource; + emit this->defaultSourceChanged(); + return; + } +} + +QString PwDefaultsMetadata::parseNameSpaJson(const char* spaJson) { + auto iter = std::array(); + spa_json_init(&iter[0], spaJson, strlen(spaJson)); + + if (spa_json_enter_object(&iter[0], &iter[1]) < 0) { + qCWarning(logMeta) << "Failed to parse source/sink SPA json - failed to enter object of" + << QString(spaJson); + return ""; + } + + auto buf = std::array(); + while (spa_json_get_string(&iter[1], buf.data(), buf.size()) > 0) { + if (strcmp(buf.data(), "name") != 0) continue; + + if (spa_json_get_string(&iter[1], buf.data(), buf.size()) < 0) { + qCWarning(logMeta + ) << "Failed to parse source/sink SPA json - failed to read value of name property" + << QString(spaJson); + return ""; + } + + return QString(buf.data()); + } + + qCWarning(logMeta) << "Failed to parse source/sink SPA json - failed to find name property of" + << QString(spaJson); + return ""; +} + +} // namespace qs::service::pipewire diff --git a/src/services/pipewire/metadata.hpp b/src/services/pipewire/metadata.hpp new file mode 100644 index 0000000..4937a74 --- /dev/null +++ b/src/services/pipewire/metadata.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include + +#include "core.hpp" +#include "registry.hpp" + +namespace qs::service::pipewire { + +constexpr const char TYPE_INTERFACE_Metadata[] = PW_TYPE_INTERFACE_Metadata; // NOLINT +class PwMetadata + : public PwBindable { // NOLINT + Q_OBJECT; + +public: + void bindHooks() override; + void unbindHooks() override; + +private: + static const pw_metadata_events EVENTS; + static int + onProperty(void* data, quint32 subject, const char* key, const char* type, const char* value); + + SpaHook listener; +}; + +class PwDefaultsMetadata: public QObject { + Q_OBJECT; + +public: + explicit PwDefaultsMetadata(PwRegistry* registry); + + [[nodiscard]] QString defaultSource() const; + [[nodiscard]] QString defaultSink() const; + +signals: + void defaultSourceChanged(); + void defaultSinkChanged(); + +private slots: + void onMetadataUpdate( + PwMetadata* metadata, + quint32 subject, + const char* key, + const char* type, + const char* value + ); + +private: + static QString parseNameSpaJson(const char* spaJson); + + PwBindableRef defaultSinkHolder; + PwBindableRef defaultSourceHolder; + + bool sinkConfigured = false; + QString mDefaultSink; + bool sourceConfigured = false; + QString mDefaultSource; +}; + +} // namespace qs::service::pipewire diff --git a/src/services/pipewire/node.cpp b/src/services/pipewire/node.cpp new file mode 100644 index 0000000..969a8b7 --- /dev/null +++ b/src/services/pipewire/node.cpp @@ -0,0 +1,384 @@ +#include "node.hpp" +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace qs::service::pipewire { + +Q_LOGGING_CATEGORY(logNode, "quickshell.service.pipewire.node", QtWarningMsg); + +QString PwAudioChannel::toString(Enum value) { + switch (value) { + case Unknown: return "Unknown"; + case NA: return "N/A"; + case Mono: return "Mono"; + case FrontCenter: return "Front Center"; + case FrontLeft: return "Front Left"; + case FrontRight: return "Front Right"; + case FrontLeftCenter: return "Front Left Center"; + case FrontRightCenter: return "Front Right Center"; + case FrontLeftWide: return "Front Left Wide"; + case FrontRightWide: return "Front Right Wide"; + case FrontCenterHigh: return "Front Center High"; + case FrontLeftHigh: return "Front Left High"; + case FrontRightHigh: return "Front Right High"; + case LowFrequencyEffects: return "Low Frequency Effects"; + case LowFrequencyEffects2: return "Low Frequency Effects 2"; + case LowFrequencyEffectsLeft: return "Low Frequency Effects Left"; + case LowFrequencyEffectsRight: return "Low Frequency Effects Right"; + case SideLeft: return "Side Left"; + case SideRight: return "Side Right"; + case RearCenter: return "Rear Center"; + case RearLeft: return "Rear Left"; + case RearRight: return "Rear Right"; + case RearLeftCenter: return "Rear Left Center"; + case RearRightCenter: return "Rear Right Center"; + case TopCenter: return "Top Center"; + case TopFrontCenter: return "Top Front Center"; + case TopFrontLeft: return "Top Front Left"; + case TopFrontRight: return "Top Front Right"; + case TopFrontLeftCenter: return "Top Front Left Center"; + case TopFrontRightCenter: return "Top Front Right Center"; + case TopSideLeft: return "Top Side Left"; + case TopSideRight: return "Top Side Right"; + case TopRearCenter: return "Top Rear Center"; + case TopRearLeft: return "Top Rear Left"; + case TopRearRight: return "Top Rear Right"; + case BottomCenter: return "Bottom Center"; + case BottomLeftCenter: return "Bottom Left Center"; + case BottomRightCenter: return "Bottom Right Center"; + default: + if (value >= AuxRangeStart && value <= AuxRangeEnd) { + return QString("Aux %1").arg(value - AuxRangeStart + 1); + } else if (value >= CustomRangeStart) { + return QString("Custom %1").arg(value - CustomRangeStart + 1); + } else { + return "Unknown"; + } + } +} + +void PwNode::bindHooks() { + pw_node_add_listener(this->proxy(), &this->listener.hook, &PwNode::EVENTS, this); +} + +void PwNode::unbindHooks() { + this->listener.remove(); + this->properties.clear(); + emit this->propertiesChanged(); + + if (this->boundData != nullptr) { + this->boundData->onUnbind(); + } +} + +void PwNode::initProps(const spa_dict* props) { + if (const auto* mediaClass = spa_dict_lookup(props, SPA_KEY_MEDIA_CLASS)) { + if (strcmp(mediaClass, "Audio/Sink") == 0) { + this->type = PwNodeType::Audio; + this->isSink = true; + this->isStream = false; + } else if (strcmp(mediaClass, "Audio/Source") == 0) { + this->type = PwNodeType::Audio; + this->isSink = false; + this->isStream = false; + } else if (strcmp(mediaClass, "Stream/Output/Audio") == 0) { + this->type = PwNodeType::Audio; + this->isSink = false; + this->isStream = true; + } else if (strcmp(mediaClass, "Stream/Input/Audio") == 0) { + this->type = PwNodeType::Audio; + this->isSink = true; + this->isStream = true; + } + } + + if (const auto* nodeName = spa_dict_lookup(props, SPA_KEY_NODE_NAME)) { + this->name = nodeName; + } + + if (const auto* nodeDesc = spa_dict_lookup(props, SPA_KEY_NODE_DESCRIPTION)) { + this->description = nodeDesc; + } + + if (const auto* nodeNick = spa_dict_lookup(props, "node.nick")) { + this->nick = nodeNick; + } + + if (this->type == PwNodeType::Audio) { + this->boundData = new PwNodeBoundAudio(this); + } +} + +const pw_node_events PwNode::EVENTS = { + .version = PW_VERSION_NODE_EVENTS, + .info = &PwNode::onInfo, + .param = &PwNode::onParam, +}; + +void PwNode::onInfo(void* data, const pw_node_info* info) { + auto* self = static_cast(data); + + if ((info->change_mask & PW_NODE_CHANGE_MASK_PROPS) != 0) { + auto properties = QMap(); + + const spa_dict_item* item = nullptr; + spa_dict_for_each(item, info->props) { properties.insert(item->key, item->value); } + + self->properties = properties; + emit self->propertiesChanged(); + } + + if (self->boundData != nullptr) { + self->boundData->onInfo(info); + } +} + +void PwNode::onParam( + void* data, + qint32 /*seq*/, + quint32 id, + quint32 index, + quint32 /*next*/, + const spa_pod* param +) { + auto* self = static_cast(data); + if (self->boundData != nullptr) { + self->boundData->onSpaParam(id, index, param); + } +} + +void PwNodeBoundAudio::onInfo(const pw_node_info* info) { + if ((info->change_mask & PW_NODE_CHANGE_MASK_PARAMS) != 0) { + for (quint32 i = 0; i < info->n_params; i++) { + auto& param = info->params[i]; // NOLINT + + if (param.id == SPA_PARAM_Props && (param.flags & SPA_PARAM_INFO_READ) != 0) { + pw_node_enum_params(this->node->proxy(), 0, param.id, 0, UINT32_MAX, nullptr); + } + } + } +} + +void PwNodeBoundAudio::onSpaParam(quint32 id, quint32 index, const spa_pod* param) { + if (id == SPA_PARAM_Props && index == 0) { + this->updateVolumeFromParam(param); + this->updateMutedFromParam(param); + } +} + +void PwNodeBoundAudio::updateVolumeFromParam(const spa_pod* param) { + const auto* volumesProp = spa_pod_find_prop(param, nullptr, SPA_PROP_channelVolumes); + const auto* channelsProp = spa_pod_find_prop(param, nullptr, SPA_PROP_channelMap); + + if (volumesProp == nullptr) { + qCWarning(logNode) << "Cannot update volume props of" << this->node + << "- channelVolumes was null."; + return; + } + + if (channelsProp == nullptr) { + qCWarning(logNode) << "Cannot update volume props of" << this->node << "- channelMap was null."; + return; + } + + if (spa_pod_is_array(&volumesProp->value) == 0) { + qCWarning(logNode) << "Cannot update volume props of" << this->node + << "- channelVolumes was not an array."; + return; + } + + if (spa_pod_is_array(&channelsProp->value) == 0) { + qCWarning(logNode) << "Cannot update volume props of" << this->node + << "- channelMap was not an array."; + return; + } + + const auto* volumes = reinterpret_cast(&volumesProp->value); // NOLINT + const auto* channels = reinterpret_cast(&channelsProp->value); // NOLINT + + auto volumesVec = QVector(); + auto channelsVec = QVector(); + + spa_pod* iter = nullptr; + SPA_POD_ARRAY_FOREACH(volumes, iter) { + // Cubing behavior found in MPD source, and appears to corrospond to everyone else's measurements correctly. + auto linear = *reinterpret_cast(iter); // NOLINT + auto visual = std::cbrt(linear); + volumesVec.push_back(visual); + } + + SPA_POD_ARRAY_FOREACH(channels, iter) { + channelsVec.push_back(*reinterpret_cast(iter)); // NOLINT + } + + if (volumesVec.size() != channelsVec.size()) { + qCWarning(logNode) << "Cannot update volume props of" << this->node + << "- channelVolumes and channelMap are not the same size. Sizes:" + << volumesVec.size() << channelsVec.size(); + return; + } + + // It is important that the lengths of channels and volumes stay in sync whenever you read them. + auto channelsChanged = false; + auto volumesChanged = false; + + if (this->mChannels != channelsVec) { + this->mChannels = channelsVec; + channelsChanged = true; + qCDebug(logNode) << "Got updated channels of" << this->node << '-' << this->mChannels; + } + + if (this->mVolumes != volumesVec) { + this->mVolumes = volumesVec; + volumesChanged = true; + qCDebug(logNode) << "Got updated volumes of" << this->node << '-' << this->mVolumes; + } + + if (channelsChanged) emit this->channelsChanged(); + if (volumesChanged) emit this->volumesChanged(); +} + +void PwNodeBoundAudio::updateMutedFromParam(const spa_pod* param) { + const auto* mutedProp = spa_pod_find_prop(param, nullptr, SPA_PROP_mute); + + if (mutedProp == nullptr) { + qCWarning(logNode) << "Cannot update muted state of" << this->node + << "- mute property was null."; + return; + } + + if (spa_pod_is_bool(&mutedProp->value) == 0) { + qCWarning(logNode) << "Cannot update muted state of" << this->node + << "- mute property was not a boolean."; + return; + } + + bool muted = false; + spa_pod_get_bool(&mutedProp->value, &muted); + + if (muted != this->mMuted) { + qCDebug(logNode) << "Got updated mute status of" << this->node << '-' << muted; + this->mMuted = muted; + emit this->mutedChanged(); + } +} + +void PwNodeBoundAudio::onUnbind() { + this->mChannels.clear(); + this->mVolumes.clear(); + emit this->channelsChanged(); + emit this->volumesChanged(); +} + +bool PwNodeBoundAudio::isMuted() const { return this->mMuted; } + +void PwNodeBoundAudio::setMuted(bool muted) { + if (this->node->proxy() == nullptr) { + qCWarning(logNode) << "Tried to change mute state for" << this->node << "which is not bound."; + return; + } + + if (muted == this->mMuted) return; + + auto buffer = std::array(); + auto builder = SPA_POD_BUILDER_INIT(buffer.data(), buffer.size()); + + // is this a leak? seems like probably not? docs don't say, as usual. + // clang-format off + auto* pod = spa_pod_builder_add_object( + &builder, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props, + SPA_PROP_mute, SPA_POD_Bool(muted) + ); + // clang-format on + + qCDebug(logNode) << "Changed muted state of" << this->node << "to" << muted; + this->mMuted = muted; + pw_node_set_param(this->node->proxy(), SPA_PARAM_Props, 0, static_cast(pod)); + emit this->mutedChanged(); +} + +float PwNodeBoundAudio::averageVolume() const { + float total = 0; + + for (auto volume: this->mVolumes) { + total += volume; + } + + return total / static_cast(this->mVolumes.size()); +} + +void PwNodeBoundAudio::setAverageVolume(float volume) { + auto oldAverage = this->averageVolume(); + auto mul = oldAverage == 0 ? 0 : volume / oldAverage; + auto volumes = QVector(); + + for (auto oldVolume: this->mVolumes) { + volumes.push_back(mul == 0 ? volume : oldVolume * mul); + } + + this->setVolumes(volumes); +} + +QVector PwNodeBoundAudio::channels() const { return this->mChannels; } + +QVector PwNodeBoundAudio::volumes() const { return this->mVolumes; } + +void PwNodeBoundAudio::setVolumes(const QVector& volumes) { + if (this->node->proxy() == nullptr) { + qCWarning(logNode) << "Tried to change node volumes for" << this->node << "which is not bound."; + return; + } + + if (volumes == this->mVolumes) return; + + if (volumes.length() != this->mVolumes.length()) { + qCWarning(logNode) << "Tried to change node volumes for" << this->node << "from" + << this->mVolumes << "to" << volumes + << "which has a different length than the list of channels" + << this->mChannels; + return; + } + + auto buffer = std::array(); + auto builder = SPA_POD_BUILDER_INIT(buffer.data(), buffer.size()); + + auto cubedVolumes = QVector(); + for (auto volume: volumes) { + cubedVolumes.push_back(volume * volume * volume); + } + + // clang-format off + auto* pod = spa_pod_builder_add_object( + &builder, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props, + SPA_PROP_channelVolumes, SPA_POD_Array(sizeof(float), SPA_TYPE_Float, cubedVolumes.length(), cubedVolumes.data()) + ); + // clang-format on + + qCDebug(logNode) << "Changed volumes of" << this->node << "to" << volumes; + this->mVolumes = volumes; + pw_node_set_param(this->node->proxy(), SPA_PARAM_Props, 0, static_cast(pod)); + emit this->volumesChanged(); +} + +} // namespace qs::service::pipewire diff --git a/src/services/pipewire/node.hpp b/src/services/pipewire/node.hpp new file mode 100644 index 0000000..a1a60c9 --- /dev/null +++ b/src/services/pipewire/node.hpp @@ -0,0 +1,174 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core.hpp" +#include "registry.hpp" + +namespace qs::service::pipewire { + +class PwAudioChannel: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum { + Unknown = SPA_AUDIO_CHANNEL_UNKNOWN, + NA = SPA_AUDIO_CHANNEL_NA, + Mono = SPA_AUDIO_CHANNEL_MONO, + FrontCenter = SPA_AUDIO_CHANNEL_FC, + FrontLeft = SPA_AUDIO_CHANNEL_FL, + FrontRight = SPA_AUDIO_CHANNEL_FR, + FrontLeftCenter = SPA_AUDIO_CHANNEL_FLC, + FrontRightCenter = SPA_AUDIO_CHANNEL_FRC, + FrontLeftWide = SPA_AUDIO_CHANNEL_FLW, + FrontRightWide = SPA_AUDIO_CHANNEL_FRW, + FrontCenterHigh = SPA_AUDIO_CHANNEL_FCH, + FrontLeftHigh = SPA_AUDIO_CHANNEL_FLH, + FrontRightHigh = SPA_AUDIO_CHANNEL_FRH, + LowFrequencyEffects = SPA_AUDIO_CHANNEL_LFE, + LowFrequencyEffects2 = SPA_AUDIO_CHANNEL_LFE2, + LowFrequencyEffectsLeft = SPA_AUDIO_CHANNEL_LLFE, + LowFrequencyEffectsRight = SPA_AUDIO_CHANNEL_RLFE, + SideLeft = SPA_AUDIO_CHANNEL_SL, + SideRight = SPA_AUDIO_CHANNEL_SR, + RearCenter = SPA_AUDIO_CHANNEL_RC, + RearLeft = SPA_AUDIO_CHANNEL_RL, + RearRight = SPA_AUDIO_CHANNEL_RR, + RearLeftCenter = SPA_AUDIO_CHANNEL_RLC, + RearRightCenter = SPA_AUDIO_CHANNEL_RRC, + TopCenter = SPA_AUDIO_CHANNEL_TC, + TopFrontCenter = SPA_AUDIO_CHANNEL_TFC, + TopFrontLeft = SPA_AUDIO_CHANNEL_TFL, + TopFrontRight = SPA_AUDIO_CHANNEL_TFR, + TopFrontLeftCenter = SPA_AUDIO_CHANNEL_TFLC, + TopFrontRightCenter = SPA_AUDIO_CHANNEL_TFRC, + TopSideLeft = SPA_AUDIO_CHANNEL_TSL, + TopSideRight = SPA_AUDIO_CHANNEL_TSR, + TopRearCenter = SPA_AUDIO_CHANNEL_TRC, + TopRearLeft = SPA_AUDIO_CHANNEL_TRL, + TopRearRight = SPA_AUDIO_CHANNEL_TRR, + BottomCenter = SPA_AUDIO_CHANNEL_BC, + BottomLeftCenter = SPA_AUDIO_CHANNEL_BLC, + BottomRightCenter = SPA_AUDIO_CHANNEL_BRC, + /// The start of the aux channel range. + /// + /// Values between AuxRangeStart and AuxRangeEnd are valid. + AuxRangeStart = SPA_AUDIO_CHANNEL_START_Aux, + /// The end of the aux channel range. + /// + /// Values between AuxRangeStart and AuxRangeEnd are valid. + AuxRangeEnd = SPA_AUDIO_CHANNEL_LAST_Aux, + /// The end of the custom channel range. + /// + /// Values starting at CustomRangeStart are valid. + CustomRangeStart = SPA_AUDIO_CHANNEL_START_Custom, + }; + Q_ENUM(Enum); + + /// Print a human readable representation of the given channel, + /// including aux and custom channel ranges. + Q_INVOKABLE static QString toString(PwAudioChannel::Enum value); +}; + +enum class PwNodeType { + Untracked, + Audio, +}; + +class PwNode; + +class PwNodeBoundData { +public: + PwNodeBoundData() = default; + virtual ~PwNodeBoundData() = default; + Q_DISABLE_COPY_MOVE(PwNodeBoundData); + + virtual void onInfo(const pw_node_info* /*info*/) {} + virtual void onSpaParam(quint32 /*id*/, quint32 /*index*/, const spa_pod* /*param*/) {} + virtual void onUnbind() {} +}; + +class PwNodeBoundAudio + : public QObject + , public PwNodeBoundData { + Q_OBJECT; + +public: + explicit PwNodeBoundAudio(PwNode* node): node(node) {} + + void onInfo(const pw_node_info* info) override; + void onSpaParam(quint32 id, quint32 index, const spa_pod* param) override; + void onUnbind() override; + + [[nodiscard]] bool isMuted() const; + void setMuted(bool muted); + + [[nodiscard]] float averageVolume() const; + void setAverageVolume(float volume); + + [[nodiscard]] QVector channels() const; + + [[nodiscard]] QVector volumes() const; + void setVolumes(const QVector& volumes); + +signals: + void volumesChanged(); + void channelsChanged(); + void mutedChanged(); + +private: + void updateVolumeFromParam(const spa_pod* param); + void updateMutedFromParam(const spa_pod* param); + + bool mMuted = false; + QVector mChannels; + QVector mVolumes; + PwNode* node; +}; + +constexpr const char TYPE_INTERFACE_Node[] = PW_TYPE_INTERFACE_Node; // NOLINT +class PwNode: public PwBindable { // NOLINT + Q_OBJECT; + +public: + void bindHooks() override; + void unbindHooks() override; + void initProps(const spa_dict* props) override; + + QString name; + QString description; + QString nick; + QMap properties; + + PwNodeType type = PwNodeType::Untracked; + bool isSink = false; + bool isStream = false; + + PwNodeBoundData* boundData = nullptr; + +signals: + void propertiesChanged(); + +private: + static const pw_node_events EVENTS; + static void onInfo(void* data, const pw_node_info* info); + static void + onParam(void* data, qint32 seq, quint32 id, quint32 index, quint32 next, const spa_pod* param); + + SpaHook listener; +}; + +} // namespace qs::service::pipewire diff --git a/src/services/pipewire/qml.cpp b/src/services/pipewire/qml.cpp new file mode 100644 index 0000000..a6617d2 --- /dev/null +++ b/src/services/pipewire/qml.cpp @@ -0,0 +1,472 @@ +#include "qml.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "connection.hpp" +#include "link.hpp" +#include "metadata.hpp" +#include "node.hpp" +#include "registry.hpp" + +namespace qs::service::pipewire { + +void PwObjectIface::ref() { + this->refcount++; + + if (this->refcount == 1) { + this->object->ref(); + } +} + +void PwObjectIface::unref() { + if (this->refcount == 0) return; + this->refcount--; + + if (this->refcount == 0) { + this->object->unref(); + } +} + +Pipewire::Pipewire(QObject* parent): QObject(parent) { + auto* connection = PwConnection::instance(); + + for (auto* node: connection->registry.nodes.values()) { + this->onNodeAdded(node); + } + + QObject::connect(&connection->registry, &PwRegistry::nodeAdded, this, &Pipewire::onNodeAdded); + + for (auto* link: connection->registry.links.values()) { + this->onLinkAdded(link); + } + + QObject::connect(&connection->registry, &PwRegistry::linkAdded, this, &Pipewire::onLinkAdded); + + for (auto* group: connection->registry.linkGroups) { + this->onLinkGroupAdded(group); + } + + QObject::connect( + &connection->registry, + &PwRegistry::linkGroupAdded, + this, + &Pipewire::onLinkGroupAdded + ); + + // clang-format off + QObject::connect(&connection->defaults, &PwDefaultsMetadata::defaultSinkChanged, this, &Pipewire::defaultAudioSinkChanged); + QObject::connect(&connection->defaults, &PwDefaultsMetadata::defaultSourceChanged, this, &Pipewire::defaultAudioSourceChanged); + // clang-format on +} + +QQmlListProperty Pipewire::nodes() { + return QQmlListProperty(this, nullptr, &Pipewire::nodesCount, &Pipewire::nodeAt); +} + +qsizetype Pipewire::nodesCount(QQmlListProperty* property) { + return static_cast(property->object)->mNodes.count(); // NOLINT +} + +PwNodeIface* Pipewire::nodeAt(QQmlListProperty* property, qsizetype index) { + return static_cast(property->object)->mNodes.at(index); // NOLINT +} + +void Pipewire::onNodeAdded(PwNode* node) { + auto* iface = PwNodeIface::instance(node); + QObject::connect(iface, &QObject::destroyed, this, &Pipewire::onNodeRemoved); + + this->mNodes.push_back(iface); + emit this->nodesChanged(); +} + +void Pipewire::onNodeRemoved(QObject* object) { + auto* iface = static_cast(object); // NOLINT + this->mNodes.removeOne(iface); + emit this->nodesChanged(); +} + +QQmlListProperty Pipewire::links() { + return QQmlListProperty(this, nullptr, &Pipewire::linksCount, &Pipewire::linkAt); +} + +qsizetype Pipewire::linksCount(QQmlListProperty* property) { + return static_cast(property->object)->mLinks.count(); // NOLINT +} + +PwLinkIface* Pipewire::linkAt(QQmlListProperty* property, qsizetype index) { + return static_cast(property->object)->mLinks.at(index); // NOLINT +} + +void Pipewire::onLinkAdded(PwLink* link) { + auto* iface = PwLinkIface::instance(link); + QObject::connect(iface, &QObject::destroyed, this, &Pipewire::onLinkRemoved); + + this->mLinks.push_back(iface); + emit this->linksChanged(); +} + +void Pipewire::onLinkRemoved(QObject* object) { + auto* iface = static_cast(object); // NOLINT + this->mLinks.removeOne(iface); + emit this->linksChanged(); +} + +QQmlListProperty Pipewire::linkGroups() { + return QQmlListProperty( + this, + nullptr, + &Pipewire::linkGroupsCount, + &Pipewire::linkGroupAt + ); +} + +qsizetype Pipewire::linkGroupsCount(QQmlListProperty* property) { + return static_cast(property->object)->mLinkGroups.count(); // NOLINT +} + +PwLinkGroupIface* +Pipewire::linkGroupAt(QQmlListProperty* property, qsizetype index) { + return static_cast(property->object)->mLinkGroups.at(index); // NOLINT +} + +void Pipewire::onLinkGroupAdded(PwLinkGroup* linkGroup) { + auto* iface = PwLinkGroupIface::instance(linkGroup); + QObject::connect(iface, &QObject::destroyed, this, &Pipewire::onLinkGroupRemoved); + + this->mLinkGroups.push_back(iface); + emit this->linkGroupsChanged(); +} + +void Pipewire::onLinkGroupRemoved(QObject* object) { + auto* iface = static_cast(object); // NOLINT + this->mLinkGroups.removeOne(iface); + emit this->linkGroupsChanged(); +} + +PwNodeIface* Pipewire::defaultAudioSink() const { // NOLINT + auto* connection = PwConnection::instance(); + auto name = connection->defaults.defaultSink(); + + for (auto* node: connection->registry.nodes.values()) { + if (name == node->name) { + return PwNodeIface::instance(node); + } + } + + return nullptr; +} + +PwNodeIface* Pipewire::defaultAudioSource() const { // NOLINT + auto* connection = PwConnection::instance(); + auto name = connection->defaults.defaultSource(); + + for (auto* node: connection->registry.nodes.values()) { + if (name == node->name) { + return PwNodeIface::instance(node); + } + } + + return nullptr; +} + +PwNodeIface* PwNodeLinkTracker::node() const { return this->mNode; } + +void PwNodeLinkTracker::setNode(PwNodeIface* node) { + if (node == this->mNode) return; + + if (this->mNode != nullptr) { + if (node == nullptr) { + QObject::disconnect(&PwConnection::instance()->registry, nullptr, this, nullptr); + } + + QObject::disconnect(this->mNode, nullptr, this, nullptr); + } + + if (node != nullptr) { + if (this->mNode == nullptr) { + QObject::connect( + &PwConnection::instance()->registry, + &PwRegistry::linkGroupAdded, + this, + &PwNodeLinkTracker::onLinkGroupCreated + ); + } + + QObject::connect(node, &QObject::destroyed, this, &PwNodeLinkTracker::onNodeDestroyed); + } + + this->mNode = node; + this->updateLinks(); + emit this->nodeChanged(); +} + +void PwNodeLinkTracker::updateLinks() { + // done first to avoid unref->reref of nodes + auto newLinks = QVector(); + if (this->mNode != nullptr) { + auto* connection = PwConnection::instance(); + + for (auto* link: connection->registry.linkGroups) { + if ((!this->mNode->isSink() && link->outputNode() == this->mNode->id()) + || (this->mNode->isSink() && link->inputNode() == this->mNode->id())) + { + auto* iface = PwLinkGroupIface::instance(link); + + // do not connect twice + if (!this->mLinkGroups.contains(iface)) { + QObject::connect( + iface, + &QObject::destroyed, + this, + &PwNodeLinkTracker::onLinkGroupDestroyed + ); + } + + newLinks.push_back(iface); + } + } + } + + for (auto* iface: this->mLinkGroups) { + // only disconnect no longer used nodes + if (!newLinks.contains(iface)) { + QObject::disconnect(iface, nullptr, this, nullptr); + } + } + + this->mLinkGroups = newLinks; + emit this->linkGroupsChanged(); +} + +QQmlListProperty PwNodeLinkTracker::linkGroups() { + return QQmlListProperty( + this, + nullptr, + &PwNodeLinkTracker::linkGroupsCount, + &PwNodeLinkTracker::linkGroupAt + ); +} + +qsizetype PwNodeLinkTracker::linkGroupsCount(QQmlListProperty* property) { + return static_cast(property->object)->mLinkGroups.count(); // NOLINT +} + +PwLinkGroupIface* +PwNodeLinkTracker::linkGroupAt(QQmlListProperty* property, qsizetype index) { + return static_cast(property->object)->mLinkGroups.at(index); // NOLINT +} + +void PwNodeLinkTracker::onNodeDestroyed() { + this->mNode = nullptr; + this->updateLinks(); + emit this->nodeChanged(); +} + +void PwNodeLinkTracker::onLinkGroupCreated(PwLinkGroup* linkGroup) { + if ((!this->mNode->isSink() && linkGroup->outputNode() == this->mNode->id()) + || (this->mNode->isSink() && linkGroup->inputNode() == this->mNode->id())) + { + auto* iface = PwLinkGroupIface::instance(linkGroup); + QObject::connect(iface, &QObject::destroyed, this, &PwNodeLinkTracker::onLinkGroupDestroyed); + this->mLinkGroups.push_back(iface); + emit this->linkGroupsChanged(); + } +} + +void PwNodeLinkTracker::onLinkGroupDestroyed(QObject* object) { + if (this->mLinkGroups.removeOne(object)) { + emit this->linkGroupsChanged(); + } +} + +PwNodeAudioIface::PwNodeAudioIface(PwNodeBoundAudio* boundData, QObject* parent) + : QObject(parent) + , boundData(boundData) { + // clang-format off + QObject::connect(boundData, &PwNodeBoundAudio::mutedChanged, this, &PwNodeAudioIface::mutedChanged); + QObject::connect(boundData, &PwNodeBoundAudio::channelsChanged, this, &PwNodeAudioIface::channelsChanged); + QObject::connect(boundData, &PwNodeBoundAudio::volumesChanged, this, &PwNodeAudioIface::volumesChanged); + // clang-format on +} + +bool PwNodeAudioIface::isMuted() const { return this->boundData->isMuted(); } + +void PwNodeAudioIface::setMuted(bool muted) { this->boundData->setMuted(muted); } + +float PwNodeAudioIface::averageVolume() const { return this->boundData->averageVolume(); } + +void PwNodeAudioIface::setAverageVolume(float volume) { this->boundData->setAverageVolume(volume); } + +QVector PwNodeAudioIface::channels() const { + return this->boundData->channels(); +} + +QVector PwNodeAudioIface::volumes() const { return this->boundData->volumes(); } + +void PwNodeAudioIface::setVolumes(const QVector& volumes) { + this->boundData->setVolumes(volumes); +} + +PwNodeIface::PwNodeIface(PwNode* node): PwObjectIface(node), mNode(node) { + QObject::connect(node, &PwNode::propertiesChanged, this, &PwNodeIface::propertiesChanged); + + if (auto* audioBoundData = dynamic_cast(node->boundData)) { + this->audioIface = new PwNodeAudioIface(audioBoundData, this); + } +} + +PwNode* PwNodeIface::node() const { return this->mNode; } + +QString PwNodeIface::name() const { return this->mNode->name; } + +quint32 PwNodeIface::id() const { return this->mNode->id; } + +QString PwNodeIface::description() const { return this->mNode->description; } + +QString PwNodeIface::nickname() const { return this->mNode->nick; } + +bool PwNodeIface::isSink() const { return this->mNode->isSink; } + +bool PwNodeIface::isStream() const { return this->mNode->isStream; } + +QVariantMap PwNodeIface::properties() const { + auto map = QVariantMap(); + for (auto [k, v]: this->mNode->properties.asKeyValueRange()) { + map.insert(k, QVariant::fromValue(v)); + } + + return map; +} + +PwNodeAudioIface* PwNodeIface::audio() const { return this->audioIface; } + +PwNodeIface* PwNodeIface::instance(PwNode* node) { + auto v = node->property("iface"); + if (v.canConvert()) { + return v.value(); + } + + auto* instance = new PwNodeIface(node); + node->setProperty("iface", QVariant::fromValue(instance)); + + return instance; +} + +PwLinkIface::PwLinkIface(PwLink* link): PwObjectIface(link), mLink(link) { + QObject::connect(link, &PwLink::stateChanged, this, &PwLinkIface::stateChanged); +} + +PwLink* PwLinkIface::link() const { return this->mLink; } + +quint32 PwLinkIface::id() const { return this->mLink->id; } + +PwNodeIface* PwLinkIface::target() const { + return PwNodeIface::instance( + PwConnection::instance()->registry.nodes.value(this->mLink->inputNode()) + ); +} + +PwNodeIface* PwLinkIface::source() const { + return PwNodeIface::instance( + PwConnection::instance()->registry.nodes.value(this->mLink->outputNode()) + ); +} + +PwLinkState::Enum PwLinkIface::state() const { return this->mLink->state(); } + +PwLinkIface* PwLinkIface::instance(PwLink* link) { + auto v = link->property("iface"); + if (v.canConvert()) { + return v.value(); + } + + auto* instance = new PwLinkIface(link); + link->setProperty("iface", QVariant::fromValue(instance)); + + return instance; +} + +PwLinkGroupIface::PwLinkGroupIface(PwLinkGroup* group): QObject(group), mGroup(group) { + QObject::connect(group, &PwLinkGroup::stateChanged, this, &PwLinkGroupIface::stateChanged); + QObject::connect(group, &QObject::destroyed, this, [this]() { delete this; }); +} + +void PwLinkGroupIface::ref() { this->mGroup->ref(); } + +void PwLinkGroupIface::unref() { this->mGroup->unref(); } + +PwLinkGroup* PwLinkGroupIface::group() const { return this->mGroup; } + +PwNodeIface* PwLinkGroupIface::target() const { + return PwNodeIface::instance( + PwConnection::instance()->registry.nodes.value(this->mGroup->inputNode()) + ); +} + +PwNodeIface* PwLinkGroupIface::source() const { + return PwNodeIface::instance( + PwConnection::instance()->registry.nodes.value(this->mGroup->outputNode()) + ); +} + +PwLinkState::Enum PwLinkGroupIface::state() const { return this->mGroup->state(); } + +PwLinkGroupIface* PwLinkGroupIface::instance(PwLinkGroup* group) { + auto v = group->property("iface"); + if (v.canConvert()) { + return v.value(); + } + + auto* instance = new PwLinkGroupIface(group); + group->setProperty("iface", QVariant::fromValue(instance)); + + return instance; +} + +PwObjectTracker::~PwObjectTracker() { this->clearList(); } + +QList PwObjectTracker::objects() const { return this->trackedObjects; } + +void PwObjectTracker::setObjects(const QList& objects) { + // +1 ref before removing old refs to avoid an unbind->bind. + for (auto* object: objects) { + if (auto* pwObject = dynamic_cast(object)) { + pwObject->ref(); + } + } + + this->clearList(); + + // connect destroy + for (auto* object: objects) { + if (auto* pwObject = dynamic_cast(object)) { + QObject::connect(object, &QObject::destroyed, this, &PwObjectTracker::objectDestroyed); + } + } + + this->trackedObjects = objects; +} + +void PwObjectTracker::clearList() { + for (auto* object: this->trackedObjects) { + if (auto* pwObject = dynamic_cast(object)) { + pwObject->unref(); + QObject::disconnect(object, nullptr, this, nullptr); + } + } + + this->trackedObjects.clear(); +} + +void PwObjectTracker::objectDestroyed(QObject* object) { + this->trackedObjects.removeOne(object); + emit this->objectsChanged(); +} + +} // namespace qs::service::pipewire diff --git a/src/services/pipewire/qml.hpp b/src/services/pipewire/qml.hpp new file mode 100644 index 0000000..9b45272 --- /dev/null +++ b/src/services/pipewire/qml.hpp @@ -0,0 +1,368 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "link.hpp" +#include "node.hpp" +#include "registry.hpp" + +namespace qs::service::pipewire { + +class PwNodeIface; +class PwLinkIface; +class PwLinkGroupIface; + +class PwObjectRefIface { +public: + PwObjectRefIface() = default; + virtual ~PwObjectRefIface() = default; + Q_DISABLE_COPY_MOVE(PwObjectRefIface); + + virtual void ref() = 0; + virtual void unref() = 0; +}; + +class PwObjectIface + : public QObject + , public PwObjectRefIface { + Q_OBJECT; + +public: + explicit PwObjectIface(PwBindableObject* object): QObject(object), object(object) {}; + // destructor should ONLY be called by the pw object destructor, making an unref unnecessary + ~PwObjectIface() override = default; + Q_DISABLE_COPY_MOVE(PwObjectIface); + + void ref() override; + void unref() override; + +private: + quint32 refcount = 0; + PwBindableObject* object; +}; + +///! Contains links to all pipewire objects. +class Pipewire: public QObject { + Q_OBJECT; + // clang-format off + /// All pipewire nodes. + Q_PROPERTY(QQmlListProperty nodes READ nodes NOTIFY nodesChanged); + /// All pipewire links. + Q_PROPERTY(QQmlListProperty links READ links NOTIFY linksChanged); + /// All pipewire link groups. + Q_PROPERTY(QQmlListProperty linkGroups READ linkGroups NOTIFY linkGroupsChanged); + /// The default audio sink or `null`. + Q_PROPERTY(PwNodeIface* defaultAudioSink READ defaultAudioSink NOTIFY defaultAudioSinkChanged); + /// The default audio source or `null`. + Q_PROPERTY(PwNodeIface* defaultAudioSource READ defaultAudioSource NOTIFY defaultAudioSourceChanged); + // clang-format on + QML_ELEMENT; + QML_SINGLETON; + +public: + explicit Pipewire(QObject* parent = nullptr); + + [[nodiscard]] QQmlListProperty nodes(); + [[nodiscard]] QQmlListProperty links(); + [[nodiscard]] QQmlListProperty linkGroups(); + [[nodiscard]] PwNodeIface* defaultAudioSink() const; + [[nodiscard]] PwNodeIface* defaultAudioSource() const; + +signals: + void nodesChanged(); + void linksChanged(); + void linkGroupsChanged(); + void defaultAudioSinkChanged(); + void defaultAudioSourceChanged(); + +private slots: + void onNodeAdded(PwNode* node); + void onNodeRemoved(QObject* object); + void onLinkAdded(PwLink* link); + void onLinkRemoved(QObject* object); + void onLinkGroupAdded(PwLinkGroup* group); + void onLinkGroupRemoved(QObject* object); + +private: + static qsizetype nodesCount(QQmlListProperty* property); + static PwNodeIface* nodeAt(QQmlListProperty* property, qsizetype index); + static qsizetype linksCount(QQmlListProperty* property); + static PwLinkIface* linkAt(QQmlListProperty* property, qsizetype index); + static qsizetype linkGroupsCount(QQmlListProperty* property); + static PwLinkGroupIface* + linkGroupAt(QQmlListProperty* property, qsizetype index); + + QVector mNodes; + QVector mLinks; + QVector mLinkGroups; +}; + +///! Tracks all link connections to a given node. +class PwNodeLinkTracker: public QObject { + Q_OBJECT; + // clang-format off + /// The node to track connections to. + Q_PROPERTY(PwNodeIface* node READ node WRITE setNode NOTIFY nodeChanged); + /// Link groups connected to the given node. + /// + /// If the node is a sink, links which target the node will be tracked. + /// If the node is a source, links which source the node will be tracked. + Q_PROPERTY(QQmlListProperty linkGroups READ linkGroups NOTIFY linkGroupsChanged); + // clang-format on + QML_ELEMENT; + +public: + explicit PwNodeLinkTracker(QObject* parent = nullptr): QObject(parent) {} + + [[nodiscard]] PwNodeIface* node() const; + void setNode(PwNodeIface* node); + + [[nodiscard]] QQmlListProperty linkGroups(); + +signals: + void nodeChanged(); + void linkGroupsChanged(); + +private slots: + void onNodeDestroyed(); + void onLinkGroupCreated(PwLinkGroup* linkGroup); + void onLinkGroupDestroyed(QObject* object); + +private: + static qsizetype linkGroupsCount(QQmlListProperty* property); + static PwLinkGroupIface* + linkGroupAt(QQmlListProperty* property, qsizetype index); + + void updateLinks(); + + PwNodeIface* mNode = nullptr; + QVector mLinkGroups; +}; + +///! Audio specific properties of pipewire nodes. +class PwNodeAudioIface: public QObject { + Q_OBJECT; + /// If the node is currently muted. Setting this property changes the mute state. + /// + /// **This property is invalid unless the node is [bound](../pwobjecttracker).** + Q_PROPERTY(bool muted READ isMuted WRITE setMuted NOTIFY mutedChanged); + /// The average volume over all channels of the node. + /// Setting this property modifies the volume of all channels proportionately. + /// + /// **This property is invalid unless the node is [bound](../pwobjecttracker).** + Q_PROPERTY(float volume READ averageVolume WRITE setAverageVolume NOTIFY volumesChanged); + /// The audio channels present on the node. + /// + /// **This property is invalid unless the node is [bound](../pwobjecttracker).** + Q_PROPERTY(QVector channels READ channels NOTIFY channelsChanged); + /// The volumes of each audio channel individually. Each entry corrosponds to + /// the channel at the same index in `channels`. `volumes` and `channels` will always be + /// the same length. + /// + /// **This property is invalid unless the node is [bound](../pwobjecttracker).** + Q_PROPERTY(QVector volumes READ volumes WRITE setVolumes NOTIFY volumesChanged); + QML_NAMED_ELEMENT(PwNodeAudio); + QML_UNCREATABLE("PwNodeAudio cannot be created directly"); + +public: + explicit PwNodeAudioIface(PwNodeBoundAudio* boundData, QObject* parent); + + [[nodiscard]] bool isMuted() const; + void setMuted(bool muted); + + [[nodiscard]] float averageVolume() const; + void setAverageVolume(float volume); + + [[nodiscard]] QVector channels() const; + + [[nodiscard]] QVector volumes() const; + void setVolumes(const QVector& volumes); + +signals: + void mutedChanged(); + void channelsChanged(); + void volumesChanged(); + +private: + PwNodeBoundAudio* boundData; +}; + +///! A node in the pipewire connection graph. +class PwNodeIface: public PwObjectIface { + Q_OBJECT; + /// The pipewire object id of the node. + /// + /// Mainly useful for debugging. you can inspect the node directly + /// with `pw-cli i `. + Q_PROPERTY(quint32 id READ id CONSTANT); + /// The node's name, corrosponding to the object's `node.name` property. + Q_PROPERTY(QString name READ name CONSTANT); + /// The node's description, corrosponding to the object's `node.description` property. + /// + /// May be empty. Generally more human readable than `name`. + Q_PROPERTY(QString description READ description CONSTANT); + /// The node's nickname, corrosponding to the object's `node.nickname` property. + /// + /// May be empty. Generally but not always more human readable than `description`. + Q_PROPERTY(QString nickname READ nickname CONSTANT); + /// If `true`, then the node accepts audio input from other nodes, + /// if `false` the node outputs audio to other nodes. + Q_PROPERTY(bool isSink READ isSink CONSTANT); + /// If `true` then the node is likely to be a program, if false it is liekly to be hardware. + Q_PROPERTY(bool isStream READ isStream CONSTANT); + /// The property set present on the node, as an object containing key-value pairs. + /// You can inspect this directly with `pw-cli i `. + /// + /// A few properties of note, which may or may not be present: + /// - `application.name` - A suggested human readable name for the node. + /// - `application.icon-name` - The name of an icon recommended to display for the node. + /// - `media.name` - A description of the currently playing media. + /// (more likely to be present than `media.title` and `media.artist`) + /// - `media.title` - The title of the currently playing media. + /// - `media.artist` - The artist of the currently playing media. + /// + /// **This property is invalid unless the node is [bound](../pwobjecttracker).** + Q_PROPERTY(QVariantMap properties READ properties NOTIFY propertiesChanged); + /// Extra information present only if the node sends or receives audio. + Q_PROPERTY(PwNodeAudioIface* audio READ audio CONSTANT); + QML_NAMED_ELEMENT(PwNode); + QML_UNCREATABLE("PwNodes cannot be created directly"); + +public: + explicit PwNodeIface(PwNode* node); + + [[nodiscard]] PwNode* node() const; + [[nodiscard]] quint32 id() const; + [[nodiscard]] QString name() const; + [[nodiscard]] QString description() const; + [[nodiscard]] QString nickname() const; + [[nodiscard]] bool isSink() const; + [[nodiscard]] bool isStream() const; + [[nodiscard]] QVariantMap properties() const; + [[nodiscard]] PwNodeAudioIface* audio() const; + + static PwNodeIface* instance(PwNode* node); + +signals: + void propertiesChanged(); + +private: + PwNode* mNode; + PwNodeAudioIface* audioIface = nullptr; +}; + +///! A connection between pipewire nodes. +/// Note that there is one link per *channel* of a connection between nodes. +/// You usually want [PwLinkGroup](../pwlinkgroup). +class PwLinkIface: public PwObjectIface { + Q_OBJECT; + /// The pipewire object id of the link. + /// + /// Mainly useful for debugging. you can inspect the link directly + /// with `pw-cli i `. + Q_PROPERTY(quint32 id READ id CONSTANT); + /// The node that is *receiving* information. (the sink) + Q_PROPERTY(PwNodeIface* target READ target CONSTANT); + /// The node that is *sending* information. (the source) + Q_PROPERTY(PwNodeIface* source READ source CONSTANT); + /// The current state of the link. + /// + /// **This property is invalid unless the link is [bound](../pwobjecttracker).** + Q_PROPERTY(PwLinkState::Enum state READ state NOTIFY stateChanged); + QML_NAMED_ELEMENT(PwLink); + QML_UNCREATABLE("PwLinks cannot be created directly"); + +public: + explicit PwLinkIface(PwLink* link); + + [[nodiscard]] PwLink* link() const; + [[nodiscard]] quint32 id() const; + [[nodiscard]] PwNodeIface* target() const; + [[nodiscard]] PwNodeIface* source() const; + [[nodiscard]] PwLinkState::Enum state() const; + + static PwLinkIface* instance(PwLink* link); + +signals: + void stateChanged(); + +private: + PwLink* mLink; +}; + +///! A group of connections between pipewire nodes. +/// A group of connections between pipewire nodes, one per source->target pair. +class PwLinkGroupIface + : public QObject + , public PwObjectRefIface { + Q_OBJECT; + /// The node that is *receiving* information. (the sink) + Q_PROPERTY(PwNodeIface* target READ target CONSTANT); + /// The node that is *sending* information. (the source) + Q_PROPERTY(PwNodeIface* source READ source CONSTANT); + /// The current state of the link group. + /// + /// **This property is invalid unless the link is [bound](../pwobjecttracker).** + Q_PROPERTY(PwLinkState::Enum state READ state NOTIFY stateChanged); + QML_NAMED_ELEMENT(PwLinkGroup); + QML_UNCREATABLE("PwLinkGroups cannot be created directly"); + +public: + explicit PwLinkGroupIface(PwLinkGroup* group); + // destructor should ONLY be called by the pw object destructor, making an unref unnecessary + ~PwLinkGroupIface() override = default; + Q_DISABLE_COPY_MOVE(PwLinkGroupIface); + + void ref() override; + void unref() override; + + [[nodiscard]] PwLinkGroup* group() const; + [[nodiscard]] PwNodeIface* target() const; + [[nodiscard]] PwNodeIface* source() const; + [[nodiscard]] PwLinkState::Enum state() const; + + static PwLinkGroupIface* instance(PwLinkGroup* group); + +signals: + void stateChanged(); + +private: + PwLinkGroup* mGroup; +}; + +///! Binds pipewire objects. +/// If the object list of at least one PwObjectTracker contains a given pipewire object, +/// it will become *bound* and you will be able to interact with bound-only properties. +class PwObjectTracker: public QObject { + Q_OBJECT; + /// The list of objects to bind. + Q_PROPERTY(QList objects READ objects WRITE setObjects NOTIFY objectsChanged); + QML_ELEMENT; + +public: + explicit PwObjectTracker(QObject* parent = nullptr): QObject(parent) {} + ~PwObjectTracker() override; + Q_DISABLE_COPY_MOVE(PwObjectTracker); + + [[nodiscard]] QList objects() const; + void setObjects(const QList& objects); + +signals: + void objectsChanged(); + +private slots: + void objectDestroyed(QObject* object); + +private: + void clearList(); + + QList trackedObjects; +}; + +} // namespace qs::service::pipewire diff --git a/src/services/pipewire/registry.cpp b/src/services/pipewire/registry.cpp new file mode 100644 index 0000000..2814276 --- /dev/null +++ b/src/services/pipewire/registry.cpp @@ -0,0 +1,193 @@ +#include "registry.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core.hpp" +#include "link.hpp" +#include "metadata.hpp" +#include "node.hpp" + +namespace qs::service::pipewire { + +Q_LOGGING_CATEGORY(logRegistry, "quickshell.service.pipewire.registry", QtWarningMsg); + +PwBindableObject::~PwBindableObject() { + if (this->id != 0) { + qCFatal(logRegistry) << "Destroyed pipewire object" << this + << "without causing safeDestroy. THIS IS UNDEFINED BEHAVIOR."; + } +} + +void PwBindableObject::init(PwRegistry* registry, quint32 id, quint32 perms) { + this->id = id; + this->perms = perms; + this->registry = registry; + this->setParent(registry); + qCDebug(logRegistry) << "Creating object" << this; +} + +void PwBindableObject::safeDestroy() { + this->unbind(); + qCDebug(logRegistry) << "Destroying object" << this; + emit this->destroying(this); + this->id = 0; + delete this; +} + +void PwBindableObject::debugId(QDebug& debug) const { + auto saver = QDebugStateSaver(debug); + debug.nospace() << this->id << "/" << (this->object == nullptr ? "unbound" : "bound"); +} + +void PwBindableObject::ref() { + this->refcount++; + if (this->refcount == 1) this->bind(); +} + +void PwBindableObject::unref() { + this->refcount--; + if (this->refcount == 0) this->unbind(); +} + +void PwBindableObject::bind() { + qCDebug(logRegistry) << "Bound object" << this; + this->bindHooks(); +} + +void PwBindableObject::unbind() { + if (this->object == nullptr) return; + qCDebug(logRegistry) << "Unbinding object" << this; + this->unbindHooks(); + pw_proxy_destroy(this->object); + this->object = nullptr; +} + +QDebug operator<<(QDebug debug, const PwBindableObject* object) { + if (object == nullptr) { + debug << "PwBindableObject(0x0)"; + } else { + auto saver = QDebugStateSaver(debug); + // 0 if not present, start of class name if present + auto idx = QString(object->metaObject()->className()).lastIndexOf(':') + 1; + debug.nospace() << (object->metaObject()->className() + idx) << '(' // NOLINT + << static_cast(object) << ", id="; + object->debugId(debug); + debug << ')'; + } + + return debug; +} + +PwBindableObjectRef::PwBindableObjectRef(PwBindableObject* object) { this->setObject(object); } + +PwBindableObjectRef::~PwBindableObjectRef() { this->setObject(nullptr); } + +void PwBindableObjectRef::setObject(PwBindableObject* object) { + if (this->mObject != nullptr) { + this->mObject->unref(); + QObject::disconnect(this->mObject, nullptr, this, nullptr); + } + + this->mObject = object; + + if (object != nullptr) { + this->mObject->ref(); + QObject::connect(object, &QObject::destroyed, this, &PwBindableObjectRef::onObjectDestroyed); + } +} + +void PwBindableObjectRef::onObjectDestroyed() { + // allow references to it so consumers can disconnect themselves + emit this->objectDestroyed(); + this->mObject = nullptr; +} + +void PwRegistry::init(PwCore& core) { + this->object = pw_core_get_registry(core.core, PW_VERSION_REGISTRY, 0); + pw_registry_add_listener(this->object, &this->listener.hook, &PwRegistry::EVENTS, this); +} + +const pw_registry_events PwRegistry::EVENTS = { + .version = PW_VERSION_REGISTRY_EVENTS, + .global = &PwRegistry::onGlobal, + .global_remove = &PwRegistry::onGlobalRemoved, +}; + +void PwRegistry::onGlobal( + void* data, + quint32 id, + quint32 permissions, + const char* type, + quint32 /*version*/, + const spa_dict* props +) { + auto* self = static_cast(data); + + if (strcmp(type, PW_TYPE_INTERFACE_Metadata) == 0) { + auto* meta = new PwMetadata(); + meta->init(self, id, permissions); + meta->initProps(props); + + self->metadata.emplace(id, meta); + meta->bind(); + } else if (strcmp(type, PW_TYPE_INTERFACE_Link) == 0) { + auto* link = new PwLink(); + link->init(self, id, permissions); + link->initProps(props); + + self->links.emplace(id, link); + self->addLinkToGroup(link); + emit self->linkAdded(link); + } else if (strcmp(type, PW_TYPE_INTERFACE_Node) == 0) { + auto* node = new PwNode(); + node->init(self, id, permissions); + node->initProps(props); + + self->nodes.emplace(id, node); + emit self->nodeAdded(node); + } +} + +void PwRegistry::onGlobalRemoved(void* data, quint32 id) { + auto* self = static_cast(data); + + if (auto* meta = self->metadata.value(id)) { + self->metadata.remove(id); + meta->safeDestroy(); + } else if (auto* link = self->links.value(id)) { + self->links.remove(id); + link->safeDestroy(); + } else if (auto* node = self->nodes.value(id)) { + self->nodes.remove(id); + node->safeDestroy(); + } +} + +void PwRegistry::addLinkToGroup(PwLink* link) { + for (auto* group: this->linkGroups) { + if (group->tryAddLink(link)) return; + } + + auto* group = new PwLinkGroup(link); + QObject::connect(group, &QObject::destroyed, this, &PwRegistry::onLinkGroupDestroyed); + this->linkGroups.push_back(group); + emit this->linkGroupAdded(group); +} + +void PwRegistry::onLinkGroupDestroyed(QObject* object) { + auto* group = static_cast(object); // NOLINT + this->linkGroups.removeOne(group); +} + +} // namespace qs::service::pipewire diff --git a/src/services/pipewire/registry.hpp b/src/services/pipewire/registry.hpp new file mode 100644 index 0000000..dab01af --- /dev/null +++ b/src/services/pipewire/registry.hpp @@ -0,0 +1,160 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core.hpp" + +namespace qs::service::pipewire { + +Q_DECLARE_LOGGING_CATEGORY(logRegistry); + +class PwRegistry; +class PwMetadata; +class PwNode; +class PwLink; +class PwLinkGroup; + +class PwBindableObject: public QObject { + Q_OBJECT; + +public: + PwBindableObject() = default; + ~PwBindableObject() override; + Q_DISABLE_COPY_MOVE(PwBindableObject); + + // constructors and destructors can't do virtual calls. + virtual void init(PwRegistry* registry, quint32 id, quint32 perms); + virtual void initProps(const spa_dict* /*props*/) {} + virtual void safeDestroy(); + + quint32 id = 0; + quint32 perms = 0; + + void debugId(QDebug& debug) const; + void ref(); + void unref(); + +signals: + // goes with safeDestroy + void destroying(PwBindableObject* self); + +protected: + virtual void bind(); + void unbind(); + virtual void bindHooks() {}; + virtual void unbindHooks() {}; + + quint32 refcount = 0; + pw_proxy* object = nullptr; + PwRegistry* registry = nullptr; +}; + +QDebug operator<<(QDebug debug, const PwBindableObject* object); + +template +class PwBindable: public PwBindableObject { +public: + T* proxy() { + return reinterpret_cast(this->object); // NOLINT + } + +protected: + void bind() override { + if (this->object != nullptr) return; + auto* object = + pw_registry_bind(this->registry->object, this->id, INTERFACE, VERSION, 0); // NOLINT + this->object = static_cast(object); + this->PwBindableObject::bind(); + } + + friend class PwRegistry; +}; + +class PwBindableObjectRef: public QObject { + Q_OBJECT; + +public: + explicit PwBindableObjectRef(PwBindableObject* object = nullptr); + ~PwBindableObjectRef() override; + Q_DISABLE_COPY_MOVE(PwBindableObjectRef); + +signals: + void objectDestroyed(); + +private slots: + void onObjectDestroyed(); + +protected: + void setObject(PwBindableObject* object); + + PwBindableObject* mObject = nullptr; +}; + +template +class PwBindableRef: public PwBindableObjectRef { +public: + explicit PwBindableRef(T* object = nullptr): PwBindableObjectRef(object) {} + + void setObject(T* object) { this->PwBindableObjectRef::setObject(object); } + + T* object() { return this->mObject; } +}; + +class PwRegistry + : public QObject + , public PwObject { + Q_OBJECT; + +public: + void init(PwCore& core); + + //QHash clients; + QHash metadata; + QHash nodes; + QHash links; + QVector linkGroups; + +signals: + void nodeAdded(PwNode* node); + void linkAdded(PwLink* link); + void linkGroupAdded(PwLinkGroup* group); + void metadataUpdate( + PwMetadata* owner, + quint32 subject, + const char* key, + const char* type, + const char* value + ); + +private slots: + void onLinkGroupDestroyed(QObject* object); + +private: + static const pw_registry_events EVENTS; + + static void onGlobal( + void* data, + quint32 id, + quint32 permissions, + const char* type, + quint32 version, + const spa_dict* props + ); + + static void onGlobalRemoved(void* data, quint32 id); + + void addLinkToGroup(PwLink* link); + + SpaHook listener; +}; + +} // namespace qs::service::pipewire diff --git a/src/services/status_notifier/module.md b/src/services/status_notifier/module.md index ff1e620..ffe0b4c 100644 --- a/src/services/status_notifier/module.md +++ b/src/services/status_notifier/module.md @@ -1,4 +1,4 @@ -name = "Quickshell.Service.SystemTray" +name = "Quickshell.Services.SystemTray" description = "Types for implementing a system tray" headers = [ "qml.hpp" ] -----