diff --git a/CMakeLists.txt b/CMakeLists.txt index af9df65..b83836d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,11 +9,13 @@ set(CMAKE_AUTORCC ON) set(CMAKE_CXX_STANDARD 14) set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake/) + include(GenerateExportHeader) include(GNUInstallDirs) include(CMakePackageConfigHelpers) -set(QT Core Gui Quick QuickControls2 DBus Xml Concurrent) +set(QT Core Gui Quick QuickControls2 Widgets DBus Xml Concurrent) find_package(Qt5 REQUIRED ${QT}) # Get the installation directory from qmake @@ -39,3 +41,4 @@ add_subdirectory(mpris) add_subdirectory(networkmanagement) add_subdirectory(screen) add_subdirectory(system) +add_subdirectory(audio) diff --git a/audio/CMakeLists.txt b/audio/CMakeLists.txt new file mode 100644 index 0000000..c46045f --- /dev/null +++ b/audio/CMakeLists.txt @@ -0,0 +1,82 @@ +add_definitions(-DTRANSLATION_DOMAIN=\"cutefish_pulseaudio\") + +set(USE_GSETTINGS False) +set(USE_GCONF False) + +configure_file(config.h.cmake ${CMAKE_CURRENT_BINARY_DIR}/config.h) + +set(audio_SRCS + card.cpp + client.cpp + context.cpp + device.cpp + maps.cpp + operation.cpp + port.cpp + profile.cpp + pulseaudio.cpp + pulseobject.cpp + sink.cpp + sinkinput.cpp + modulemanager.cpp + source.cpp + sourceoutput.cpp + stream.cpp + volumemonitor.cpp + volumeobject.cpp + debug.cpp + server.cpp + streamrestore.cpp + module.cpp + canberracontext.cpp + speakertest.cpp + qml/listitemmenu.cpp + qml/plugin.cpp + # qml/microphoneindicator.cpp + # qml/volumeosd.cpp + qml/volumefeedback.cpp + + model/sortfiltermodel.cpp +) + +set(qml_SRCS + qml/qmldir + qml/PulseObjectFilterModel.qml +) + +find_package(PkgConfig) +find_package(Canberra REQUIRED) + +find_package(CanberraPulse) +set_package_properties(CanberraPulse PROPERTIES + DESCRIPTION "Pulseaudio backend for libcanberra" + PURPOSE "Required for volume feedback sounds" + TYPE RUNTIME +) + +find_package(SoundThemeFreedesktop) +set_package_properties(SoundThemeFreedesktop PROPERTIES + DESCRIPTION "The standard freedesktop sound theme" + PURPOSE "Required for volume feedback sounds" + URL "https://www.freedesktop.org/wiki/Specifications/sound-theme-spec/" + TYPE RUNTIME +) + +pkg_check_modules(LIBPULSE libpulse REQUIRED IMPORTED_TARGET) +pkg_check_modules(LIBPULSE_MAINLOOP libpulse-mainloop-glib REQUIRED IMPORTED_TARGET) + +add_library(cutefishaudio_qmlplugins SHARED ${audio_SRCS}) + +target_link_libraries(cutefishaudio_qmlplugins + Qt5::Core + Qt5::Qml + Qt5::Gui + Qt5::Widgets + Qt5::DBus + Qt5::Quick + PkgConfig::LIBPULSE + PkgConfig::LIBPULSE_MAINLOOP +) + +install(TARGETS cutefishaudio_qmlplugins DESTINATION ${INSTALL_QMLDIR}/Cutefish/Audio) +install(FILES ${qml_SRCS} DESTINATION ${INSTALL_QMLDIR}/Cutefish/Audio) \ No newline at end of file diff --git a/audio/canberracontext.cpp b/audio/canberracontext.cpp new file mode 100644 index 0000000..336726a --- /dev/null +++ b/audio/canberracontext.cpp @@ -0,0 +1,51 @@ +/* + SPDX-FileCopyrightText: 2018 Nicolas Fella + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "canberracontext.h" + +namespace QPulseAudio +{ +CanberraContext *CanberraContext::s_context = nullptr; + +CanberraContext *CanberraContext::instance() +{ + if (!s_context) { + s_context = new CanberraContext; + } + return s_context; +} + +CanberraContext::CanberraContext(QObject *parent) + : QObject(parent) +{ + ca_context_create(&m_canberra); +} + +CanberraContext::~CanberraContext() +{ + if (m_canberra) { + ca_context_destroy(m_canberra); + } +} + +ca_context *CanberraContext::canberra() +{ + return m_canberra; +} + +void CanberraContext::ref() +{ + ++m_references; +} + +void CanberraContext::unref() +{ + if (--m_references == 0) { + delete this; + s_context = nullptr; + } +} +} diff --git a/audio/canberracontext.h b/audio/canberracontext.h new file mode 100644 index 0000000..52695b7 --- /dev/null +++ b/audio/canberracontext.h @@ -0,0 +1,35 @@ +/* + SPDX-FileCopyrightText: 2018 Nicolas Fella + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ +#pragma once + +#include +#include + +namespace QPulseAudio +{ +class CanberraContext : public QObject +{ + Q_OBJECT + +public: + explicit CanberraContext(QObject *parent = nullptr); + virtual ~CanberraContext(); + + static CanberraContext *instance(); + + ca_context *canberra(); + + void ref(); + void unref(); + +private: + ca_context *m_canberra = nullptr; + int m_references = 0; + + static CanberraContext *s_context; +}; + +} diff --git a/audio/card.cpp b/audio/card.cpp new file mode 100644 index 0000000..aff96f2 --- /dev/null +++ b/audio/card.cpp @@ -0,0 +1,112 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "card.h" + +#include "debug.h" + +#include "context.h" + +namespace QPulseAudio +{ +Card::Card(QObject *parent) + : PulseObject(parent) +{ +} + +void Card::update(const pa_card_info *info) +{ + updatePulseObject(info); + + QString infoName = QString::fromUtf8(info->name); + if (m_name != infoName) { + m_name = infoName; + Q_EMIT nameChanged(); + } + + const quint32 oldActiveProfileIndex = m_activeProfileIndex; + bool profilesHaveChanged = false; + int i = 0; + for (auto **it = info->profiles2; it && *it != nullptr; ++it) { + if (i < m_profiles.count()) { + Profile *profile = static_cast(m_profiles.at(i)); + profilesHaveChanged |= profile->setInfo(*it); + } else { + Profile *profile = new Profile(this); + profile->setInfo(*it); + m_profiles.append(profile); + profilesHaveChanged = true; + } + if (info->active_profile2 == *it) { + m_activeProfileIndex = i; + } + ++i; + } + + while (m_profiles.count() > i) { + delete m_profiles.takeLast(); + profilesHaveChanged = true; + } + + if (profilesHaveChanged) { + Q_EMIT profilesChanged(); + } + if (profilesHaveChanged || m_activeProfileIndex != oldActiveProfileIndex) { + Q_EMIT activeProfileIndexChanged(); + } + + bool portsHaveChanged = false; + i = 0; + for (auto **ports = info->ports; ports && *ports != nullptr; ++ports) { + if (i < m_ports.count()) { + Port *port = static_cast(m_ports.at(i)); + portsHaveChanged |= port->setInfo(*ports); + } else { + Port *port = new Port(this); + port->setInfo(*ports); + m_ports.append(port); + portsHaveChanged = true; + } + ++i; + } + + while (m_ports.count() > i) { + delete m_ports.takeLast(); + portsHaveChanged = true; + } + + if (portsHaveChanged) { + Q_EMIT portsChanged(); + } +} + +QString Card::name() const +{ + return m_name; +} + +QList Card::profiles() const +{ + return m_profiles; +} + +quint32 Card::activeProfileIndex() const +{ + return m_activeProfileIndex; +} + +void Card::setActiveProfileIndex(quint32 profileIndex) +{ + const Profile *profile = qobject_cast(profiles().at(profileIndex)); + context()->setCardProfile(index(), profile->name()); +} + +QList Card::ports() const +{ + return m_ports; +} + +} // QPulseAudio diff --git a/audio/card.h b/audio/card.h new file mode 100644 index 0000000..818cdfe --- /dev/null +++ b/audio/card.h @@ -0,0 +1,108 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef CARD_H +#define CARD_H + +#include + +#include +#include + +#include "port.h" +#include "profile.h" +#include "pulseobject.h" + +namespace QPulseAudio +{ +class CardPort : public Port +{ + Q_OBJECT + Q_PROPERTY(QVariantMap properties READ properties NOTIFY propertiesChanged) +public: + explicit CardPort(QObject *parent = nullptr) + : Port(parent) + { + } + ~CardPort() override + { + } + + // int direction; /**< A #pa_direction enum, indicating the direction of this port. */ + // uint32_t n_profiles; /**< Number of entries in profile array */ + // pa_card_profile_info** profiles; /**< \deprecated Superseded by profiles2 */ + // int64_t latency_offset; /**< Latency offset of the port that gets added to the sink/source latency when the port is active. \since 3.0 */ + // pa_card_profile_info2** profiles2; /**< Array of pointers to available profiles, or NULL. Array is terminated by an entry set to NULL. \since 5.0 */ + + void update(const pa_card_port_info *info) + { + setInfo(info); + + QVariantMap properties; + void *it = nullptr; + while (const char *key = pa_proplist_iterate(info->proplist, &it)) { + Q_ASSERT(key); + const char *value = pa_proplist_gets(info->proplist, key); + if (!value) { + qCDebug(PLASMAPA) << "property" << key << "not a string"; + continue; + } + Q_ASSERT(value); + properties.insert(QString::fromUtf8(key), QString::fromUtf8(value)); + } + + if (m_properties != properties) { + m_properties = properties; + Q_EMIT propertiesChanged(); + } + } + + QVariantMap properties() const + { + return m_properties; + } + +Q_SIGNALS: + void propertiesChanged(); + +private: + QVariantMap m_properties; +}; + +class Card : public PulseObject +{ + Q_OBJECT + Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(QList profiles READ profiles NOTIFY profilesChanged) + Q_PROPERTY(quint32 activeProfileIndex READ activeProfileIndex WRITE setActiveProfileIndex NOTIFY activeProfileIndexChanged) + Q_PROPERTY(QList ports READ ports NOTIFY portsChanged) +public: + explicit Card(QObject *parent); + + void update(const pa_card_info *info); + + QString name() const; + QList profiles() const; + quint32 activeProfileIndex() const; + void setActiveProfileIndex(quint32 profileIndex); + QList ports() const; + +Q_SIGNALS: + void nameChanged(); + void profilesChanged(); + void activeProfileIndexChanged(); + void portsChanged(); + +private: + QString m_name; + QList m_profiles; + quint32 m_activeProfileIndex = -1; + QList m_ports; +}; + +} // QPulseAudio + +#endif // CARD_H diff --git a/audio/client.cpp b/audio/client.cpp new file mode 100644 index 0000000..f5da7e2 --- /dev/null +++ b/audio/client.cpp @@ -0,0 +1,38 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "client.h" + +#include "debug.h" + +namespace QPulseAudio +{ +Client::Client(QObject *parent) + : PulseObject(parent) +{ +} + +Client::~Client() +{ +} + +void Client::update(const pa_client_info *info) +{ + updatePulseObject(info); + + QString infoName = QString::fromUtf8(info->name); + if (m_name != infoName) { + m_name = infoName; + Q_EMIT nameChanged(); + } +} + +QString Client::name() const +{ + return m_name; +} + +} // QPulseAudio diff --git a/audio/client.h b/audio/client.h new file mode 100644 index 0000000..2d1f633 --- /dev/null +++ b/audio/client.h @@ -0,0 +1,39 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef CLIENT_H +#define CLIENT_H + +#include + +#include + +#include "pulseobject.h" + +namespace QPulseAudio +{ +class Client : public PulseObject +{ + Q_OBJECT + Q_PROPERTY(QString name READ name NOTIFY nameChanged) +public: + explicit Client(QObject *parent); + ~Client() override; + + void update(const pa_client_info *info); + + QString name() const; + +Q_SIGNALS: + void nameChanged(); + +private: + QString m_name; +}; + +} // QPulseAudio + +#endif // CLIENT_H diff --git a/audio/config.h.cmake b/audio/config.h.cmake new file mode 100644 index 0000000..9f57f3b --- /dev/null +++ b/audio/config.h.cmake @@ -0,0 +1,4 @@ +/* config.h. Generated by cmake from config.h.cmake */ + +#cmakedefine01 USE_GSETTINGS +#cmakedefine01 USE_GCONF \ No newline at end of file diff --git a/audio/context.cpp b/audio/context.cpp new file mode 100644 index 0000000..4139abc --- /dev/null +++ b/audio/context.cpp @@ -0,0 +1,627 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "context.h" +#include "server.h" + +#include "debug.h" +#include +#include +#include +#include +#include +#include + +#include "card.h" +#include "client.h" +#include "module.h" +#include "sink.h" +#include "sinkinput.h" +#include "source.h" +#include "sourceoutput.h" +#include "streamrestore.h" + +namespace QPulseAudio +{ +Context *Context::s_context = nullptr; +QString Context::s_applicationId; + +const qint64 Context::NormalVolume = PA_VOLUME_NORM; +const qint64 Context::MinimalVolume = 0; +const qint64 Context::MaximalVolume = (PA_VOLUME_NORM / 100.0) * 150; + +static bool isGoodState(int eol) +{ + if (eol < 0) { + // Error + return false; + } + + if (eol > 0) { + // End of callback chain + return false; + } + + return true; +} + +// -------------------------- + +static void sink_cb(pa_context *context, const pa_sink_info *info, int eol, void *data) +{ + if (!isGoodState(eol)) { + return; + } + Q_ASSERT(context); + Q_ASSERT(data); + ((Context *)data)->sinkCallback(info); +} + +static void sink_input_callback(pa_context *context, const pa_sink_input_info *info, int eol, void *data) +{ + if (!isGoodState(eol)) { + return; + } + // pulsesink probe is used by gst-pulse only to query sink formats (not for playback) + if (qstrcmp(info->name, "pulsesink probe") == 0) { + return; + } + if (const char *id = pa_proplist_gets(info->proplist, "module-stream-restore.id")) { + if (qstrcmp(id, "sink-input-by-media-role:event") == 0) { + qCDebug(PLASMAPA) << "Ignoring event role sink input."; + return; + } + } + Q_ASSERT(context); + Q_ASSERT(data); + ((Context *)data)->sinkInputCallback(info); +} + +static void source_cb(pa_context *context, const pa_source_info *info, int eol, void *data) +{ + if (!isGoodState(eol)) { + return; + } + // FIXME: This forces excluding monitors + if (info->monitor_of_sink != PA_INVALID_INDEX) { + return; + } + Q_ASSERT(context); + Q_ASSERT(data); + ((Context *)data)->sourceCallback(info); +} + +static void source_output_cb(pa_context *context, const pa_source_output_info *info, int eol, void *data) +{ + if (!isGoodState(eol)) { + return; + } + // FIXME: This forces excluding these apps + if (const char *app = pa_proplist_gets(info->proplist, PA_PROP_APPLICATION_ID)) { + if (strcmp(app, "org.PulseAudio.pavucontrol") == 0 // + || strcmp(app, "org.gnome.VolumeControl") == 0 // + || strcmp(app, "org.kde.kmixd") == 0 // + || strcmp(app, "org.kde.plasma-pa") == 0) { + return; + } + } + Q_ASSERT(context); + Q_ASSERT(data); + ((Context *)data)->sourceOutputCallback(info); +} + +static void client_cb(pa_context *context, const pa_client_info *info, int eol, void *data) +{ + if (!isGoodState(eol)) { + return; + } + Q_ASSERT(context); + Q_ASSERT(data); + ((Context *)data)->clientCallback(info); +} + +static void card_cb(pa_context *context, const pa_card_info *info, int eol, void *data) +{ + if (!isGoodState(eol)) { + return; + } + Q_ASSERT(context); + Q_ASSERT(data); + ((Context *)data)->cardCallback(info); +} + +static void module_info_list_cb(pa_context *context, const pa_module_info *info, int eol, void *data) +{ + if (!isGoodState(eol)) { + return; + } + Q_ASSERT(context); + Q_ASSERT(data); + ((Context *)data)->moduleCallback(info); +} + +static void server_cb(pa_context *context, const pa_server_info *info, void *data) +{ + Q_ASSERT(context); + Q_ASSERT(data); + ((Context *)data)->serverCallback(info); +} + +static void context_state_callback(pa_context *context, void *data) +{ + Q_ASSERT(data); + ((Context *)data)->contextStateCallback(context); +} + +static void subscribe_cb(pa_context *context, pa_subscription_event_type_t type, uint32_t index, void *data) +{ + Q_ASSERT(data); + ((Context *)data)->subscribeCallback(context, type, index); +} + +static void ext_stream_restore_read_cb(pa_context *context, const pa_ext_stream_restore_info *info, int eol, void *data) +{ + if (!isGoodState(eol)) { + return; + } + Q_ASSERT(context); + Q_ASSERT(data); + ((Context *)data)->streamRestoreCallback(info); +} + +static void ext_stream_restore_subscribe_cb(pa_context *context, void *data) +{ + Q_ASSERT(context); + Q_ASSERT(data); + if (!PAOperation(pa_ext_stream_restore_read(context, ext_stream_restore_read_cb, data))) { + qCWarning(PLASMAPA) << "pa_ext_stream_restore_read() failed"; + } +} + +static void ext_stream_restore_change_sink_cb(pa_context *context, const pa_ext_stream_restore_info *info, int eol, void *data) +{ + if (!isGoodState(eol)) { + return; + } + Q_ASSERT(context); + Q_ASSERT(data); + if (qstrncmp(info->name, "sink-input-by", 13) == 0) { + Context *context = static_cast(data); + const QByteArray deviceData = context->newDefaultSink().toUtf8(); + pa_ext_stream_restore_info newinfo; + newinfo.name = info->name; + newinfo.channel_map = info->channel_map; + newinfo.volume = info->volume; + newinfo.mute = info->mute; + newinfo.device = deviceData.constData(); + context->streamRestoreWrite(&newinfo); + } +} + +static void ext_stream_restore_change_source_cb(pa_context *context, const pa_ext_stream_restore_info *info, int eol, void *data) +{ + if (!isGoodState(eol)) { + return; + } + Q_ASSERT(context); + Q_ASSERT(data); + if (qstrncmp(info->name, "source-output-by", 16) == 0) { + Context *context = static_cast(data); + const QByteArray deviceData = context->newDefaultSource().toUtf8(); + pa_ext_stream_restore_info newinfo; + newinfo.name = info->name; + newinfo.channel_map = info->channel_map; + newinfo.volume = info->volume; + newinfo.mute = info->mute; + newinfo.device = deviceData.constData(); + context->streamRestoreWrite(&newinfo); + } +} + +// -------------------------- + +Context::Context(QObject *parent) + : QObject(parent) + , m_server(new Server(this)) + , m_context(nullptr) + , m_mainloop(nullptr) + , m_references(0) +{ + QDBusServiceWatcher *watcher = new QDBusServiceWatcher(QStringLiteral("org.pulseaudio.Server"), // + QDBusConnection::sessionBus(), + QDBusServiceWatcher::WatchForRegistration, + this); + connect(watcher, &QDBusServiceWatcher::serviceRegistered, this, &Context::connectToDaemon); + connectToDaemon(); +} + +Context::~Context() +{ + if (m_context) { + pa_context_unref(m_context); + m_context = nullptr; + } + + if (m_mainloop) { + pa_glib_mainloop_free(m_mainloop); + m_mainloop = nullptr; + } + + reset(); +} + +Context *Context::instance() +{ + if (!s_context) { + s_context = new Context; + } + return s_context; +} + +void Context::ref() +{ + ++m_references; +} + +void Context::unref() +{ + if (--m_references == 0) { + delete this; + s_context = nullptr; + } +} + +void Context::subscribeCallback(pa_context *context, pa_subscription_event_type_t type, uint32_t index) +{ + Q_ASSERT(context == m_context); + + switch (type & PA_SUBSCRIPTION_EVENT_FACILITY_MASK) { + case PA_SUBSCRIPTION_EVENT_SINK: + if ((type & PA_SUBSCRIPTION_EVENT_TYPE_MASK) == PA_SUBSCRIPTION_EVENT_REMOVE) { + m_sinks.removeEntry(index); + } else { + if (!PAOperation(pa_context_get_sink_info_by_index(context, index, sink_cb, this))) { + qCWarning(PLASMAPA) << "pa_context_get_sink_info_by_index() failed"; + return; + } + } + break; + + case PA_SUBSCRIPTION_EVENT_SOURCE: + if ((type & PA_SUBSCRIPTION_EVENT_TYPE_MASK) == PA_SUBSCRIPTION_EVENT_REMOVE) { + m_sources.removeEntry(index); + } else { + if (!PAOperation(pa_context_get_source_info_by_index(context, index, source_cb, this))) { + qCWarning(PLASMAPA) << "pa_context_get_source_info_by_index() failed"; + return; + } + } + break; + + case PA_SUBSCRIPTION_EVENT_SINK_INPUT: + if ((type & PA_SUBSCRIPTION_EVENT_TYPE_MASK) == PA_SUBSCRIPTION_EVENT_REMOVE) { + m_sinkInputs.removeEntry(index); + } else { + if (!PAOperation(pa_context_get_sink_input_info(context, index, sink_input_callback, this))) { + qCWarning(PLASMAPA) << "pa_context_get_sink_input_info() failed"; + return; + } + } + break; + + case PA_SUBSCRIPTION_EVENT_SOURCE_OUTPUT: + if ((type & PA_SUBSCRIPTION_EVENT_TYPE_MASK) == PA_SUBSCRIPTION_EVENT_REMOVE) { + m_sourceOutputs.removeEntry(index); + } else { + if (!PAOperation(pa_context_get_source_output_info(context, index, source_output_cb, this))) { + qCWarning(PLASMAPA) << "pa_context_get_sink_input_info() failed"; + return; + } + } + break; + + case PA_SUBSCRIPTION_EVENT_CLIENT: + if ((type & PA_SUBSCRIPTION_EVENT_TYPE_MASK) == PA_SUBSCRIPTION_EVENT_REMOVE) { + m_clients.removeEntry(index); + } else { + if (!PAOperation(pa_context_get_client_info(context, index, client_cb, this))) { + qCWarning(PLASMAPA) << "pa_context_get_client_info() failed"; + return; + } + } + break; + + case PA_SUBSCRIPTION_EVENT_CARD: + if ((type & PA_SUBSCRIPTION_EVENT_TYPE_MASK) == PA_SUBSCRIPTION_EVENT_REMOVE) { + m_cards.removeEntry(index); + } else { + if (!PAOperation(pa_context_get_card_info_by_index(context, index, card_cb, this))) { + qCWarning(PLASMAPA) << "pa_context_get_card_info_by_index() failed"; + return; + } + } + break; + + case PA_SUBSCRIPTION_EVENT_MODULE: + if ((type & PA_SUBSCRIPTION_EVENT_TYPE_MASK) == PA_SUBSCRIPTION_EVENT_REMOVE) { + m_modules.removeEntry(index); + } else { + if (!PAOperation(pa_context_get_module_info_list(context, module_info_list_cb, this))) { + qCWarning(PLASMAPA) << "pa_context_get_module_info_list() failed"; + return; + } + } + break; + + case PA_SUBSCRIPTION_EVENT_SERVER: + if (!PAOperation(pa_context_get_server_info(context, server_cb, this))) { + qCWarning(PLASMAPA) << "pa_context_get_server_info() failed"; + return; + } + break; + } +} + +void Context::contextStateCallback(pa_context *c) +{ + qCDebug(PLASMAPA) << "state callback"; + pa_context_state_t state = pa_context_get_state(c); + if (state == PA_CONTEXT_READY) { + qCDebug(PLASMAPA) << "ready"; + + // 1. Register for the stream changes (except during probe) + if (m_context == c) { + pa_context_set_subscribe_callback(c, subscribe_cb, this); + + if (!PAOperation( + pa_context_subscribe(c, + (pa_subscription_mask_t)(PA_SUBSCRIPTION_MASK_SINK | PA_SUBSCRIPTION_MASK_SOURCE | PA_SUBSCRIPTION_MASK_CLIENT + | PA_SUBSCRIPTION_MASK_SINK_INPUT | PA_SUBSCRIPTION_MASK_SOURCE_OUTPUT + | PA_SUBSCRIPTION_MASK_CARD | PA_SUBSCRIPTION_MASK_MODULE | PA_SUBSCRIPTION_MASK_SERVER), + nullptr, + nullptr))) { + qCWarning(PLASMAPA) << "pa_context_subscribe() failed"; + return; + } + } + + if (!PAOperation(pa_context_get_sink_info_list(c, sink_cb, this))) { + qCWarning(PLASMAPA) << "pa_context_get_sink_info_list() failed"; + return; + } + + if (!PAOperation(pa_context_get_source_info_list(c, source_cb, this))) { + qCWarning(PLASMAPA) << "pa_context_get_source_info_list() failed"; + return; + } + + if (!PAOperation(pa_context_get_client_info_list(c, client_cb, this))) { + qCWarning(PLASMAPA) << "pa_context_client_info_list() failed"; + return; + } + + if (!PAOperation(pa_context_get_card_info_list(c, card_cb, this))) { + qCWarning(PLASMAPA) << "pa_context_get_card_info_list() failed"; + return; + } + + if (!PAOperation(pa_context_get_sink_input_info_list(c, sink_input_callback, this))) { + qCWarning(PLASMAPA) << "pa_context_get_sink_input_info_list() failed"; + return; + } + + if (!PAOperation(pa_context_get_source_output_info_list(c, source_output_cb, this))) { + qCWarning(PLASMAPA) << "pa_context_get_source_output_info_list() failed"; + return; + } + + if (!PAOperation(pa_context_get_module_info_list(c, module_info_list_cb, this))) { + qCWarning(PLASMAPA) << "pa_context_get_module_info_list() failed"; + return; + } + + if (!PAOperation(pa_context_get_server_info(c, server_cb, this))) { + qCWarning(PLASMAPA) << "pa_context_get_server_info() failed"; + return; + } + + if (PAOperation(pa_ext_stream_restore_read(c, ext_stream_restore_read_cb, this))) { + pa_ext_stream_restore_set_subscribe_cb(c, ext_stream_restore_subscribe_cb, this); + PAOperation(pa_ext_stream_restore_subscribe(c, 1, nullptr, this)); + } else { + qCWarning(PLASMAPA) << "Failed to initialize stream_restore extension"; + } + } else if (!PA_CONTEXT_IS_GOOD(state)) { + qCWarning(PLASMAPA) << "context kaput"; + if (m_context) { + pa_context_unref(m_context); + m_context = nullptr; + } + reset(); + QTimer::singleShot(1000, this, &Context::connectToDaemon); + } +} + +void Context::sinkCallback(const pa_sink_info *info) +{ + // This parenting here is a bit weird + m_sinks.updateEntry(info, this); +} + +void Context::sinkInputCallback(const pa_sink_input_info *info) +{ + m_sinkInputs.updateEntry(info, this); +} + +void Context::sourceCallback(const pa_source_info *info) +{ + m_sources.updateEntry(info, this); +} + +void Context::sourceOutputCallback(const pa_source_output_info *info) +{ + m_sourceOutputs.updateEntry(info, this); +} + +void Context::clientCallback(const pa_client_info *info) +{ + m_clients.updateEntry(info, this); +} + +void Context::cardCallback(const pa_card_info *info) +{ + m_cards.updateEntry(info, this); +} + +void Context::moduleCallback(const pa_module_info *info) +{ + m_modules.updateEntry(info, this); +} + +void Context::streamRestoreCallback(const pa_ext_stream_restore_info *info) +{ + if (qstrcmp(info->name, "sink-input-by-media-role:event") != 0) { + return; + } + + const int eventRoleIndex = 1; + StreamRestore *obj = qobject_cast(m_streamRestores.data().value(eventRoleIndex)); + + if (!obj) { + QVariantMap props; + props.insert(QStringLiteral("application.icon_name"), QStringLiteral("preferences-desktop-notification")); + obj = new StreamRestore(eventRoleIndex, props, this); + obj->update(info); + m_streamRestores.insert(obj); + } else { + obj->update(info); + } +} + +void Context::serverCallback(const pa_server_info *info) +{ + m_server->update(info); +} + +void Context::setCardProfile(quint32 index, const QString &profile) +{ + if (!m_context) { + return; + } + qCDebug(PLASMAPA) << index << profile; + if (!PAOperation(pa_context_set_card_profile_by_index(m_context, index, profile.toUtf8().constData(), nullptr, nullptr))) { + qCWarning(PLASMAPA) << "pa_context_set_card_profile_by_index failed"; + return; + } +} + +void Context::setDefaultSink(const QString &name) +{ + if (!m_context) { + return; + } + const QByteArray nameData = name.toUtf8(); + if (!PAOperation(pa_context_set_default_sink(m_context, nameData.constData(), nullptr, nullptr))) { + qCWarning(PLASMAPA) << "pa_context_set_default_sink failed"; + } + + // Change device for all entries in stream-restore database + m_newDefaultSink = name; + if (!PAOperation(pa_ext_stream_restore_read(m_context, ext_stream_restore_change_sink_cb, this))) { + qCWarning(PLASMAPA) << "pa_ext_stream_restore_read failed"; + } +} + +void Context::setDefaultSource(const QString &name) +{ + if (!m_context) { + return; + } + const QByteArray nameData = name.toUtf8(); + if (!PAOperation(pa_context_set_default_source(m_context, nameData.constData(), nullptr, nullptr))) { + qCWarning(PLASMAPA) << "pa_context_set_default_source failed"; + } + + // Change device for all entries in stream-restore database + m_newDefaultSource = name; + if (!PAOperation(pa_ext_stream_restore_read(m_context, ext_stream_restore_change_source_cb, this))) { + qCWarning(PLASMAPA) << "pa_ext_stream_restore_read failed"; + } +} + +void Context::streamRestoreWrite(const pa_ext_stream_restore_info *info) +{ + if (!m_context) { + return; + } + if (!PAOperation(pa_ext_stream_restore_write(m_context, PA_UPDATE_REPLACE, info, 1, true, nullptr, nullptr))) { + qCWarning(PLASMAPA) << "pa_ext_stream_restore_write failed"; + } +} + +void Context::connectToDaemon() +{ + if (m_context) { + return; + } + + // We require a glib event loop + if (!QByteArray(QAbstractEventDispatcher::instance()->metaObject()->className()).contains("EventDispatcherGlib") + && !QByteArray(QAbstractEventDispatcher::instance()->metaObject()->className()).contains("GlibEventDispatcher")) { + qCWarning(PLASMAPA) << "Disabling PulseAudio integration for lack of GLib event loop"; + return; + } + + qCDebug(PLASMAPA) << "Attempting connection to PulseAudio sound daemon"; + if (!m_mainloop) { + m_mainloop = pa_glib_mainloop_new(nullptr); + Q_ASSERT(m_mainloop); + } + + pa_mainloop_api *api = pa_glib_mainloop_get_api(m_mainloop); + Q_ASSERT(api); + + pa_proplist *proplist = pa_proplist_new(); + pa_proplist_sets(proplist, PA_PROP_APPLICATION_NAME, QString("Cutefish PA").toUtf8().constData()); + if (!s_applicationId.isEmpty()) { + pa_proplist_sets(proplist, PA_PROP_APPLICATION_ID, s_applicationId.toUtf8().constData()); + } else { + pa_proplist_sets(proplist, PA_PROP_APPLICATION_ID, QGuiApplication::desktopFileName().toUtf8().constData()); + } + pa_proplist_sets(proplist, PA_PROP_APPLICATION_ICON_NAME, "audio-card"); + m_context = pa_context_new_with_proplist(api, nullptr, proplist); + pa_proplist_free(proplist); + Q_ASSERT(m_context); + + if (pa_context_connect(m_context, nullptr, PA_CONTEXT_NOFAIL, nullptr) < 0) { + pa_context_unref(m_context); + pa_glib_mainloop_free(m_mainloop); + m_context = nullptr; + m_mainloop = nullptr; + return; + } + pa_context_set_state_callback(m_context, &context_state_callback, this); +} + +void Context::reset() +{ + m_sinks.reset(); + m_sinkInputs.reset(); + m_sources.reset(); + m_sourceOutputs.reset(); + m_clients.reset(); + m_cards.reset(); + m_modules.reset(); + m_streamRestores.reset(); + m_server->reset(); +} + +void Context::setApplicationId(const QString &applicationId) +{ + s_applicationId = applicationId; +} + +} // QPulseAudio diff --git a/audio/context.h b/audio/context.h new file mode 100644 index 0000000..3d1f752 --- /dev/null +++ b/audio/context.h @@ -0,0 +1,223 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef CONTEXT_H +#define CONTEXT_H + +#include +#include +#include + +#include +#include +#include +#include + +#include "maps.h" +#include "operation.h" + +namespace QPulseAudio +{ +class Server; + +class Context : public QObject +{ + Q_OBJECT +public: + explicit Context(QObject *parent = nullptr); + ~Context() override; + + static Context *instance(); + + static const qint64 NormalVolume; + static const qint64 MinimalVolume; + static const qint64 MaximalVolume; + + void ref(); + void unref(); + + bool isValid() + { + return m_context && m_mainloop; + } + + pa_context *context() const + { + return m_context; + } + + const SinkMap &sinks() const + { + return m_sinks; + } + const SinkInputMap &sinkInputs() const + { + return m_sinkInputs; + } + const SourceMap &sources() const + { + return m_sources; + } + const SourceOutputMap &sourceOutputs() const + { + return m_sourceOutputs; + } + const ClientMap &clients() const + { + return m_clients; + } + const CardMap &cards() const + { + return m_cards; + } + const ModuleMap &modules() const + { + return m_modules; + } + const StreamRestoreMap &streamRestores() const + { + return m_streamRestores; + } + Server *server() const + { + return m_server; + } + QString newDefaultSink() const + { + return m_newDefaultSink; + } + QString newDefaultSource() const + { + return m_newDefaultSource; + } + + void subscribeCallback(pa_context *context, pa_subscription_event_type_t type, uint32_t index); + void contextStateCallback(pa_context *context); + + void sinkCallback(const pa_sink_info *info); + void sinkInputCallback(const pa_sink_input_info *info); + void sourceCallback(const pa_source_info *info); + void sourceOutputCallback(const pa_source_output_info *info); + void clientCallback(const pa_client_info *info); + void cardCallback(const pa_card_info *info); + void moduleCallback(const pa_module_info *info); + void streamRestoreCallback(const pa_ext_stream_restore_info *info); + void serverCallback(const pa_server_info *info); + + void setCardProfile(quint32 index, const QString &profile); + void setDefaultSink(const QString &name); + void setDefaultSource(const QString &name); + void streamRestoreWrite(const pa_ext_stream_restore_info *info); + + static void setApplicationId(const QString &applicationId); + + template + void setGenericVolume(quint32 index, int channel, qint64 newVolume, pa_cvolume cVolume, PAFunction pa_set_volume) + { + if (!m_context) { + return; + } + newVolume = qBound(0, newVolume, PA_VOLUME_MAX); + pa_cvolume newCVolume = cVolume; + if (channel == -1) { // -1 all channels + const qint64 diff = newVolume - pa_cvolume_max(&cVolume); + for (int i = 0; i < newCVolume.channels; ++i) { + newCVolume.values[i] = qBound(0, newCVolume.values[i] + diff, PA_VOLUME_MAX); + } + } else { + Q_ASSERT(newCVolume.channels > channel); + newCVolume.values[channel] = newVolume; + } + if (!PAOperation(pa_set_volume(m_context, index, &newCVolume, nullptr, nullptr))) { + qCWarning(PLASMAPA) << "pa_set_volume failed"; + return; + } + } + + template + void setGenericVolumes(quint32 index, QVector channelVolumes, pa_cvolume cVolume, PAFunction pa_set_volume) + { + if (!m_context) { + return; + } + Q_ASSERT(channelVolumes.count() == cVolume.channels); + + pa_cvolume newCVolume = cVolume; + for (int i = 0; i < channelVolumes.count(); ++i) { + newCVolume.values[i] = qBound(0, channelVolumes.at(i), PA_VOLUME_MAX); + } + + if (!PAOperation(pa_set_volume(m_context, index, &newCVolume, nullptr, nullptr))) { + qCWarning(PLASMAPA) << "pa_set_volume failed"; + return; + } + } + + template + void setGenericMute(quint32 index, bool mute, PAFunction pa_set_mute) + { + if (!m_context) { + return; + } + if (!PAOperation(pa_set_mute(m_context, index, mute, nullptr, nullptr))) { + qCWarning(PLASMAPA) << "pa_set_mute failed"; + return; + } + } + + template + void setGenericPort(quint32 index, const QString &portName, PAFunction pa_set_port) + { + if (!m_context) { + return; + } + if (!PAOperation(pa_set_port(m_context, index, portName.toUtf8().constData(), nullptr, nullptr))) { + qCWarning(PLASMAPA) << "pa_set_port failed"; + return; + } + } + + template + void setGenericDeviceForStream(quint32 streamIndex, quint32 deviceIndex, PAFunction pa_move_stream_to_device) + { + if (!m_context) { + return; + } + if (!PAOperation(pa_move_stream_to_device(m_context, streamIndex, deviceIndex, nullptr, nullptr))) { + qCWarning(PLASMAPA) << "pa_move_stream_to_device failed"; + return; + } + } + +private: + void connectToDaemon(); + void reset(); + + // Don't forget to add things to reset(). + SinkMap m_sinks; + SinkInputMap m_sinkInputs; + SourceMap m_sources; + SourceOutputMap m_sourceOutputs; + ClientMap m_clients; + CardMap m_cards; + ModuleMap m_modules; + StreamRestoreMap m_streamRestores; + Server *m_server; + + pa_context *m_context; + pa_glib_mainloop *m_mainloop; + + QString m_newDefaultSink; + QString m_newDefaultSource; + + int m_references; + static Context *s_context; + static QString s_applicationId; +}; + +} // QPulseAudio + +#endif // CONTEXT_H diff --git a/audio/debug.cpp b/audio/debug.cpp new file mode 100644 index 0000000..ac6697e --- /dev/null +++ b/audio/debug.cpp @@ -0,0 +1,9 @@ +/* This file is part of the KDE project + SPDX-FileCopyrightText: 2015 Bhushan Shah + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#include "debug.h" + +Q_LOGGING_CATEGORY(PLASMAPA, "com.cutefish.pulseaudio", QtWarningMsg) diff --git a/audio/debug.h b/audio/debug.h new file mode 100644 index 0000000..017c87a --- /dev/null +++ b/audio/debug.h @@ -0,0 +1,13 @@ +/* This file is part of the KDE project + SPDX-FileCopyrightText: 2015 Bhushan Shah + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#ifndef DEBUG_H +#define DEBUG_H + +#include +Q_DECLARE_LOGGING_CATEGORY(PLASMAPA) + +#endif diff --git a/audio/device.cpp b/audio/device.cpp new file mode 100644 index 0000000..5ff6746 --- /dev/null +++ b/audio/device.cpp @@ -0,0 +1,68 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "device.h" + +QPulseAudio::Device::State QPulseAudio::Device::state() const +{ + return m_state; +} + +QString QPulseAudio::Device::name() const +{ + return m_name; +} + +QString QPulseAudio::Device::description() const +{ + return m_description; +} + +QString QPulseAudio::Device::formFactor() const +{ + return m_formFactor; +} + +quint32 QPulseAudio::Device::cardIndex() const +{ + return m_cardIndex; +} + +QList QPulseAudio::Device::ports() const +{ + return m_ports; +} + +quint32 QPulseAudio::Device::activePortIndex() const +{ + return m_activePortIndex; +} + +bool QPulseAudio::Device::isVirtualDevice() const +{ + return m_virtualDevice; +} + +QPulseAudio::Device::Device(QObject *parent) + : VolumeObject(parent) +{ +} + +QPulseAudio::Device::State QPulseAudio::Device::stateFromPaState(int value) const +{ + switch (value) { + case -1: // PA_X_INVALID_STATE + return InvalidState; + case 0: // PA_X_RUNNING + return RunningState; + case 1: // PA_X_IDLE + return IdleState; + case 2: // PA_X_SUSPENDED + return SuspendedState; + default: + return UnknownState; + } +} diff --git a/audio/device.h b/audio/device.h new file mode 100644 index 0000000..bba8ad7 --- /dev/null +++ b/audio/device.h @@ -0,0 +1,160 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef DEVICE_H +#define DEVICE_H + +#include + +#include + +#include "port.h" +#include "pulseobject.h" +#include "volumeobject.h" + +namespace QPulseAudio +{ +class Device : public VolumeObject +{ + Q_OBJECT + Q_PROPERTY(State state READ state NOTIFY stateChanged) + Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(QString description READ description NOTIFY descriptionChanged) + Q_PROPERTY(QString formFactor READ formFactor NOTIFY formFactorChanged) + Q_PROPERTY(quint32 cardIndex READ cardIndex NOTIFY cardIndexChanged) + Q_PROPERTY(QList ports READ ports NOTIFY portsChanged) + Q_PROPERTY(quint32 activePortIndex READ activePortIndex WRITE setActivePortIndex NOTIFY activePortIndexChanged) + Q_PROPERTY(bool default READ isDefault WRITE setDefault NOTIFY defaultChanged) + Q_PROPERTY(bool virtualDevice READ isVirtualDevice NOTIFY virtualDeviceChanged) +public: + enum State { + InvalidState = 0, + RunningState, + IdleState, + SuspendedState, + UnknownState, + }; + Q_ENUMS(State); + + ~Device() override + { + } + + template + void updateDevice(const PAInfo *info) + { + updateVolumeObject(info); + + if (m_name != info->name) { + m_name = info->name; + Q_EMIT nameChanged(); + } + if (m_description != info->description) { + m_description = info->description; + Q_EMIT descriptionChanged(); + } + const char *form_factor = pa_proplist_gets(info->proplist, PA_PROP_DEVICE_FORM_FACTOR); + if (form_factor) { + QString formFactor = QString::fromUtf8(form_factor); + if (m_formFactor != formFactor) { + m_formFactor = formFactor; + Q_EMIT formFactorChanged(); + } + } + + if (m_cardIndex != info->card) { + m_cardIndex = info->card; + Q_EMIT cardIndexChanged(); + } + + const quint32 oldActivePortIndex = m_activePortIndex; + bool portsHaveChanged = false; + int i = 0; + for (auto **ports = info->ports; ports && *ports != nullptr; ++ports) { + if (i < m_ports.count()) { + Port *port = static_cast(m_ports.at(i)); + portsHaveChanged |= port->setInfo(*ports); + } else { + Port *port = new Port(this); + port->setInfo(*ports); + m_ports.append(port); + portsHaveChanged = true; + } + if (info->active_port == *ports) { + m_activePortIndex = i; + } + ++i; + } + + while (m_ports.count() > i) { + delete m_ports.takeLast(); + portsHaveChanged = true; + } + + if (portsHaveChanged) { + Q_EMIT portsChanged(); + } + if (portsHaveChanged || m_activePortIndex != oldActivePortIndex) { + Q_EMIT activePortIndexChanged(); + } + + State infoState = stateFromPaState(info->state); + if (infoState != m_state) { + m_state = infoState; + Q_EMIT stateChanged(); + } + + const bool isVirtual = !(info->flags & 4); // PA_X_HARDWARE + if (m_virtualDevice != isVirtual) { + m_virtualDevice = isVirtual; + Q_EMIT virtualDeviceChanged(); + } + } + + State state() const; + QString name() const; + QString description() const; + QString formFactor() const; + quint32 cardIndex() const; + QList ports() const; + quint32 activePortIndex() const; + virtual void setActivePortIndex(quint32 port_index) = 0; + virtual bool isDefault() const = 0; + virtual void setDefault(bool enable) = 0; + bool isVirtualDevice() const; + + virtual Q_INVOKABLE void switchStreams() = 0; + +Q_SIGNALS: + void stateChanged(); + void nameChanged(); + void descriptionChanged(); + void formFactorChanged(); + void cardIndexChanged(); + void portsChanged(); + void activePortIndexChanged(); + void defaultChanged(); + void virtualDeviceChanged(); + +protected: + explicit Device(QObject *parent); + +private: + State stateFromPaState(int value) const; + + QString m_name; + QString m_description; + QString m_formFactor; + quint32 m_cardIndex = -1; + QList m_ports; + quint32 m_activePortIndex = -1; + State m_state = UnknownState; + bool m_virtualDevice = false; +}; + +} // QPulseAudio + +#endif // DEVICE_H diff --git a/audio/gconfitem.cpp b/audio/gconfitem.cpp new file mode 100644 index 0000000..c51f0b8 --- /dev/null +++ b/audio/gconfitem.cpp @@ -0,0 +1,313 @@ +/* + * SPDX-FileCopyrightText: 2009 Nokia Corporation and/or its subsidiary(-ies). + * SPDX-FileCopyrightText: 2016 David Edmundson + * SPDX-FileContributor: Marius Vollmer + * + * SPDX-License-Identifier: LGPL-2.1-only + */ + +#include +#include +#include +#include + +#include "gconfitem.h" + +#include +#include +#include + +struct GConfItemPrivate { + QString root; + QVariant value; + guint notify_id; + + static void notify_trampoline(GConfClient *, guint, GConfEntry *, gpointer); +}; + +#define withClient(c) for (GConfClient *c = (gconf_client_get_default()); c; g_object_unref(c), c = NULL) + +static QByteArray convertKey(QString key) +{ + if (key.startsWith('/')) { + return key.toUtf8(); + } else { + qWarning() << "Using dot-separated key names with GConfItem is deprecated."; + qWarning() << "Please use" << '/' + key.replace('.', '/') << "instead of" << key; + return '/' + key.replace('.', '/').toUtf8(); + } +} + +static QString convertKey(const char *key) +{ + return QString::fromUtf8(key); +} + +static QVariant convertValue(GConfValue *src) +{ + if (!src) { + return QVariant(); + } else { + switch (src->type) { + case GCONF_VALUE_INVALID: + return QVariant(QVariant::Invalid); + case GCONF_VALUE_BOOL: + return QVariant((bool)gconf_value_get_bool(src)); + case GCONF_VALUE_INT: + return QVariant(gconf_value_get_int(src)); + case GCONF_VALUE_FLOAT: + return QVariant(gconf_value_get_float(src)); + case GCONF_VALUE_STRING: + return QVariant(QString::fromUtf8(gconf_value_get_string(src))); + case GCONF_VALUE_LIST: + switch (gconf_value_get_list_type(src)) { + case GCONF_VALUE_STRING: { + QStringList result; + for (GSList *elts = gconf_value_get_list(src); elts; elts = elts->next) + result.append(QString::fromUtf8(gconf_value_get_string((GConfValue *)elts->data))); + return QVariant(result); + } + default: { + QList result; + for (GSList *elts = gconf_value_get_list(src); elts; elts = elts->next) + result.append(convertValue((GConfValue *)elts->data)); + return QVariant(result); + } + } + case GCONF_VALUE_SCHEMA: + default: + return QVariant(); + } + } +} + +static GConfValue *convertString(const QString &str) +{ + GConfValue *v = gconf_value_new(GCONF_VALUE_STRING); + gconf_value_set_string(v, str.toUtf8().data()); + return v; +} + +static GConfValueType primitiveType(const QVariant &elt) +{ + switch (elt.type()) { + case QVariant::String: + return GCONF_VALUE_STRING; + case QVariant::Int: + return GCONF_VALUE_INT; + case QVariant::Double: + return GCONF_VALUE_FLOAT; + case QVariant::Bool: + return GCONF_VALUE_BOOL; + default: + return GCONF_VALUE_INVALID; + } +} + +static GConfValueType uniformType(const QList &list) +{ + GConfValueType result = GCONF_VALUE_INVALID; + + Q_FOREACH (const QVariant &elt, list) { + GConfValueType elt_type = primitiveType(elt); + + if (elt_type == GCONF_VALUE_INVALID) + return GCONF_VALUE_INVALID; + + if (result == GCONF_VALUE_INVALID) + result = elt_type; + else if (result != elt_type) + return GCONF_VALUE_INVALID; + } + + if (result == GCONF_VALUE_INVALID) + return GCONF_VALUE_STRING; // empty list. + else + return result; +} + +static int convertValue(const QVariant &src, GConfValue **valp) +{ + GConfValue *v; + + switch (src.type()) { + case QVariant::Invalid: + v = nullptr; + break; + case QVariant::Bool: + v = gconf_value_new(GCONF_VALUE_BOOL); + gconf_value_set_bool(v, src.toBool()); + break; + case QVariant::Int: + v = gconf_value_new(GCONF_VALUE_INT); + gconf_value_set_int(v, src.toInt()); + break; + case QVariant::Double: + v = gconf_value_new(GCONF_VALUE_FLOAT); + gconf_value_set_float(v, src.toDouble()); + break; + case QVariant::String: + v = convertString(src.toString()); + break; + case QVariant::StringList: { + GSList *elts = nullptr; + v = gconf_value_new(GCONF_VALUE_LIST); + gconf_value_set_list_type(v, GCONF_VALUE_STRING); + Q_FOREACH (const QString &str, src.toStringList()) + elts = g_slist_prepend(elts, convertString(str)); + gconf_value_set_list_nocopy(v, g_slist_reverse(elts)); + break; + } + case QVariant::List: { + GConfValueType elt_type = uniformType(src.toList()); + if (elt_type == GCONF_VALUE_INVALID) + v = nullptr; + else { + GSList *elts = nullptr; + v = gconf_value_new(GCONF_VALUE_LIST); + gconf_value_set_list_type(v, elt_type); + Q_FOREACH (const QVariant &elt, src.toList()) { + GConfValue *val = nullptr; + convertValue(elt, &val); // guaranteed to succeed. + elts = g_slist_prepend(elts, val); + } + gconf_value_set_list_nocopy(v, g_slist_reverse(elts)); + } + break; + } + default: + return 0; + } + + *valp = v; + return 1; +} + +void GConfItemPrivate::notify_trampoline(GConfClient *, guint, GConfEntry *entry, gpointer data) +{ + GConfItem *item = (GConfItem *)data; + + item->update_value(true, entry->key, convertValue(entry->value)); +} + +void GConfItem::update_value(bool emit_signal, const QString &key, const QVariant &value) +{ + QVariant new_value; + + if (emit_signal) { + subtreeChanged(key, value); + } +} + +QString GConfItem::root() const +{ + return priv->root; +} + +QVariant GConfItem::value(const QString &subKey) const +{ + QVariant new_value; + withClient(client) + { + GError *error = nullptr; + QByteArray k = convertKey(priv->root + '/' + subKey); + GConfValue *v = gconf_client_get(client, k.data(), &error); + + if (error) { + qWarning() << error->message; + g_error_free(error); + new_value = QVariant(); + } else { + new_value = convertValue(v); + if (v) + gconf_value_free(v); + } + } + return new_value; +} + +void GConfItem::set(const QString &subKey, const QVariant &val) +{ + withClient(client) + { + QByteArray k = convertKey(priv->root + '/' + subKey); + GConfValue *v; + if (convertValue(val, &v)) { + GError *error = nullptr; + + if (v) { + gconf_client_set(client, k.data(), v, &error); + gconf_value_free(v); + } else { + gconf_client_unset(client, k.data(), &error); + } + + if (error) { + qWarning() << error->message; + g_error_free(error); + } + } else { + qWarning() << "Can't store a" << val.typeName(); + } + } +} + +QList GConfItem::listDirs() const +{ + QList children; + + withClient(client) + { + QByteArray k = convertKey(priv->root); + GSList *dirs = gconf_client_all_dirs(client, k.data(), nullptr); + for (GSList *d = dirs; d; d = d->next) { + children.append(convertKey((char *)d->data)); + g_free(d->data); + } + g_slist_free(dirs); + } + + return children; +} + +QList GConfItem::listEntries() const +{ + QList children; + + withClient(client) + { + QByteArray k = convertKey(priv->root); + GSList *entries = gconf_client_all_entries(client, k.data(), nullptr); + for (GSList *e = entries; e; e = e->next) { + children.append(convertKey(((GConfEntry *)e->data)->key)); + gconf_entry_free((GConfEntry *)e->data); + } + g_slist_free(entries); + } + + return children; +} + +GConfItem::GConfItem(const QString &key, QObject *parent) + : QObject(parent) + , priv(new GConfItemPrivate) +{ + priv->root = key; + withClient(client) + { + QByteArray k = convertKey(priv->root); + gconf_client_add_dir(client, k.data(), GCONF_CLIENT_PRELOAD_ONELEVEL, nullptr); + priv->notify_id = gconf_client_notify_add(client, k.data(), GConfItemPrivate::notify_trampoline, this, nullptr, nullptr); + } +} + +GConfItem::~GConfItem() +{ + withClient(client) + { + QByteArray k = convertKey(priv->root); + gconf_client_notify_remove(client, priv->notify_id); + gconf_client_remove_dir(client, k.data(), nullptr); + } + delete priv; +} diff --git a/audio/gconfitem.h b/audio/gconfitem.h new file mode 100644 index 0000000..f2bbd99 --- /dev/null +++ b/audio/gconfitem.h @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: 2009 Nokia Corporation. + * SPDX-FileCopyrightText: 2016 David Edmundson + * + * Contact: Marius Vollmer + * + * SPDX-License-Identifier: LGPL-2.1-only + * + */ + +#ifndef GCONFITEM_H +#define GCONFITEM_H + +#include +#include +#include + +/*! + + \brief GConfItem is a simple C++ wrapper for GConf. + + Creating a GConfItem instance gives you access to a single GConf + key. You can get and set its value, and connect to its + valueChanged() signal to be notified about changes. + + The value of a GConf key is returned to you as a QVariant, and you + pass in a QVariant when setting the value. GConfItem converts + between a QVariant and GConf values as needed, and according to the + following rules: + + - A QVariant of type QVariant::Invalid denotes an unset GConf key. + + - QVariant::Int, QVariant::Double, QVariant::Bool are converted to + and from the obvious equivalents. + + - QVariant::String is converted to/from a GConf string and always + uses the UTF-8 encoding. No other encoding is supported. + + - QVariant::StringList is converted to a list of UTF-8 strings. + + - QVariant::List (which denotes a QList) is converted + to/from a GConf list. All elements of such a list must have the + same type, and that type must be one of QVariant::Int, + QVariant::Double, QVariant::Bool, or QVariant::String. (A list of + strings is returned as a QVariant::StringList, however, when you + get it back.) + + - Any other QVariant or GConf value is essentially ignored. + + - This is fored by Dave from libqtgconf to really reduce the amount of QObjects needed + to manipulate various items in a tree. + + + \warning GConfItem is as thread-safe as GConf. + +*/ + +class GConfItem : public QObject +{ + Q_OBJECT + +public: + /*! Initializes a GConfItem to access the GConf key denoted by + \a key. Key names should follow the normal GConf conventions + like "/myapp/settings/first". + + \param key The name of the key. + \param parent Parent object + */ + explicit GConfItem(const QString &keyRoot, QObject *parent = nullptr); + + /*! Finalizes a GConfItem. + */ + ~GConfItem() override; + + /*! Returns the root of this item, as given to the constructor. + */ + QString root() const; + + /*! Returns the current value of this item, as a QVariant. + * subkey is relative to the provided root. + */ + QVariant value(const QString &subKey) const; + + /*! Returns the current value of this item, as a QVariant. If + * there is no value for this item, return \a def instead. + + */ + void set(const QString &subKey, const QVariant &val); + + /*! Return a list of the directories below this item. The + returned strings are absolute key names like + "/myapp/settings". + + A directory is a key that has children. The same key might + also have a value, but that is confusing and best avoided. + */ + QList listDirs() const; + + /*! Return a list of entries below this item. The returned + strings are absolute key names like "/myapp/settings/first". + + A entry is a key that has a value. The same key might also + have children, but that is confusing and is best avoided. + */ + QList listEntries() const; + +Q_SIGNALS: + /*! Emitted when some value in subtree of this item changes + */ + + void subtreeChanged(const QString &key, const QVariant &value); + +private: + friend struct GConfItemPrivate; + struct GConfItemPrivate *priv; + + void update_value(bool emit_signal, const QString &key, const QVariant &value); +}; + +#endif // GCONFITEM_H diff --git a/audio/gsettingsitem.cpp b/audio/gsettingsitem.cpp new file mode 100644 index 0000000..f42d606 --- /dev/null +++ b/audio/gsettingsitem.cpp @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: 2018 Nicolas Fella + * + * SPDX-License-Identifier: LGPL-2.1-only + * + */ + +#include + +#include "debug.h" +#include "gsettingsitem.h" + +QVariant GSettingsItem::value(const QString &key) const +{ + if (!m_settings) { + return QVariant(); + } + + GVariant *gvalue = g_settings_get_value(m_settings, key.toLatin1().data()); + + QVariant toReturn; + + switch (g_variant_classify(gvalue)) { + case G_VARIANT_CLASS_BOOLEAN: + toReturn = QVariant((bool)g_variant_get_boolean(gvalue)); + break; + case G_VARIANT_CLASS_STRING: + toReturn = QVariant(QString::fromUtf8(g_variant_get_string(gvalue, nullptr))); + break; + default: + qCWarning(PLASMAPA()) << "Unhandled variant type in value()"; + } + + g_variant_unref(gvalue); + + return toReturn; +} + +void GSettingsItem::set(const QString &key, const QVariant &val) +{ + if (!m_settings) { + return; + } + + // It might be hard to detect the right GVariant type from + // complex QVariant types such as string lists or more detailed + // types such as integers (GVariant has different sizes), + // therefore we get the current value for the key and convert + // to QVariant using the GVariant type + GVariant *oldValue = g_settings_get_value(m_settings, key.toLatin1().data()); + GVariant *newValue = nullptr; + + switch (g_variant_type_peek_string(g_variant_get_type(oldValue))[0]) { + case G_VARIANT_CLASS_BOOLEAN: + newValue = g_variant_new_boolean(val.toBool()); + break; + case G_VARIANT_CLASS_STRING: + newValue = g_variant_new_string(val.toString().toUtf8().constData()); + break; + default: + qCWarning(PLASMAPA()) << "Unhandled variant type in set()"; + } + + if (newValue) { + g_settings_set_value(m_settings, key.toLatin1().data(), newValue); + } + + g_variant_unref(oldValue); +} + +bool GSettingsItem::isValid() const +{ + return m_settings; +} + +GSettingsItem::GSettingsItem(const QString &key, QObject *parent) + : QObject(parent) +{ + const char schemaId[] = "org.freedesktop.pulseaudio.module-group"; + + // g_settings_new_with_path asserts if the schema doesn't exist, check this manually to avoid an abort. + auto *defaultSource = g_settings_schema_source_get_default(); + if (!defaultSource) { + qCWarning(PLASMAPA) << "No GSettings schemas are installed on the system"; + return; + } + + auto *schema = g_settings_schema_source_lookup(defaultSource, schemaId, true /*recursive*/); + if (!schema) { + qCWarning(PLASMAPA) << "Settings schema" << schemaId << "is not installed"; + return; + } + + m_settings = g_settings_new_with_path(schemaId, key.toLatin1().data()); + g_settings_schema_unref(schema); + + g_signal_connect(m_settings, "changed", G_CALLBACK(GSettingsItem::settingChanged), this); +} + +GSettingsItem::~GSettingsItem() +{ + g_settings_sync(); + if (m_settings) { + g_object_unref(m_settings); + } +} diff --git a/audio/gsettingsitem.h b/audio/gsettingsitem.h new file mode 100644 index 0000000..cbaeeb7 --- /dev/null +++ b/audio/gsettingsitem.h @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2018 Nicolas Fella + * + * SPDX-License-Identifier: LGPL-2.1-only + * + */ + +#ifndef GSETTINGSITEM_H +#define GSETTINGSITEM_H + +#include +#include +#include + +#include + +class GSettingsItem : public QObject +{ + Q_OBJECT + +public: + explicit GSettingsItem(const QString &key, QObject *parent = nullptr); + virtual ~GSettingsItem() override; + + QVariant value(const QString &key) const; + void set(const QString &key, const QVariant &val); + + bool isValid() const; + +Q_SIGNALS: + void subtreeChanged(); + +private: + GSettings *m_settings = nullptr; + + static void settingChanged(GSettings *settings, const gchar *key, gpointer data) + { + Q_UNUSED(settings) + Q_UNUSED(key) + + GSettingsItem *self = static_cast(data); + Q_EMIT self->subtreeChanged(); + } +}; + +#endif // GCONFITEM_H diff --git a/audio/maps.cpp b/audio/maps.cpp new file mode 100644 index 0000000..186c56a --- /dev/null +++ b/audio/maps.cpp @@ -0,0 +1,7 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "maps.h" diff --git a/audio/maps.h b/audio/maps.h new file mode 100644 index 0000000..88bef5a --- /dev/null +++ b/audio/maps.h @@ -0,0 +1,170 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef MAPS_H +#define MAPS_H + +#include "debug.h" +#include +#include + +#include +#include + +namespace QPulseAudio +{ +// Used for typedefs. +class Card; +class Client; +class Sink; +class SinkInput; +class Source; +class SourceOutput; +class StreamRestore; +class Module; + +/** + * @see MapBase + * This class is nothing more than the QObject base since moc cannot handle + * templates. + */ +class MapBaseQObject : public QObject +{ + Q_OBJECT + +public: + virtual int count() const = 0; + virtual QObject *objectAt(int index) const = 0; + virtual int indexOfObject(QObject *object) const = 0; + +Q_SIGNALS: + void aboutToBeAdded(int index); + void added(int index); + void aboutToBeRemoved(int index); + void removed(int index); +}; + +/** + * Maps a specific index to a specific object pointer. + * This is used to give the unique arbitrary PulseAudio index of a PulseObject a + * serialized list index. Namely it enables us to translate a discrete list + * index to a pulse index to an object, and any permutation thereof. + */ +template +class MapBase : public MapBaseQObject +{ +public: + ~MapBase() override + { + } + + const QMap &data() const + { + return m_data; + } + + int count() const override + { + return m_data.count(); + } + + int indexOfObject(QObject *object) const override + { + int index = 0; + QMapIterator it(m_data); + while (it.hasNext()) { + it.next(); + if (it.value() == object) { + return index; + } + index++; + } + return -1; + } + + QObject *objectAt(int index) const override + { + return (m_data.constBegin() + index).value(); + } + + void reset() + { + while (!m_data.isEmpty()) { + removeEntry(m_data.lastKey()); + } + m_pendingRemovals.clear(); + } + + void insert(Type *object) + { + Q_ASSERT(!m_data.contains(object->index())); + + int modelIndex = 0; + for (auto it = m_data.constBegin(); it != m_data.constEnd(); ++it) { + if (object->index() < it.key()) { + break; + } + modelIndex++; + } + + Q_EMIT aboutToBeAdded(modelIndex); + m_data.insert(object->index(), object); + Q_ASSERT(modelIndex == m_data.keys().indexOf(object->index())); + Q_EMIT added(modelIndex); + } + + // Context is passed in as parent because context needs to include the maps + // so we'd cause a circular dep if we were to try to use the instance here. + // Plus that's weird separation anyway. + void updateEntry(const PAInfo *info, QObject *parent) + { + Q_ASSERT(info); + + if (m_pendingRemovals.remove(info->index)) { + // Was already removed again. + return; + } + + auto *obj = m_data.value(info->index, nullptr); + if (!obj) { + obj = new Type(parent); + } + obj->update(info); + + if (!m_data.contains(info->index)) { + insert(obj); + } + } + + void removeEntry(quint32 index) + { + if (!m_data.contains(index)) { + m_pendingRemovals.insert(index); + } else { + const int modelIndex = m_data.keys().indexOf(index); + Q_EMIT aboutToBeRemoved(modelIndex); + delete m_data.take(index); + Q_EMIT removed(modelIndex); + } + } + +protected: + QMap m_data; + QSet m_pendingRemovals; +}; + +typedef MapBase SinkMap; +typedef MapBase SinkInputMap; +typedef MapBase SourceMap; +typedef MapBase SourceOutputMap; +typedef MapBase ClientMap; +typedef MapBase CardMap; +typedef MapBase ModuleMap; +typedef MapBase StreamRestoreMap; + +} // QPulseAudio + +#endif // MAPS_H diff --git a/audio/model/sortfiltermodel.cpp b/audio/model/sortfiltermodel.cpp new file mode 100644 index 0000000..ebf14fc --- /dev/null +++ b/audio/model/sortfiltermodel.cpp @@ -0,0 +1,208 @@ +#include "sortfiltermodel.h" + +#include +#include + +SortFilterModel::SortFilterModel(QObject *parent) + : QSortFilterProxyModel(parent) +{ + setObjectName(QStringLiteral("SortFilterModel")); + setDynamicSortFilter(true); + connect(this, &QAbstractItemModel::rowsInserted, this, &SortFilterModel::countChanged); + connect(this, &QAbstractItemModel::rowsRemoved, this, &SortFilterModel::countChanged); + connect(this, &QAbstractItemModel::modelReset, this, &SortFilterModel::countChanged); + connect(this, &SortFilterModel::countChanged, this, &SortFilterModel::syncRoleNames); +} + +SortFilterModel::~SortFilterModel() +{ +} + +void SortFilterModel::syncRoleNames() +{ + if (!sourceModel()) { + return; + } + + m_roleIds.clear(); + const QHash rNames = roleNames(); + m_roleIds.reserve(rNames.count()); + for (auto i = rNames.constBegin(); i != rNames.constEnd(); ++i) { + m_roleIds[QString::fromUtf8(i.value())] = i.key(); + } + + setFilterRole(m_filterRole); + setSortRole(m_sortRole); +} + +QHash SortFilterModel::roleNames() const +{ + if (sourceModel()) { + return sourceModel()->roleNames(); + } + return {}; +} + +int SortFilterModel::roleNameToId(const QString &name) const +{ + return m_roleIds.value(name, Qt::DisplayRole); +} + +void SortFilterModel::setModel(QAbstractItemModel *model) +{ + if (model == sourceModel()) { + return; + } + + if (sourceModel()) { + disconnect(sourceModel(), &QAbstractItemModel::modelReset, this, &SortFilterModel::syncRoleNames); + } + + QSortFilterProxyModel::setSourceModel(model); + + if (model) { + connect(model, &QAbstractItemModel::modelReset, this, &SortFilterModel::syncRoleNames); + syncRoleNames(); + } + + Q_EMIT sourceModelChanged(model); +} + +bool SortFilterModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const +{ + if (m_filterCallback.isCallable()) { + QJSValueList args; + args << QJSValue(source_row); + + const QModelIndex idx = sourceModel()->index(source_row, filterKeyColumn(), source_parent); + QQmlEngine *engine = QQmlEngine::contextForObject(this)->engine(); + args << engine->toScriptValue(idx.data(m_roleIds.value(m_filterRole))); + + return const_cast(this)->m_filterCallback.call(args).toBool(); + } + + return QSortFilterProxyModel::filterAcceptsRow(source_row, source_parent); +} + +void SortFilterModel::setFilterRegExp(const QString &exp) +{ + if (exp == filterRegExp()) { + return; + } + QSortFilterProxyModel::setFilterRegExp(QRegExp(exp, Qt::CaseInsensitive)); + Q_EMIT filterRegExpChanged(exp); +} + +QString SortFilterModel::filterRegExp() const +{ + return QSortFilterProxyModel::filterRegExp().pattern(); +} + +void SortFilterModel::setFilterString(const QString &filterString) +{ + if (filterString == m_filterString) { + return; + } + m_filterString = filterString; + QSortFilterProxyModel::setFilterFixedString(filterString); + Q_EMIT filterStringChanged(filterString); +} + +QString SortFilterModel::filterString() const +{ + return m_filterString; +} + +QJSValue SortFilterModel::filterCallback() const +{ + return m_filterCallback; +} + +void SortFilterModel::setFilterCallback(const QJSValue &callback) +{ + if (m_filterCallback.strictlyEquals(callback)) { + return; + } + + if (!callback.isNull() && !callback.isCallable()) { + return; + } + + m_filterCallback = callback; + invalidateFilter(); + + Q_EMIT filterCallbackChanged(callback); +} + +void SortFilterModel::setFilterRole(const QString &role) +{ + QSortFilterProxyModel::setFilterRole(roleNameToId(role)); + m_filterRole = role; +} + +QString SortFilterModel::filterRole() const +{ + return m_filterRole; +} + +void SortFilterModel::setSortRole(const QString &role) +{ + m_sortRole = role; + if (role.isEmpty()) { + sort(-1, Qt::AscendingOrder); + } else if (sourceModel()) { + QSortFilterProxyModel::setSortRole(roleNameToId(role)); + sort(sortColumn(), sortOrder()); + } +} + +QString SortFilterModel::sortRole() const +{ + return m_sortRole; +} + +void SortFilterModel::setSortOrder(const Qt::SortOrder order) +{ + if (order == sortOrder()) { + return; + } + sort(sortColumn(), order); +} + +void SortFilterModel::setSortColumn(int column) +{ + if (column == sortColumn()) { + return; + } + sort(column, sortOrder()); + Q_EMIT sortColumnChanged(); +} + +QVariantMap SortFilterModel::get(int row) const +{ + QModelIndex idx = index(row, 0); + QVariantMap hash; + + const QHash rNames = roleNames(); + for (auto i = rNames.begin(); i != rNames.end(); ++i) { + hash[QString::fromUtf8(i.value())] = data(idx, i.key()); + } + + return hash; +} + +int SortFilterModel::mapRowToSource(int row) const +{ + QModelIndex idx = index(row, 0); + return mapToSource(idx).row(); +} + +int SortFilterModel::mapRowFromSource(int row) const +{ + if (!sourceModel()) { + qWarning() << "No source model defined!"; + return -1; + } + QModelIndex idx = sourceModel()->index(row, 0); + return mapFromSource(idx).row(); +} \ No newline at end of file diff --git a/audio/model/sortfiltermodel.h b/audio/model/sortfiltermodel.h new file mode 100644 index 0000000..5da4ab7 --- /dev/null +++ b/audio/model/sortfiltermodel.h @@ -0,0 +1,132 @@ +#ifndef DATAMODEL_H +#define DATAMODEL_H + +#include +#include +#include +#include +#include + +class SortFilterModel : public QSortFilterProxyModel +{ + Q_OBJECT + /** + * The source model of this sorting proxy model. It has to inherit QAbstractItemModel (ListModel is not supported) + */ + Q_PROPERTY(QAbstractItemModel *sourceModel READ sourceModel WRITE setModel NOTIFY sourceModelChanged) + + /** + * The regular expression for the filter, only items with their filterRole matching filterRegExp will be displayed + */ + Q_PROPERTY(QString filterRegExp READ filterRegExp WRITE setFilterRegExp NOTIFY filterRegExpChanged) + + /** + * The string for the filter, only items with their filterRole matching filterString will be displayed + */ + Q_PROPERTY(QString filterString READ filterString WRITE setFilterString NOTIFY filterStringChanged REVISION 1) + + /** + * A JavaScript callable that is passed the source model row index as first argument and the value + * of filterRole as second argument. The callable's return value is evaluated as boolean to determine + * whether the row is accepted (true) or filtered out (false). It overrides the default implementation + * that uses filterRegExp or filterString; while filterCallable is set those two properties are + * ignored. Attempts to write a non-callable to this property are silently ignored, but you can set + * it to null. + */ + Q_PROPERTY(QJSValue filterCallback READ filterCallback WRITE setFilterCallback NOTIFY filterCallbackChanged REVISION 1) + + /** + * The role of the sourceModel on which filterRegExp must be applied. + */ + Q_PROPERTY(QString filterRole READ filterRole WRITE setFilterRole) + + /** + * The role of the sourceModel that will be used for sorting. if empty the order will be left unaltered + */ + Q_PROPERTY(QString sortRole READ sortRole WRITE setSortRole) + + /** + * One of Qt.Ascending or Qt.Descending + */ + Q_PROPERTY(Qt::SortOrder sortOrder READ sortOrder WRITE setSortOrder) + + /** + * Specify which column should be used for sorting + */ + Q_PROPERTY(int sortColumn READ sortColumn WRITE setSortColumn NOTIFY sortColumnChanged) + + /** + * How many items are in this model + */ + Q_PROPERTY(int count READ count NOTIFY countChanged) + + friend class DataModel; + +public: + explicit SortFilterModel(QObject *parent = nullptr); + ~SortFilterModel() override; + + void setModel(QAbstractItemModel *source); + + void setFilterRegExp(const QString &exp); + QString filterRegExp() const; + + void setFilterString(const QString &filterString); + QString filterString() const; + + void setFilterCallback(const QJSValue &callback); + QJSValue filterCallback() const; + + void setFilterRole(const QString &role); + QString filterRole() const; + + void setSortRole(const QString &role); + QString sortRole() const; + + void setSortOrder(const Qt::SortOrder order); + + void setSortColumn(int column); + + int count() const + { + return QSortFilterProxyModel::rowCount(); + } + + /** + * Returns the item at index in the list model. + * This allows the item data to be accessed (but not modified) from JavaScript. + * It returns an Object with a property for each role. + * + * @param i the row we want + */ + Q_INVOKABLE QVariantMap get(int i) const; + + Q_INVOKABLE int mapRowToSource(int i) const; + + Q_INVOKABLE int mapRowFromSource(int i) const; + +Q_SIGNALS: + void countChanged(); + void sortColumnChanged(); + void sourceModelChanged(QObject *); + void filterRegExpChanged(const QString &); + Q_REVISION(1) void filterStringChanged(const QString &); + Q_REVISION(1) void filterCallbackChanged(const QJSValue &); + +protected: + int roleNameToId(const QString &name) const; + bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; + QHash roleNames() const override; + +protected Q_SLOTS: + void syncRoleNames(); + +private: + QString m_filterRole; + QString m_sortRole; + QString m_filterString; + QJSValue m_filterCallback; + QHash m_roleIds; +}; + +#endif \ No newline at end of file diff --git a/audio/module.cpp b/audio/module.cpp new file mode 100644 index 0000000..8fc88cc --- /dev/null +++ b/audio/module.cpp @@ -0,0 +1,46 @@ +/* + SPDX-FileCopyrightText: 2017 David Rosca + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "module.h" + +#include "debug.h" + +#include "context.h" + +namespace QPulseAudio +{ +Module::Module(QObject *parent) + : PulseObject(parent) +{ +} + +void Module::update(const pa_module_info *info) +{ + updatePulseObject(info); + + const QString infoName = QString::fromUtf8(info->name); + if (m_name != infoName) { + m_name = infoName; + Q_EMIT nameChanged(); + } + const QString infoArgument = QString::fromUtf8(info->argument); + if (m_argument != infoArgument) { + m_argument = infoArgument; + Q_EMIT argumentChanged(); + } +} + +QString Module::name() const +{ + return m_name; +} + +QString Module::argument() const +{ + return m_argument; +} + +} // QPulseAudio diff --git a/audio/module.h b/audio/module.h new file mode 100644 index 0000000..07ea25c --- /dev/null +++ b/audio/module.h @@ -0,0 +1,44 @@ +/* + SPDX-FileCopyrightText: 2017 David Rosca + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef MODULE_H +#define MODULE_H + +#include + +#include +#include + +#include "pulseobject.h" + +namespace QPulseAudio +{ +class Module : public PulseObject +{ + Q_OBJECT + Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(QString argument READ argument NOTIFY argumentChanged) + +public: + explicit Module(QObject *parent); + + void update(const pa_module_info *info); + + QString name() const; + QString argument() const; + +Q_SIGNALS: + void nameChanged(); + void argumentChanged(); + +private: + QString m_name; + QString m_argument; +}; + +} // QPulseAudio + +#endif // MODULE_H diff --git a/audio/modulemanager.cpp b/audio/modulemanager.cpp new file mode 100644 index 0000000..3709b2e --- /dev/null +++ b/audio/modulemanager.cpp @@ -0,0 +1,192 @@ +/* + SPDX-FileCopyrightText: 2016 David Edmundson + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "modulemanager.h" +#include "config.h" +#include "module.h" +#include "server.h" + +#if USE_GSETTINGS +#include "gsettingsitem.h" + +#define PA_SETTINGS_PATH_MODULES "/org/freedesktop/pulseaudio/module-groups" +#endif + +#if USE_GCONF +#include "gconfitem.h" +#define PA_SETTINGS_PATH_MODULES "/system/pulseaudio/modules" +#endif + +#include + +namespace QPulseAudio +{ +#if USE_GCONF || USE_GSETTINGS + +#if USE_GSETTINGS +class ConfigModule : public GSettingsItem +#elif USE_GCONF +class ConfigModule : public GConfItem +#endif +{ +public: + ConfigModule(const QString &configName, const QString &moduleName, QObject *parent); + bool isEnabled() const; + void setEnabled(bool enabled, const QVariant &args = QVariant()); + +private: + QString m_moduleName; +}; + +ConfigModule::ConfigModule(const QString &configName, const QString &moduleName, QObject *parent) + : +#if USE_GSETTINGS + GSettingsItem(QStringLiteral(PA_SETTINGS_PATH_MODULES "/") + configName + QStringLiteral("/"), parent) + , +#elif USE_GCONF + GConfItem(QStringLiteral(PA_SETTINGS_PATH_MODULES "/") + configName, parent) + , +#endif + m_moduleName(moduleName) +{ +} + +bool ConfigModule::isEnabled() const +{ + return value(QStringLiteral("enabled")).toBool(); +} + +void ConfigModule::setEnabled(bool enabled, const QVariant &args) +{ + set(QStringLiteral("locked"), true); + + if (enabled) { + set(QStringLiteral("name0"), m_moduleName); + set(QStringLiteral("args0"), args); + set(QStringLiteral("enabled"), true); + } else { + set(QStringLiteral("enabled"), false); + } + set(QStringLiteral("locked"), false); +} + +#endif + +ModuleManager::ModuleManager(QObject *parent) + : QObject(parent) +{ +#if USE_GCONF || USE_GSETTINGS + m_combineSinks = new ConfigModule(QStringLiteral("combine"), QStringLiteral("module-combine"), this); + m_switchOnConnect = new ConfigModule(QStringLiteral("switch-on-connect"), QStringLiteral("module-switch-on-connect"), this); + m_deviceManager = new ConfigModule(QStringLiteral("device-manager"), QStringLiteral("module-device-manager"), this); + + connect(m_combineSinks, &ConfigModule::subtreeChanged, this, &ModuleManager::combineSinksChanged); + connect(m_switchOnConnect, &ConfigModule::subtreeChanged, this, &ModuleManager::switchOnConnectChanged); + connect(m_deviceManager, &ConfigModule::subtreeChanged, this, &ModuleManager::switchOnConnectChanged); +#endif + + connect(Context::instance()->server(), &Server::updated, this, &ModuleManager::serverUpdated); + + QTimer *updateModulesTimer = new QTimer(this); + updateModulesTimer->setInterval(500); + updateModulesTimer->setSingleShot(true); + connect(updateModulesTimer, &QTimer::timeout, this, &ModuleManager::updateLoadedModules); + connect(&Context::instance()->modules(), &MapBaseQObject::added, updateModulesTimer, static_cast(&QTimer::start)); + connect(&Context::instance()->modules(), &MapBaseQObject::removed, updateModulesTimer, static_cast(&QTimer::start)); + updateLoadedModules(); +} + +ModuleManager::~ModuleManager(){}; + +bool ModuleManager::settingsSupported() const +{ + // PipeWire does not (yet) have support for module-switch-on-connect and module-combine-sink + // Also switching streams is the default there + // TODO Check whether there is a PipeWire-specific way to do these + if (Context::instance()->server()->isPipeWire()) { + return false; + } + +#if USE_GCONF || USE_GSETTINGS + return true; +#else + return false; +#endif +} + +bool ModuleManager::combineSinks() const +{ +#if USE_GCONF || USE_GSETTINGS + return m_combineSinks->isEnabled(); +#else + return false; +#endif +} + +void ModuleManager::setCombineSinks(bool combineSinks) +{ +#if USE_GCONF || USE_GSETTINGS + m_combineSinks->setEnabled(combineSinks); +#else + Q_UNUSED(combineSinks) +#endif +} + +bool ModuleManager::switchOnConnect() const +{ +#if USE_GCONF || USE_GSETTINGS + // switch on connect and device-manager do the same task. Only one should be enabled + + // Note on the first run m_deviceManager will appear to be disabled even though it's actually running + // because there is no gconf entry, however m_switchOnConnect will only exist if set by Plasma PA + // hence only check this entry + return m_switchOnConnect->isEnabled(); +#else + return false; +#endif +} + +void ModuleManager::setSwitchOnConnect(bool switchOnConnect) +{ +#if USE_GCONF || USE_GSETTINGS + m_deviceManager->setEnabled(!switchOnConnect); + m_switchOnConnect->setEnabled(switchOnConnect); +#else + Q_UNUSED(switchOnConnect) +#endif +} + +QStringList ModuleManager::loadedModules() const +{ + return m_loadedModules; +} + +void ModuleManager::updateLoadedModules() +{ + m_loadedModules.clear(); + const auto modules = Context::instance()->modules().data(); + for (Module *module : modules) { + m_loadedModules.append(module->name()); + } + Q_EMIT loadedModulesChanged(); +} + +bool ModuleManager::configModuleLoaded() const +{ + return m_loadedModules.contains(configModuleName()); +} + +QString ModuleManager::configModuleName() const +{ +#if USE_GCONF + return QStringLiteral("module-gconf"); +#elif USE_GSETTINGS + return QStringLiteral("module-gsettings"); +#else + return QString(); +#endif +} +} diff --git a/audio/modulemanager.h b/audio/modulemanager.h new file mode 100644 index 0000000..3dc9c68 --- /dev/null +++ b/audio/modulemanager.h @@ -0,0 +1,61 @@ +/* + SPDX-FileCopyrightText: 2016 David Edmundson + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef MODULEMANAGER_H +#define MODULEMANAGER_H + +#include + +#include + +#include "context.h" +// Properties need fully qualified classes even with pointers. +#include "client.h" + +namespace QPulseAudio +{ +class ConfigModule; + +class ModuleManager : public QObject +{ + Q_OBJECT + Q_PROPERTY(bool settingsSupported READ settingsSupported NOTIFY serverUpdated) + Q_PROPERTY(bool combineSinks READ combineSinks WRITE setCombineSinks NOTIFY combineSinksChanged) + Q_PROPERTY(bool switchOnConnect READ switchOnConnect WRITE setSwitchOnConnect NOTIFY switchOnConnectChanged) + Q_PROPERTY(bool configModuleLoaded READ configModuleLoaded NOTIFY loadedModulesChanged) + Q_PROPERTY(QString configModuleName READ configModuleName CONSTANT) + Q_PROPERTY(QStringList loadedModules READ loadedModules NOTIFY loadedModulesChanged) +public: + explicit ModuleManager(QObject *parent = nullptr); + ~ModuleManager() override; + + bool settingsSupported() const; + bool combineSinks() const; + void setCombineSinks(bool combineSinks); + bool switchOnConnect() const; + void setSwitchOnConnect(bool switchOnConnect); + QStringList loadedModules() const; + bool configModuleLoaded() const; + QString configModuleName() const; + +Q_SIGNALS: + void combineSinksChanged(); + void switchOnConnectChanged(); + void loadedModulesChanged(); + void serverUpdated(); + +private: + void updateLoadedModules(); + + ConfigModule *m_combineSinks; + ConfigModule *m_switchOnConnect; + ConfigModule *m_deviceManager; + QStringList m_loadedModules; +}; + +} // QPulseAudio + +#endif // STREAM_H diff --git a/audio/operation.cpp b/audio/operation.cpp new file mode 100644 index 0000000..f6fa3ff --- /dev/null +++ b/audio/operation.cpp @@ -0,0 +1,44 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "operation.h" + +namespace QPulseAudio +{ +PAOperation::PAOperation(pa_operation *operation) + : m_operation(operation) +{ +} + +PAOperation::~PAOperation() +{ + if (m_operation) { + pa_operation_unref(m_operation); + } +} + +PAOperation &PAOperation::operator=(pa_operation *operation) +{ + m_operation = operation; + return *this; +} + +bool PAOperation::operator!() +{ + return !m_operation; +} + +pa_operation *&PAOperation::operator*() +{ + return m_operation; +} + +PAOperation::operator bool() +{ + return m_operation; +} + +} // QPulseAudio diff --git a/audio/operation.h b/audio/operation.h new file mode 100644 index 0000000..0863e4a --- /dev/null +++ b/audio/operation.h @@ -0,0 +1,56 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef OPERATION_H +#define OPERATION_H + +#include + +namespace QPulseAudio +{ +/** + * @brief The PAOperation class + * Helps with management of pa_operations. pa_operations need to be expicitly + * unref'd after use, so this class is essentially a fancy scoping helper where + * destruction of an instance would also unref the held operation (if there is + * one). + */ +class PAOperation +{ +public: + /** + * @brief PAOperation + * @param operation operation to manage the scope of + */ + explicit PAOperation(pa_operation *operation = nullptr); + ~PAOperation(); + + PAOperation &operator=(pa_operation *operation); + + /** + * @brief operator ! + * @return whether or not there is an operation pointer + */ + bool operator!(); + + /** + * @brief operator bool representing whether there is an operation + */ + operator bool(); + + /** + * @brief operator * + * @return pointer to internal pa_operation object + */ + pa_operation *&operator*(); + +private: + pa_operation *m_operation; +}; + +} // QPulseAudio + +#endif // OPERATION_H diff --git a/audio/port.cpp b/audio/port.cpp new file mode 100644 index 0000000..8228df0 --- /dev/null +++ b/audio/port.cpp @@ -0,0 +1,20 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "port.h" + +namespace QPulseAudio +{ +Port::Port(QObject *parent) + : Profile(parent) +{ +} + +Port::~Port() +{ +} + +} // QPulseAudio diff --git a/audio/port.h b/audio/port.h new file mode 100644 index 0000000..e9115eb --- /dev/null +++ b/audio/port.h @@ -0,0 +1,44 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef PORT_H +#define PORT_H + +#include "profile.h" + +#include + +namespace QPulseAudio +{ +class Port : public Profile +{ + Q_OBJECT + +public: + explicit Port(QObject *parent); + ~Port() override; + + template + bool setInfo(const PAInfo *info) + { + Availability newAvailability; + switch (info->available) { + case PA_PORT_AVAILABLE_NO: + newAvailability = Unavailable; + break; + case PA_PORT_AVAILABLE_YES: + newAvailability = Available; + break; + default: + newAvailability = Unknown; + } + return setCommonInfo(info, newAvailability); + } +}; + +} // QPulseAudio + +#endif // PORT_H diff --git a/audio/profile.cpp b/audio/profile.cpp new file mode 100644 index 0000000..76a0ec7 --- /dev/null +++ b/audio/profile.cpp @@ -0,0 +1,44 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "profile.h" + +namespace QPulseAudio +{ +Profile::Profile(QObject *parent) + : QObject(parent) + , m_name() + , m_description() + , m_priority(0) + , m_availability(Unknown) +{ +} + +Profile::~Profile() +{ +} + +QString Profile::name() const +{ + return m_name; +} + +QString Profile::description() const +{ + return m_description; +} + +quint32 Profile::priority() const +{ + return m_priority; +} + +Profile::Availability Profile::availability() const +{ + return m_availability; +} + +} // QPulseAudio diff --git a/audio/profile.h b/audio/profile.h new file mode 100644 index 0000000..7da1231 --- /dev/null +++ b/audio/profile.h @@ -0,0 +1,96 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef PROFILE_H +#define PROFILE_H + +#include +#include + +namespace QPulseAudio +{ +class Profile : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(QString description READ description NOTIFY descriptionChanged) + Q_PROPERTY(quint32 priority READ priority NOTIFY priorityChanged) + Q_PROPERTY(Availability availability READ availability NOTIFY availabilityChanged) +public: + enum Availability { + Unknown, + Available, + Unavailable, + }; + Q_ENUM(Availability) + + explicit Profile(QObject *parent); + ~Profile() override; + + template + bool setInfo(const PAInfo *info) + { + return setCommonInfo(info, info->available ? Available : Unavailable); + } + + QString name() const; + QString description() const; + quint32 priority() const; + Availability availability() const; + +Q_SIGNALS: + void nameChanged(); + void descriptionChanged(); + void priorityChanged(); + void availabilityChanged(); + +protected: + template + bool setCommonInfo(const PAInfo *info, Availability newAvailability) + { + bool changed = false; + + // Description is optional. Name not so much as we need some ID. + Q_ASSERT(info->name); + QString infoName = QString::fromUtf8(info->name); + if (m_name != infoName) { + m_name = infoName; + Q_EMIT nameChanged(); + changed = true; + } + if (info->description) { + QString infoDescription = QString::fromUtf8(info->description); + if (m_description != infoDescription) { + m_description = infoDescription; + Q_EMIT descriptionChanged(); + changed = true; + } + } + if (m_priority != info->priority) { + m_priority = info->priority; + Q_EMIT priorityChanged(); + changed = true; + } + + if (m_availability != newAvailability) { + m_availability = newAvailability; + Q_EMIT availabilityChanged(); + changed = true; + } + + return changed; + } + +private: + QString m_name; + QString m_description; + quint32 m_priority; + Availability m_availability; +}; + +} // QPulseAudio + +#endif // PROFILE_H diff --git a/audio/pulseaudio.cpp b/audio/pulseaudio.cpp new file mode 100644 index 0000000..ebbe0c5 --- /dev/null +++ b/audio/pulseaudio.cpp @@ -0,0 +1,372 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + SPDX-FileCopyrightText: 2016 David Rosca + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "pulseaudio.h" + +#include "card.h" +#include "debug.h" +#include "module.h" +#include "server.h" +#include "sink.h" +#include "sinkinput.h" +#include "source.h" +#include "sourceoutput.h" +#include "streamrestore.h" + +#include + +namespace QPulseAudio +{ +AbstractModel::AbstractModel(const MapBaseQObject *map, QObject *parent) + : QAbstractListModel(parent) + , m_map(map) +{ + Context::instance()->ref(); + + connect(m_map, &MapBaseQObject::aboutToBeAdded, this, [this](int index) { + beginInsertRows(QModelIndex(), index, index); + }); + connect(m_map, &MapBaseQObject::added, this, [this](int index) { + onDataAdded(index); + endInsertRows(); + Q_EMIT countChanged(); + }); + connect(m_map, &MapBaseQObject::aboutToBeRemoved, this, [this](int index) { + beginRemoveRows(QModelIndex(), index, index); + }); + connect(m_map, &MapBaseQObject::removed, this, [this](int index) { + Q_UNUSED(index); + endRemoveRows(); + Q_EMIT countChanged(); + }); +} + +AbstractModel::~AbstractModel() +{ + // deref context after we've deleted this object + // see https://bugs.kde.org/show_bug.cgi?id=371215 + Context::instance()->unref(); +} + +QHash AbstractModel::roleNames() const +{ + if (!m_roles.empty()) { + qCDebug(PLASMAPA) << "returning roles" << m_roles; + return m_roles; + } + Q_UNREACHABLE(); + return QHash(); +} + +int AbstractModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + return m_map->count(); +} + +QVariant AbstractModel::data(const QModelIndex &index, int role) const +{ + if (!hasIndex(index.row(), index.column())) { + return QVariant(); + } + QObject *data = m_map->objectAt(index.row()); + Q_ASSERT(data); + if (role == PulseObjectRole) { + return QVariant::fromValue(data); + } else if (role == Qt::DisplayRole) { + return static_cast(data)->properties().value(QStringLiteral("name")).toString(); + } + int property = m_objectProperties.value(role, -1); + if (property == -1) { + return QVariant(); + } + return data->metaObject()->property(property).read(data); +} + +bool AbstractModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + if (!hasIndex(index.row(), index.column())) { + return false; + } + int propertyIndex = m_objectProperties.value(role, -1); + if (propertyIndex == -1) { + return false; + } + QObject *data = m_map->objectAt(index.row()); + auto property = data->metaObject()->property(propertyIndex); + return property.write(data, value); +} + +int AbstractModel::role(const QByteArray &roleName) const +{ + qCDebug(PLASMAPA) << roleName << m_roles.key(roleName, -1); + return m_roles.key(roleName, -1); +} + +Context *AbstractModel::context() const +{ + return Context::instance(); +} + +void AbstractModel::initRoleNames(const QMetaObject &qobjectMetaObject) +{ + m_roles[PulseObjectRole] = QByteArrayLiteral("PulseObject"); + + QMetaEnum enumerator; + for (int i = 0; i < metaObject()->enumeratorCount(); ++i) { + if (metaObject()->enumerator(i).name() == QLatin1String("ItemRole")) { + enumerator = metaObject()->enumerator(i); + break; + } + } + + for (int i = 0; i < enumerator.keyCount(); ++i) { + // Clip the Role suffix and glue it in the hash. + const int roleLength = 4; + QByteArray key(enumerator.key(i)); + // Enum values must end in Role or the enum is crap + Q_ASSERT(key.right(roleLength) == QByteArrayLiteral("Role")); + key.chop(roleLength); + m_roles[enumerator.value(i)] = key; + } + + int maxEnumValue = -1; + for (auto it = m_roles.constBegin(); it != m_roles.constEnd(); ++it) { + if (it.key() > maxEnumValue) { + maxEnumValue = it.key(); + } + } + Q_ASSERT(maxEnumValue != -1); + auto mo = qobjectMetaObject; + for (int i = 0; i < mo.propertyCount(); ++i) { + QMetaProperty property = mo.property(i); + QString name(property.name()); + name.replace(0, 1, name.at(0).toUpper()); + m_roles[++maxEnumValue] = name.toLatin1(); + m_objectProperties.insert(maxEnumValue, i); + if (!property.hasNotifySignal()) { + continue; + } + m_signalIndexToProperties.insert(property.notifySignalIndex(), i); + } + qCDebug(PLASMAPA) << m_roles; + + // Connect to property changes also with objects already in model + for (int i = 0; i < m_map->count(); ++i) { + onDataAdded(i); + } +} + +void AbstractModel::propertyChanged() +{ + if (!sender() || senderSignalIndex() == -1) { + return; + } + int propertyIndex = m_signalIndexToProperties.value(senderSignalIndex(), -1); + if (propertyIndex == -1) { + return; + } + int role = m_objectProperties.key(propertyIndex, -1); + if (role == -1) { + return; + } + int index = m_map->indexOfObject(sender()); + qCDebug(PLASMAPA) << "PROPERTY CHANGED (" << index << ") :: " << role << roleNames().value(role); + Q_EMIT dataChanged(createIndex(index, 0), createIndex(index, 0), {role}); +} + +void AbstractModel::onDataAdded(int index) +{ + QObject *data = m_map->objectAt(index); + const QMetaObject *mo = data->metaObject(); + // We have all the data changed notify signals already stored + auto keys = m_signalIndexToProperties.keys(); + Q_FOREACH (int index, keys) { + QMetaMethod meth = mo->method(index); + connect(data, meth, this, propertyChangedMetaMethod()); + } +} + +QMetaMethod AbstractModel::propertyChangedMetaMethod() const +{ + auto mo = metaObject(); + int methodIndex = mo->indexOfMethod("propertyChanged()"); + if (methodIndex == -1) { + return QMetaMethod(); + } + return mo->method(methodIndex); +} + +SinkModel::SinkModel(QObject *parent) + : AbstractModel(&context()->sinks(), parent) + , m_preferredSink(nullptr) +{ + initRoleNames(Sink::staticMetaObject); + + for (int i = 0; i < context()->sinks().count(); ++i) { + sinkAdded(i); + } + + connect(&context()->sinks(), &MapBaseQObject::added, this, &SinkModel::sinkAdded); + connect(&context()->sinks(), &MapBaseQObject::removed, this, &SinkModel::sinkRemoved); + + connect(context()->server(), &Server::defaultSinkChanged, this, [this]() { + updatePreferredSink(); + Q_EMIT defaultSinkChanged(); + }); +} + +Sink *SinkModel::defaultSink() const +{ + return context()->server()->defaultSink(); +} + +Sink *SinkModel::preferredSink() const +{ + return m_preferredSink; +} + +QVariant SinkModel::data(const QModelIndex &index, int role) const +{ + if (role == SortByDefaultRole) { + // Workaround QTBUG-1548 + const QString pulseIndex = data(index, AbstractModel::role(QByteArrayLiteral("Index"))).toString(); + const QString defaultDevice = data(index, AbstractModel::role(QByteArrayLiteral("Default"))).toString(); + return defaultDevice + pulseIndex; + } + return AbstractModel::data(index, role); +} + +void SinkModel::sinkAdded(int index) +{ + Q_ASSERT(qobject_cast(context()->sinks().objectAt(index))); + Sink *sink = static_cast(context()->sinks().objectAt(index)); + connect(sink, &Sink::stateChanged, this, &SinkModel::updatePreferredSink); + + updatePreferredSink(); +} + +void SinkModel::sinkRemoved(int index) +{ + Q_UNUSED(index); + + updatePreferredSink(); +} + +void SinkModel::updatePreferredSink() +{ + Sink *sink = findPreferredSink(); + + if (sink != m_preferredSink) { + qCDebug(PLASMAPA) << "Changing preferred sink to" << sink << (sink ? sink->name() : ""); + m_preferredSink = sink; + Q_EMIT preferredSinkChanged(); + } +} + +Sink *SinkModel::findPreferredSink() const +{ + const auto &sinks = context()->sinks(); + + // Only one sink is the preferred one + if (sinks.count() == 1) { + return static_cast(sinks.objectAt(0)); + } + + auto lookForState = [this](Device::State state) { + Sink *ret = nullptr; + QMapIterator it(context()->sinks().data()); + while (it.hasNext()) { + it.next(); + if ((it.value()->isVirtualDevice() && !it.value()->isDefault()) || it.value()->state() != state) { + continue; + } + if (!ret) { + ret = it.value(); + } else if (it.value() == defaultSink()) { + ret = it.value(); + break; + } + } + return ret; + }; + + Sink *preferred = nullptr; + + // Look for playing sinks + prefer default sink + preferred = lookForState(Device::RunningState); + if (preferred) { + return preferred; + } + + // Look for idle sinks + prefer default sink + preferred = lookForState(Device::IdleState); + if (preferred) { + return preferred; + } + + // Fallback to default sink + return defaultSink(); +} + +SourceModel::SourceModel(QObject *parent) + : AbstractModel(&context()->sources(), parent) +{ + initRoleNames(Source::staticMetaObject); + + connect(context()->server(), &Server::defaultSourceChanged, this, &SourceModel::defaultSourceChanged); +} + +Source *SourceModel::defaultSource() const +{ + return context()->server()->defaultSource(); +} + +QVariant SourceModel::data(const QModelIndex &index, int role) const +{ + if (role == SortByDefaultRole) { + // Workaround QTBUG-1548 + const QString pulseIndex = data(index, AbstractModel::role(QByteArrayLiteral("Index"))).toString(); + const QString defaultDevice = data(index, AbstractModel::role(QByteArrayLiteral("Default"))).toString(); + return defaultDevice + pulseIndex; + } + return AbstractModel::data(index, role); +} + +SinkInputModel::SinkInputModel(QObject *parent) + : AbstractModel(&context()->sinkInputs(), parent) +{ + initRoleNames(SinkInput::staticMetaObject); +} + +SourceOutputModel::SourceOutputModel(QObject *parent) + : AbstractModel(&context()->sourceOutputs(), parent) +{ + initRoleNames(SourceOutput::staticMetaObject); +} + +CardModel::CardModel(QObject *parent) + : AbstractModel(&context()->cards(), parent) +{ + initRoleNames(Card::staticMetaObject); +} + +StreamRestoreModel::StreamRestoreModel(QObject *parent) + : AbstractModel(&context()->streamRestores(), parent) +{ + initRoleNames(StreamRestore::staticMetaObject); +} + +ModuleModel::ModuleModel(QObject *parent) + : AbstractModel(&context()->modules(), parent) +{ + initRoleNames(Module::staticMetaObject); +} + +} // QPulseAudio diff --git a/audio/pulseaudio.h b/audio/pulseaudio.h new file mode 100644 index 0000000..1322f7b --- /dev/null +++ b/audio/pulseaudio.h @@ -0,0 +1,151 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + SPDX-FileCopyrightText: 2016 David Rosca + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef PULSEAUDIO_H +#define PULSEAUDIO_H + +#include + +#include "maps.h" + +namespace QPulseAudio +{ +class Context; + +class AbstractModel : public QAbstractListModel +{ + Q_OBJECT +public: + enum ItemRole { + PulseObjectRole = Qt::UserRole + 1, + }; + Q_PROPERTY(int count READ rowCount NOTIFY countChanged) + + Q_ENUM(ItemRole) + + ~AbstractModel() override; + QHash roleNames() const final; + int rowCount(const QModelIndex &parent = QModelIndex()) const final; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex &index, const QVariant &value, int role) final; + + Q_INVOKABLE int role(const QByteArray &roleName) const; + +Q_SIGNALS: + void countChanged(); + +protected: + AbstractModel(const MapBaseQObject *map, QObject *parent); + void initRoleNames(const QMetaObject &qobjectMetaObject); + Context *context() const; + +private Q_SLOTS: + void propertyChanged(); + +private: + void onDataAdded(int index); + void onDataRemoved(int index); + QMetaMethod propertyChangedMetaMethod() const; + + const MapBaseQObject *m_map; + QHash m_roles; + QHash m_objectProperties; + QHash m_signalIndexToProperties; + +private: + // Prevent leaf-classes from default constructing as we want to enforce + // them passing us a context or explicit nullptrs. + AbstractModel() + { + } +}; + +class CardModel : public AbstractModel +{ + Q_OBJECT +public: + explicit CardModel(QObject *parent = nullptr); +}; + +class SinkModel : public AbstractModel +{ + Q_OBJECT + Q_PROPERTY(QPulseAudio::Sink *defaultSink READ defaultSink NOTIFY defaultSinkChanged) + Q_PROPERTY(QPulseAudio::Sink *preferredSink READ preferredSink NOTIFY preferredSinkChanged) +public: + enum ItemRole { + SortByDefaultRole = PulseObjectRole + 1, + }; + Q_ENUM(ItemRole) + + explicit SinkModel(QObject *parent = nullptr); + Sink *defaultSink() const; + Sink *preferredSink() const; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + +Q_SIGNALS: + void defaultSinkChanged(); + void preferredSinkChanged(); + +private: + void sinkAdded(int index); + void sinkRemoved(int index); + void updatePreferredSink(); + Sink *findPreferredSink() const; + + Sink *m_preferredSink; +}; + +class SinkInputModel : public AbstractModel +{ + Q_OBJECT +public: + explicit SinkInputModel(QObject *parent = nullptr); +}; + +class SourceModel : public AbstractModel +{ + Q_OBJECT + Q_PROPERTY(QPulseAudio::Source *defaultSource READ defaultSource NOTIFY defaultSourceChanged) +public: + enum ItemRole { + SortByDefaultRole = PulseObjectRole + 1, + }; + Q_ENUM(ItemRole) + + explicit SourceModel(QObject *parent = nullptr); + Source *defaultSource() const; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + +Q_SIGNALS: + void defaultSourceChanged(); +}; + +class SourceOutputModel : public AbstractModel +{ + Q_OBJECT +public: + explicit SourceOutputModel(QObject *parent = nullptr); +}; + +class StreamRestoreModel : public AbstractModel +{ + Q_OBJECT +public: + explicit StreamRestoreModel(QObject *parent = nullptr); +}; + +class ModuleModel : public AbstractModel +{ + Q_OBJECT +public: + explicit ModuleModel(QObject *parent = nullptr); +}; + +} // QPulseAudio + +#endif // PULSEAUDIO_H diff --git a/audio/pulseobject.cpp b/audio/pulseobject.cpp new file mode 100644 index 0000000..aa6b5ef --- /dev/null +++ b/audio/pulseobject.cpp @@ -0,0 +1,80 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "pulseobject.h" + +#include "context.h" + +#include + +namespace QPulseAudio +{ +PulseObject::PulseObject(QObject *parent) + : QObject(parent) + , m_index(0) +{ +} + +PulseObject::~PulseObject() +{ +} + +Context *PulseObject::context() const +{ + return Context::instance(); +} + +uint32_t PulseObject::index() const +{ + return m_index; +} + +QString PulseObject::iconName() const +{ + QString name = m_properties.value(QStringLiteral("device.icon_name")).toString(); + if (!name.isEmpty() && QIcon::hasThemeIcon(name)) { + return name; + } + + name = m_properties.value(QStringLiteral("media.icon_name")).toString(); + if (!name.isEmpty() && QIcon::hasThemeIcon(name)) { + return name; + } + + name = m_properties.value(QStringLiteral("window.icon_name")).toString(); + if (!name.isEmpty() && QIcon::hasThemeIcon(name)) { + return name; + } + + name = m_properties.value(QStringLiteral("application.icon_name")).toString(); + if (!name.isEmpty() && QIcon::hasThemeIcon(name)) { + return name; + } + + name = m_properties.value(QStringLiteral("application.process.binary")).toString(); + if (!name.isEmpty() && QIcon::hasThemeIcon(name)) { + return name; + } + + name = m_properties.value(QStringLiteral("application.name")).toString(); + if (!name.isEmpty() && QIcon::hasThemeIcon(name)) { + return name; + } + + name = property("name").toString(); + if (!name.isEmpty() && QIcon::hasThemeIcon(name)) { + return name; + } + + return QString(); +} + +QVariantMap PulseObject::properties() const +{ + return m_properties; +} + +} // QPulseAudio diff --git a/audio/pulseobject.h b/audio/pulseobject.h new file mode 100644 index 0000000..851b20f --- /dev/null +++ b/audio/pulseobject.h @@ -0,0 +1,72 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef PULSEOBJECT_H +#define PULSEOBJECT_H + +#include "debug.h" +#include + +#include + +namespace QPulseAudio +{ +class Context; + +class PulseObject : public QObject +{ + Q_OBJECT + Q_PROPERTY(quint32 index READ index CONSTANT) + Q_PROPERTY(QString iconName READ iconName CONSTANT) + Q_PROPERTY(QVariantMap properties READ properties NOTIFY propertiesChanged) +public: + template + void updatePulseObject(PAInfo *info) + { + m_index = info->index; + + QVariantMap properties; + void *it = nullptr; + while (const char *key = pa_proplist_iterate(info->proplist, &it)) { + Q_ASSERT(key); + const char *value = pa_proplist_gets(info->proplist, key); + if (!value) { + qCDebug(PLASMAPA) << "property" << key << "not a string"; + continue; + } + Q_ASSERT(value); + properties.insert(QString::fromUtf8(key), QString::fromUtf8(value)); + } + + if (m_properties != properties) { + m_properties = properties; + Q_EMIT propertiesChanged(); + } + } + + quint32 index() const; + QString iconName() const; + QVariantMap properties() const; + +Q_SIGNALS: + void propertiesChanged(); + +protected: + explicit PulseObject(QObject *parent); + ~PulseObject() override; + + Context *context() const; + quint32 m_index; + QVariantMap m_properties; + +private: + // Ensure that we get properly parented. + PulseObject(); +}; + +} // QPulseAudio + +#endif // PULSEOBJECT_H diff --git a/audio/qml/PulseObjectFilterModel.qml b/audio/qml/PulseObjectFilterModel.qml new file mode 100644 index 0000000..5a2f7c4 --- /dev/null +++ b/audio/qml/PulseObjectFilterModel.qml @@ -0,0 +1,39 @@ +import Cutefish.Audio 1.0 + +SortFilterModel { + property var filters: [] + property bool filterOutInactiveDevices: false + + function role(name) { + return sourceModel.role(name); + } + + // filterCallback: function(source_row, value) { + // var idx = sourceModel.index(source_row, 0); + + // // Don't ever show the dummy output, that's silly + // var dummyOutputName = "auto_null" + // if (sourceModel.data(idx, sourceModel.role("Name")) === dummyOutputName) { + // return false; + // } + + // // Optionally run the role-based filters + // if (filters.length > 0) { + // for (var i = 0; i < filters.length; ++i) { + // var filter = filters[i]; + // if (sourceModel.data(idx, sourceModel.role(filter.role)) != filter.value) { + // return false; + // } + // } + // } + + // // Optionally exclude inactive devices + // if (filterOutInactiveDevices) { + // var ports = sourceModel.data(idx, sourceModel.role("PulseObject")).ports; + // if (ports.length === 1 && ports[0].availability == Port.Unavailable) { + // return false; + // } + // } + // return true; + // } +} diff --git a/audio/qml/listitemmenu.cpp b/audio/qml/listitemmenu.cpp new file mode 100644 index 0000000..355a183 --- /dev/null +++ b/audio/qml/listitemmenu.cpp @@ -0,0 +1,509 @@ +/* + SPDX-FileCopyrightText: 2021 Kai Uwe Broulik + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "listitemmenu.h" + +#include +#include +#include +#include +#include + +#include "card.h" +#include "debug.h" +#include "device.h" +#include "port.h" +#include "pulseaudio.h" +#include "pulseobject.h" +#include "stream.h" + +using namespace QPulseAudio; + +static const auto s_offProfile = QLatin1String("off"); + +ListItemMenu::ListItemMenu(QObject *parent) + : QObject(parent) +{ +} + +ListItemMenu::~ListItemMenu() = default; + +void ListItemMenu::classBegin() +{ +} + +void ListItemMenu::componentComplete() +{ + m_complete = true; + update(); +} + +ListItemMenu::ItemType ListItemMenu::itemType() const +{ + return m_itemType; +} + +void ListItemMenu::setItemType(ItemType itemType) +{ + if (m_itemType != itemType) { + m_itemType = itemType; + update(); + Q_EMIT itemTypeChanged(); + } +} + +QPulseAudio::PulseObject *ListItemMenu::pulseObject() const +{ + return m_pulseObject.data(); +} + +void ListItemMenu::setPulseObject(QPulseAudio::PulseObject *pulseObject) +{ + if (m_pulseObject.data() != pulseObject) { + // TODO is Qt clever enough to catch the disconnect from base class? + if (m_pulseObject) { + disconnect(m_pulseObject, nullptr, this, nullptr); + } + + m_pulseObject = pulseObject; + + if (auto *device = qobject_cast(m_pulseObject.data())) { + connect(device, &Device::activePortIndexChanged, this, &ListItemMenu::update); + connect(device, &Device::portsChanged, this, &ListItemMenu::update); + } + + update(); + Q_EMIT pulseObjectChanged(); + } +} + +QAbstractItemModel *ListItemMenu::sourceModel() const +{ + return m_sourceModel.data(); +} + +void ListItemMenu::setSourceModel(QAbstractItemModel *sourceModel) +{ + if (m_sourceModel.data() == sourceModel) { + return; + } + + if (m_sourceModel) { + disconnect(m_sourceModel, nullptr, this, nullptr); + } + + m_sourceModel = sourceModel; + + if (m_sourceModel) { + connect(m_sourceModel, &QAbstractItemModel::rowsInserted, this, &ListItemMenu::update); + connect(m_sourceModel, &QAbstractItemModel::rowsRemoved, this, &ListItemMenu::update); + connect(m_sourceModel, &QAbstractItemModel::modelReset, this, &ListItemMenu::update); + } + + update(); + Q_EMIT sourceModelChanged(); +} + +QPulseAudio::CardModel *ListItemMenu::cardModel() const +{ + return m_cardModel.data(); +} + +void ListItemMenu::setCardModel(QPulseAudio::CardModel *cardModel) +{ + if (m_cardModel.data() == cardModel) { + return; + } + + if (m_cardModel) { + disconnect(m_cardModel, nullptr, this, nullptr); + } + m_cardModel = cardModel; + + if (m_cardModel) { + const int profilesRole = m_cardModel->role("Profiles"); + Q_ASSERT(profilesRole > -1); + + connect(m_cardModel, &CardModel::dataChanged, this, [this, profilesRole](const QModelIndex &, const QModelIndex &, const QVector &roles) { + if (roles.isEmpty() || roles.contains(profilesRole)) { + update(); + } + }); + } + + update(); + Q_EMIT cardModelChanged(); +} + +bool ListItemMenu::isVisible() const +{ + return m_visible; +} + +void ListItemMenu::setVisible(bool visible) +{ + if (m_visible != visible) { + m_visible = visible; + Q_EMIT visibleChanged(); + } +} + +bool ListItemMenu::hasContent() const +{ + return m_hasContent; +} + +QQuickItem *ListItemMenu::visualParent() const +{ + return m_visualParent.data(); +} + +void ListItemMenu::setVisualParent(QQuickItem *visualParent) +{ + if (m_visualParent.data() != visualParent) { + m_visualParent = visualParent; + Q_EMIT visualParentChanged(); + } +} + +bool ListItemMenu::checkHasContent() +{ + // If there are at least two sink/source devices to choose from. + if (m_sourceModel && m_sourceModel->rowCount() > 1) { + return true; + } + + auto *device = qobject_cast(m_pulseObject.data()); + + if (device) { + const auto ports = device->ports(); + if (ports.length() > 1) { + // In case an unavailable port is active. + if (device->activePortIndex() != static_cast(-1)) { + auto *activePort = static_cast(ports.at(device->activePortIndex())); + if (activePort->availability() == Port::Unavailable) { + return true; + } + } + + // If there are at least two available ports. + int availablePorts = 0; + for (auto *portObject : ports) { + auto *port = static_cast(portObject); + if (port->availability() == Port::Unavailable) { + continue; + } + + if (++availablePorts == 2) { + return true; + } + } + } + + if (m_cardModel) { + const int cardModelPulseObjectRole = m_cardModel->role("PulseObject"); + Q_ASSERT(cardModelPulseObjectRole != -1); + + for (int i = 0; i < m_cardModel->rowCount(); ++i) { + const QModelIndex cardIdx = m_cardModel->index(i, 0); + Card *card = qobject_cast(cardIdx.data(cardModelPulseObjectRole).value()); + + if (card->index() == device->cardIndex()) { + // If there are at least two available profiles on the corresponding card. + const auto profiles = card->profiles(); + int availableProfiles = 0; + for (auto *profileObject : profiles) { + auto *profile = static_cast(profileObject); + if (profile->availability() == Profile::Unavailable) { + continue; + } + + if (profile->name() == s_offProfile) { + continue; + } + + // TODO should we also check "if current profile is unavailable" like with ports? + if (++availableProfiles == 2) { + return true; + } + } + } + } + } + } + + return false; +} + +void ListItemMenu::update() +{ + if (!m_complete) { + return; + } + + const bool hasContent = checkHasContent(); + if (m_hasContent != hasContent) { + m_hasContent = hasContent; + Q_EMIT hasContentChanged(); + } +} + +void ListItemMenu::open(int x, int y) +{ + auto *menu = createMenu(); + if (!menu) { + return; + } + + const QPoint pos = m_visualParent->mapToGlobal(QPointF(x, y)).toPoint(); + + menu->popup(pos); + setVisible(true); +} + +// to the bottom left of visualParent +void ListItemMenu::openRelative() +{ + auto *menu = createMenu(); + if (!menu) { + return; + } + + menu->adjustSize(); + + QPoint pos = m_visualParent->mapToGlobal(QPointF(m_visualParent->width(), m_visualParent->height())).toPoint(); + pos.rx() -= menu->width(); + + // TODO do we still need this ungrab mouse hack? + menu->popup(pos); + setVisible(true); +} + +static int getModelRole(QObject *model, const QByteArray &name) +{ + // Can either be an AbstractModel, then it's easy + if (auto *abstractModel = qobject_cast(model)) { + return abstractModel->role(name); + } + + // or that PulseObjectFilterModel from QML where everything is a QVariant... + QVariant roleVariant; + bool ok = QMetaObject::invokeMethod(model, "role", Q_RETURN_ARG(QVariant, roleVariant), Q_ARG(QVariant, QVariant(name))); + if (!ok) { + qCCritical(PLASMAPA) << "Failed to invoke 'role' on" << model; + return -1; + } + + int role = roleVariant.toInt(&ok); + if (!ok) { + qCCritical(PLASMAPA) << "Return value from 'role' is bogus" << roleVariant; + return -1; + } + + return role; +} + +QMenu *ListItemMenu::createMenu() +{ + if (m_visible) { + return nullptr; + } + + if (!m_visualParent || !m_visualParent->window()) { + qCWarning(PLASMAPA) << "Cannot prepare menu without visualParent or a window"; + return nullptr; + } + + QMenu *menu = new QMenu(); + menu->setAttribute(Qt::WA_DeleteOnClose); + + connect(menu, &QMenu::aboutToHide, this, [this] { + setVisible(false); + }); + + if (auto *device = qobject_cast(m_pulseObject.data())) { + // Switch all streams of the relevant kind to this device + if (m_sourceModel->rowCount() > 1) { + QAction *switchStreamsAction = nullptr; + if (m_itemType == Sink) { + switchStreamsAction = menu->addAction( + QIcon::fromTheme(QStringLiteral("audio-on"), + QIcon::fromTheme(QStringLiteral("audio-ready"), QIcon::fromTheme(QStringLiteral("audio-speakers-symbolic")))), + tr("Play all audio via this device")); + } else if (m_itemType == Source) { + switchStreamsAction = menu->addAction( + QIcon::fromTheme(QStringLiteral("mic-on"), + QIcon::fromTheme(QStringLiteral("mic-ready"), QIcon::fromTheme(QStringLiteral("audio-input-microphone-symbolic")))), + tr("Record all audio via this device")); + } + + if (switchStreamsAction) { + connect(switchStreamsAction, &QAction::triggered, device, &Device::switchStreams); + } + } + + // Ports + const auto ports = device->ports(); + bool activePortUnavailable = false; + if (device->activePortIndex() != static_cast(-1)) { + auto *activePort = static_cast(ports.at(device->activePortIndex())); + activePortUnavailable = activePort->availability() == Port::Unavailable; + } + + QMap availablePorts; + for (int i = 0; i < ports.count(); ++i) { + auto *port = static_cast(ports.at(i)); + + // If an unavailable port is active, show all the ports, + // otherwise show only the available ones + if (activePortUnavailable || port->availability() != Port::Unavailable) { + availablePorts.insert(i, port); + } + } + + if (availablePorts.count() > 1) { + menu->addSection(tr("Heading for a list of ports of a device (for example built-in laptop speakers or a plug for headphones)", "Ports")); + + auto *portGroup = new QActionGroup(menu); + + for (auto it = availablePorts.constBegin(), end = availablePorts.constEnd(); it != end; ++it) { + const int i = it.key(); + Port *port = it.value(); + + QAction *item = nullptr; + + if (port->availability() == Port::Unavailable) { + if (port->name() == QLatin1String("analog-output-speaker") || port->name() == QLatin1String("analog-input-microphone-internal")) { + item = menu->addAction(tr("%1 (unavailable)").arg(port->description())); + } else { + item = menu->addAction(tr("%1 (unplugged)").arg(port->description())); + } + } else { + item = menu->addAction(port->description()); + } + + item->setCheckable(true); + item->setChecked(static_cast(i) == device->activePortIndex()); + connect(item, &QAction::triggered, device, [device, i] { + device->setActivePortIndex(i); + }); + + portGroup->addAction(item); + } + } + + // Submenu with profiles + if (m_cardModel) { + const int cardModelPulseObjectRole = m_cardModel->role("PulseObject"); + Q_ASSERT(cardModelPulseObjectRole != -1); + + Card *card = nullptr; + for (int i = 0; i < m_cardModel->rowCount(); ++i) { + const QModelIndex cardIdx = m_cardModel->index(i, 0); + Card *candidateCard = qobject_cast(cardIdx.data(cardModelPulseObjectRole).value()); + + if (candidateCard && candidateCard->index() == device->cardIndex()) { + card = candidateCard; + break; + } + } + + if (card) { + QMap availableProfiles; + + const auto profiles = card->profiles(); + for (int i = 0; i < profiles.count(); ++i) { + auto *profile = static_cast(profiles.at(i)); + + // TODO should we also check "if current profile is unavailable" like with ports? + if (profile->availability() == Profile::Unavailable) { + continue; + } + + // Don't let user easily remove a device with no obvious way to get it back + // Only let that be done from the KCM where one can just flip the ComboBox back. + if (profile->name() == s_offProfile) { + continue; + } + + availableProfiles.insert(i, profile); + } + + if (availableProfiles.count() > 1) { + // If there's too many profiles, put them in a submenu, unless the menu is empty, otherwise as a section + QMenu *profilesMenu = menu; + const QString title = tr("Heading for a list of device profiles (5.1 surround sound, stereo, speakers only, ...)", "Profiles"); + // "10" is catered around laptop speakers (internal, stereo, duplex) plus one HDMI port (stereo, surround 5.1, 7.1, in and out, etc) + if (availableProfiles.count() > 10 && !menu->actions().isEmpty()) { + profilesMenu = menu->addMenu(title); + } else { + menu->addSection(title); + } + + QActionGroup *profileGroup = new QActionGroup(profilesMenu); + for (auto it = availableProfiles.constBegin(), end = availableProfiles.constEnd(); it != end; ++it) { + const int i = it.key(); + Profile *profile = it.value(); + + auto *profileAction = profilesMenu->addAction(profile->description()); + profileAction->setCheckable(true); + profileAction->setChecked(static_cast(i) == card->activeProfileIndex()); + connect(profileAction, &QAction::triggered, card, [card, i] { + card->setActiveProfileIndex(i); + }); + + profileGroup->addAction(profileAction); + } + } + } else { + qCWarning(PLASMAPA) << "Failed to find card at" << device->cardIndex() << "for" << device->description() << device->index(); + } + } + } + + // Choose output / input device + auto *stream = qobject_cast(m_pulseObject.data()); + if (stream && m_sourceModel && m_sourceModel->rowCount() > 1) { + if (m_itemType == SinkInput || m_itemType == SourceOutput) { + if (m_itemType == SinkInput) { + menu->addSection(tr("Heading for a list of possible output devices (speakers, headphones, ...) to choose", "Play audio using")); + } else { + menu->addSection(tr("Heading for a list of possible input devices (built-in microphone, headset, ...) to choose", "Record audio using")); + } + + const int indexRole = getModelRole(m_sourceModel, "Index"); + Q_ASSERT(indexRole > -1); + const int descriptionRole = getModelRole(m_sourceModel, "Description"); + Q_ASSERT(descriptionRole > -1); + + auto *deviceGroup = new QActionGroup(menu); + + for (int i = 0; i < m_sourceModel->rowCount(); ++i) { + const QModelIndex idx = m_sourceModel->index(i, 0); + const auto index = idx.data(indexRole).toUInt(); + + auto *item = menu->addAction(idx.data(descriptionRole).toString()); + item->setCheckable(true); + item->setChecked(index == stream->deviceIndex()); + connect(item, &QAction::triggered, stream, [stream, index] { + stream->setDeviceIndex(index); + }); + + deviceGroup->addAction(item); + } + } + } + + if (menu->isEmpty()) { + delete menu; + return nullptr; + } + + menu->winId(); + menu->windowHandle()->setTransientParent(m_visualParent->window()); + + return menu; +} diff --git a/audio/qml/listitemmenu.h b/audio/qml/listitemmenu.h new file mode 100644 index 0000000..a6abb4c --- /dev/null +++ b/audio/qml/listitemmenu.h @@ -0,0 +1,103 @@ +/* + SPDX-FileCopyrightText: 2021 Kai Uwe Broulik + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#pragma once + +#include +#include +#include + +class QAbstractItemModel; +class QMenu; +class QQuickItem; + +namespace QPulseAudio +{ +class CardModel; +class PulseObject; +} + +class ListItemMenu : public QObject, public QQmlParserStatus +{ + Q_OBJECT + Q_INTERFACES(QQmlParserStatus) + + Q_PROPERTY(ItemType itemType READ itemType WRITE setItemType NOTIFY itemTypeChanged) + + Q_PROPERTY(QPulseAudio::PulseObject *pulseObject READ pulseObject WRITE setPulseObject NOTIFY pulseObjectChanged) + + Q_PROPERTY(QAbstractItemModel *sourceModel READ sourceModel WRITE setSourceModel NOTIFY sourceModelChanged) + + Q_PROPERTY(QPulseAudio::CardModel *cardModel READ cardModel WRITE setCardModel NOTIFY cardModelChanged) + + Q_PROPERTY(bool visible READ isVisible NOTIFY visibleChanged) + + Q_PROPERTY(bool hasContent READ hasContent NOTIFY hasContentChanged) + + Q_PROPERTY(QQuickItem *visualParent READ visualParent WRITE setVisualParent NOTIFY visualParentChanged) + +public: + explicit ListItemMenu(QObject *parent = nullptr); + ~ListItemMenu() override; + + enum ItemType { + None, + Sink, + SinkInput, + Source, + SourceOutput, + }; + Q_ENUM(ItemType) + + ItemType itemType() const; + void setItemType(ItemType itemType); + Q_SIGNAL void itemTypeChanged(); + + QPulseAudio::PulseObject *pulseObject() const; + void setPulseObject(QPulseAudio::PulseObject *pulseObject); + Q_SIGNAL void pulseObjectChanged(); + + QAbstractItemModel *sourceModel() const; + void setSourceModel(QAbstractItemModel *sourceModel); + Q_SIGNAL void sourceModelChanged(); + + QPulseAudio::CardModel *cardModel() const; + void setCardModel(QPulseAudio::CardModel *cardModel); + Q_SIGNAL void cardModelChanged(); + + bool isVisible() const; + Q_SIGNAL void visibleChanged(); + + bool hasContent() const; + Q_SIGNAL void hasContentChanged(); + + QQuickItem *visualParent() const; + void setVisualParent(QQuickItem *visualParent); + Q_SIGNAL void visualParentChanged(); + + void classBegin() override; + void componentComplete() override; + + Q_INVOKABLE void open(int x, int y); + Q_INVOKABLE void openRelative(); + +private: + void setVisible(bool visible); + + void update(); + bool checkHasContent(); + QMenu *createMenu(); + + bool m_complete = false; + bool m_visible = false; + bool m_hasContent = false; + QPointer m_visualParent; + + ItemType m_itemType = None; + QPointer m_pulseObject; + QPointer m_sourceModel; + QPointer m_cardModel; +}; diff --git a/audio/qml/microphoneindicator.cpp b/audio/qml/microphoneindicator.cpp new file mode 100644 index 0000000..fb13170 --- /dev/null +++ b/audio/qml/microphoneindicator.cpp @@ -0,0 +1,331 @@ +/* + SPDX-FileCopyrightText: 2019 Kai Uwe Broulik + SPDX-FileCopyrightText: 2020 MBition GmbH + Author: Kai Uwe Broulik + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "microphoneindicator.h" + +#include +#include +#include +#include + +#include + +#include "client.h" +#include "context.h" +#include "pulseaudio.h" +#include "source.h" + +#include "volumeosd.h" + +using namespace QPulseAudio; + +MicrophoneIndicator::MicrophoneIndicator(QObject *parent) + : QObject(parent) + , m_sourceModel(new SourceModel(this)) + , m_sourceOutputModel(new SourceOutputModel(this)) + , m_updateTimer(new QTimer(this)) +{ + connect(m_sourceModel, &QAbstractItemModel::rowsInserted, this, &MicrophoneIndicator::scheduleUpdate); + connect(m_sourceModel, &QAbstractItemModel::rowsRemoved, this, &MicrophoneIndicator::scheduleUpdate); + connect(m_sourceModel, &QAbstractItemModel::dataChanged, this, &MicrophoneIndicator::scheduleUpdate); + + connect(m_sourceOutputModel, &QAbstractItemModel::rowsInserted, this, &MicrophoneIndicator::scheduleUpdate); + connect(m_sourceOutputModel, &QAbstractItemModel::rowsRemoved, this, &MicrophoneIndicator::scheduleUpdate); + connect(m_sourceOutputModel, &QAbstractItemModel::dataChanged, this, &MicrophoneIndicator::scheduleUpdate); + + m_updateTimer->setInterval(0); + m_updateTimer->setSingleShot(true); + connect(m_updateTimer, &QTimer::timeout, this, &MicrophoneIndicator::update); + + scheduleUpdate(); +} + +MicrophoneIndicator::~MicrophoneIndicator() = default; + +void MicrophoneIndicator::init() +{ + // does nothing, just prompts QML engine to create an instance of the singleton +} + +void MicrophoneIndicator::scheduleUpdate() +{ + if (!m_updateTimer->isActive()) { + m_updateTimer->start(); + } +} + +void MicrophoneIndicator::update() +{ + const auto apps = recordingApplications(); + if (apps.isEmpty()) { + m_showOsdOnUpdate = false; + delete m_sni; + m_sni = nullptr; + return; + } + + if (!m_sni) { + m_sni = new KStatusNotifierItem(QStringLiteral("microphone")); + m_sni->setCategory(KStatusNotifierItem::Hardware); + // always Active since it is completely removed when microphone isn't in use + m_sni->setStatus(KStatusNotifierItem::Active); + + // but also middle click to be consistent with volume icon + connect(m_sni, &KStatusNotifierItem::secondaryActivateRequested, this, &MicrophoneIndicator::toggleMuted); + connect(m_sni, &KStatusNotifierItem::activateRequested, this, &MicrophoneIndicator::toggleMuted); + + connect(m_sni, &KStatusNotifierItem::scrollRequested, this, [this](int delta, Qt::Orientation orientation) { + if (orientation != Qt::Vertical) { + return; + } + + m_wheelDelta += delta; + + while (m_wheelDelta >= 120) { + m_wheelDelta -= 120; + adjustVolume(+1); + } + while (m_wheelDelta <= -120) { + m_wheelDelta += 120; + adjustVolume(-1); + } + }); + + QMenu *menu = m_sni->contextMenu(); + + m_muteAction = menu->addAction(QIcon::fromTheme(QStringLiteral("microphone-sensitivity-muted")), i18n("Mute")); + m_muteAction->setCheckable(true); + connect(m_muteAction.data(), &QAction::triggered, this, &MicrophoneIndicator::setMuted); + + // don't let it quit plasmashell + m_sni->setStandardActionsEnabled(false); + } + + const bool allMuted = muted(); + + QString iconName; + if (allMuted) { + iconName = QStringLiteral("microphone-sensitivity-muted"); + } else { + if (Source *defaultSource = m_sourceModel->defaultSource()) { + const int percent = volumePercent(defaultSource); + iconName = QStringLiteral("microphone-sensitivity"); + // it deliberately never shows the "muted" icon unless *all* microphones are muted + if (percent <= 25) { + iconName.append(QStringLiteral("-low")); + } else if (percent <= 75) { + iconName.append(QStringLiteral("-medium")); + } else { + iconName.append(QStringLiteral("-high")); + } + } else { + iconName = QStringLiteral("microphone-sensitivity-high"); + } + } + + m_sni->setTitle(i18n("Microphone")); + m_sni->setIconByName(iconName); + m_sni->setToolTip(QIcon::fromTheme(iconName), allMuted ? i18n("Microphone Muted") : i18n("Microphone"), toolTipForApps(apps)); + + if (m_muteAction) { + m_muteAction->setChecked(allMuted); + } + + if (m_showOsdOnUpdate) { + showOsd(); + m_showOsdOnUpdate = false; + } +} + +bool MicrophoneIndicator::muted() const +{ + static const int s_mutedRole = m_sourceModel->role(QByteArrayLiteral("Muted")); + Q_ASSERT(s_mutedRole > -1); + + for (int row = 0; row < m_sourceModel->rowCount(); ++row) { + const QModelIndex idx = m_sourceModel->index(row); + if (!idx.data(s_mutedRole).toBool()) { + // this is deliberately checking if *all* microphones are muted rather than the preferred one + return false; + } + } + + return true; +} + +void MicrophoneIndicator::setMuted(bool muted) +{ + static const int s_mutedRole = m_sourceModel->role(QByteArrayLiteral("Muted")); + Q_ASSERT(s_mutedRole > -1); + + m_showOsdOnUpdate = true; + + if (muted) { + for (int row = 0; row < m_sourceModel->rowCount(); ++row) { + const QModelIndex idx = m_sourceModel->index(row); + if (!idx.data(s_mutedRole).toBool()) { + m_sourceModel->setData(idx, true, s_mutedRole); + m_mutedIndices.append(QPersistentModelIndex(idx)); + continue; + } + } + return; + } + + // If we didn't mute it, unmute all + if (m_mutedIndices.isEmpty()) { + for (int i = 0; i < m_sourceModel->rowCount(); ++i) { + m_sourceModel->setData(m_sourceModel->index(i), false, s_mutedRole); + } + return; + } + + // Otherwise unmute the devices we muted + for (auto &idx : qAsConst(m_mutedIndices)) { + if (!idx.isValid()) { + continue; + } + m_sourceModel->setData(idx, false, s_mutedRole); + } + m_mutedIndices.clear(); + + // no update() needed as the model signals a change +} + +void MicrophoneIndicator::toggleMuted() +{ + setMuted(!muted()); +} + +void MicrophoneIndicator::adjustVolume(int direction) +{ + Source *source = m_sourceModel->defaultSource(); + if (!source) { + return; + } + + const int step = qRound(5 * Context::NormalVolume / 100.0); + + const auto newVolume = qBound(Context::MinimalVolume, // + source->volume() + direction * step, // + Context::NormalVolume); + + source->setVolume(newVolume); + source->setMuted(newVolume == Context::MinimalVolume); + + m_showOsdOnUpdate = true; +} + +int MicrophoneIndicator::volumePercent(Source *source) +{ + return source->isMuted() ? 0 : qRound(source->volume() / static_cast(Context::NormalVolume) * 100); +} + +void MicrophoneIndicator::showOsd() +{ + if (!m_osd) { + m_osd = new VolumeOSD(this); + } + + auto *preferredSource = m_sourceModel->defaultSource(); + if (!preferredSource) { + return; + } + + m_osd->showMicrophone(volumePercent(preferredSource)); +} + +QVector MicrophoneIndicator::recordingApplications() const +{ + QVector indices; + + // If there are no microphones present, there's nothing to record + if (m_sourceModel->rowCount() == 0) { + return indices; + } + + static const int s_virtualStreamRole = m_sourceOutputModel->role(QByteArrayLiteral("VirtualStream")); + Q_ASSERT(s_virtualStreamRole > -1); + + indices.reserve(m_sourceOutputModel->rowCount()); + + for (int i = 0; i < m_sourceOutputModel->rowCount(); ++i) { + const QModelIndex idx = m_sourceOutputModel->index(i); + + if (idx.data(s_virtualStreamRole).toBool()) { + continue; + } + + indices.append(idx); + } + + return indices; +} + +QString MicrophoneIndicator::toolTipForApps(const QVector &apps) const +{ + Q_ASSERT(!apps.isEmpty()); + + if (apps.count() > 1) { + QStringList names; + names.reserve(apps.count()); + for (const QModelIndex &idx : apps) { + names.append(sourceOutputDisplayName(idx)); + } + names.removeDuplicates(); + // Still more than one app? + if (names.count() > 1) { + return i18nc("List of apps is using mic", "%1 are using the microphone", names.join(i18nc("list separator", ", "))); + } + } + + const QModelIndex appIdx = apps.constFirst(); + + // If there is more than one microphone, show which one is being used. + // An app could record multiple microphones simultaneously, or the user having the same app running + // multiple times recording the same microphone, but this isn't covered here for simplicity. + if (apps.count() == 1 && m_sourceModel->rowCount() > 1) { + static const int s_sourceModelDescriptionRole = m_sourceModel->role(QByteArrayLiteral("Description")); + Q_ASSERT(s_sourceModelDescriptionRole > -1); + static const int s_sourceModelIndexRole = m_sourceModel->role("Index"); + Q_ASSERT(s_sourceModelIndexRole > -1); + + static const int s_sourceOutputModelDeviceIndexRole = m_sourceOutputModel->role("DeviceIndex"); + Q_ASSERT(s_sourceOutputModelDeviceIndexRole > -1); + + const int sourceOutputDeviceIndex = appIdx.data(s_sourceOutputModelDeviceIndexRole).toInt(); + + for (int i = 0; i < m_sourceModel->rowCount(); ++i) { + const QModelIndex sourceDeviceIdx = m_sourceModel->index(i, 0); + const int sourceDeviceIndex = sourceDeviceIdx.data(s_sourceModelIndexRole).toInt(); + + if (sourceDeviceIndex == sourceOutputDeviceIndex) { + return i18nc("App %1 is using mic with name %2", + "%1 is using the microphone (%2)", + sourceOutputDisplayName(appIdx), + sourceDeviceIdx.data(s_sourceModelDescriptionRole).toString()); + } + } + } + + return i18nc("App is using mic", "%1 is using the microphone", sourceOutputDisplayName(appIdx)); +} + +QString MicrophoneIndicator::sourceOutputDisplayName(const QModelIndex &idx) const +{ + Q_ASSERT(idx.model() == m_sourceOutputModel); + + static const int s_nameRole = m_sourceOutputModel->role(QByteArrayLiteral("Name")); + Q_ASSERT(s_nameRole > -1); + static const int s_clientRole = m_sourceOutputModel->role(QByteArrayLiteral("Client")); + Q_ASSERT(s_clientRole > -1); + + auto *client = qobject_cast(idx.data(s_clientRole).value()); + + return client ? client->name() : idx.data(s_nameRole).toString(); +} diff --git a/audio/qml/microphoneindicator.h b/audio/qml/microphoneindicator.h new file mode 100644 index 0000000..9a2cef0 --- /dev/null +++ b/audio/qml/microphoneindicator.h @@ -0,0 +1,75 @@ +/* + SPDX-FileCopyrightText: 2019 Kai Uwe Broulik + SPDX-FileCopyrightText: 2020 MBition GmbH + Author: Kai Uwe Broulik + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#pragma once + +#include +#include +#include +#include + +class QAction; +class QTimer; + +class KStatusNotifierItem; + +class VolumeOSD; + +namespace QPulseAudio +{ +class Source; +class SourceModel; +class SourceOutputModel; +} + +class MicrophoneIndicator : public QObject +{ + Q_OBJECT + +public: + explicit MicrophoneIndicator(QObject *parent = nullptr); + ~MicrophoneIndicator() override; + + Q_INVOKABLE void init(); + +Q_SIGNALS: + void enabledChanged(); + +private: + void scheduleUpdate(); + void update(); + + bool muted() const; + void setMuted(bool muted); + void toggleMuted(); + + void adjustVolume(int direction); + + static int volumePercent(QPulseAudio::Source *source); + void showOsd(); + + QVector recordingApplications() const; + QString toolTipForApps(const QVector &apps) const; + QString sourceOutputDisplayName(const QModelIndex &idx) const; + + QPulseAudio::SourceModel *m_sourceModel = nullptr; // microphone devices + QPulseAudio::SourceOutputModel *m_sourceOutputModel = nullptr; // recording streams + + KStatusNotifierItem *m_sni = nullptr; + QPointer m_muteAction; + QPointer m_dontAgainAction; + + QVector m_mutedIndices; + + VolumeOSD *m_osd = nullptr; + bool m_showOsdOnUpdate = false; + + int m_wheelDelta = 0; + + QTimer *m_updateTimer; +}; diff --git a/audio/qml/plugin.cpp b/audio/qml/plugin.cpp new file mode 100644 index 0000000..6638508 --- /dev/null +++ b/audio/qml/plugin.cpp @@ -0,0 +1,71 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "plugin.h" + +#include + +#include "client.h" +#include "context.h" +#include "modulemanager.h" +#include "port.h" +#include "profile.h" +#include "pulseaudio.h" +#include "sink.h" +#include "source.h" +#include "volumemonitor.h" + +#include "listitemmenu.h" +// #include "microphoneindicator.h" +#include "speakertest.h" +#include "volumefeedback.h" +#include "model/sortfiltermodel.h" +// #include "volumeosd.h" + +static QJSValue pulseaudio_singleton(QQmlEngine *engine, QJSEngine *scriptEngine) +{ + Q_UNUSED(engine) + + QJSValue object = scriptEngine->newObject(); + object.setProperty(QStringLiteral("NormalVolume"), (double)QPulseAudio::Context::NormalVolume); + object.setProperty(QStringLiteral("MinimalVolume"), (double)QPulseAudio::Context::MinimalVolume); + object.setProperty(QStringLiteral("MaximalVolume"), (double)QPulseAudio::Context::MaximalVolume); + return object; +} + +void Plugin::registerTypes(const char *uri) +{ + QPulseAudio::Context::setApplicationId(QStringLiteral("Cutefish.Audio")); + + qmlRegisterType(uri, 1, 0, "SortFilterModel"); + + qmlRegisterType(uri, 1, 0, "CardModel"); + qmlRegisterType(uri, 1, 0, "SinkModel"); + qmlRegisterType(uri, 1, 0, "SinkInputModel"); + qmlRegisterType(uri, 1, 0, "SourceModel"); + qmlRegisterType(uri, 1, 0, "ModuleManager"); + qmlRegisterType(uri, 1, 0, "SourceOutputModel"); + qmlRegisterType(uri, 1, 0, "StreamRestoreModel"); + qmlRegisterType(uri, 1, 0, "ModuleModel"); + qmlRegisterType(uri, 0, 01, "VolumeMonitor"); + qmlRegisterUncreatableType(uri, 1, 0, "PulseObject", QString()); + qmlRegisterUncreatableType(uri, 1, 0, "Profile", QString()); + qmlRegisterUncreatableType(uri, 1, 0, "Port", QString()); + qmlRegisterType(uri, 1, 0, "ListItemMenu"); + // qmlRegisterType(uri, 1, 0, "VolumeOSD"); + qmlRegisterType(uri, 1, 0, "VolumeFeedback"); + qmlRegisterType(uri, 1, 0, "SpeakerTest"); + qmlRegisterSingletonType(uri, 1, 0, "PulseAudio", pulseaudio_singleton); + // qmlRegisterSingletonType(uri, 1, 0, "MicrophoneIndicator", [](QQmlEngine *engine, QJSEngine *jsEngine) -> QObject * { + // Q_UNUSED(engine); + // Q_UNUSED(jsEngine); + // return new MicrophoneIndicator(); + // }); + qmlRegisterAnonymousType(uri, 1); + qmlRegisterAnonymousType(uri, 1); + qmlRegisterAnonymousType(uri, 1); + qmlRegisterAnonymousType(uri, 1); +} diff --git a/audio/qml/plugin.h b/audio/qml/plugin.h new file mode 100644 index 0000000..744a751 --- /dev/null +++ b/audio/qml/plugin.h @@ -0,0 +1,20 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef PLUGIN_H +#define PLUGIN_H + +#include + +class Plugin : public QQmlExtensionPlugin +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "org.qt-project.Qt.QQmlExtensionInterface") +public: + void registerTypes(const char *uri) override; +}; + +#endif // PLUGIN_H diff --git a/audio/qml/qmldir b/audio/qml/qmldir new file mode 100644 index 0000000..81695c8 --- /dev/null +++ b/audio/qml/qmldir @@ -0,0 +1,4 @@ +module Cutefish.Audio +plugin cutefishaudio_qmlplugins + +PulseObjectFilterModel 1.0 PulseObjectFilterModel.qml diff --git a/audio/qml/volumefeedback.cpp b/audio/qml/volumefeedback.cpp new file mode 100644 index 0000000..5641b8c --- /dev/null +++ b/audio/qml/volumefeedback.cpp @@ -0,0 +1,67 @@ +/* + SPDX-FileCopyrightText: 2008 Helio Chissini de Castro + SPDX-FileCopyrightText: 2016 David Rosca + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "volumefeedback.h" +#include "canberracontext.h" + +VolumeFeedback::VolumeFeedback(QObject *parent) + : QObject(parent) +{ + QPulseAudio::CanberraContext::instance()->ref(); + if (ca_context_set_driver(QPulseAudio::CanberraContext::instance()->canberra(), "pulse") != CA_SUCCESS) { + return; + } +} + +VolumeFeedback::~VolumeFeedback() +{ + QPulseAudio::CanberraContext::instance()->unref(); +} + +bool VolumeFeedback::isValid() const +{ + return QPulseAudio::CanberraContext::instance()->canberra(); +} + +void VolumeFeedback::play(quint32 sinkIndex) +{ + auto context = QPulseAudio::CanberraContext::instance()->canberra(); + + if (!context) { + return; + } + + int playing = 0; + const int cindex = 2; // Note "2" is simply the index we've picked. It's somewhat irrelevant. + ca_context_playing(context, cindex, &playing); + + // NB Depending on how this is desired to work, we may want to simply + // skip playing, or cancel the currently playing sound and play our + // new one... for now, let's do the latter. + if (playing) { + ca_context_cancel(context, cindex); + } + + char dev[64]; + snprintf(dev, sizeof(dev), "%lu", (unsigned long)sinkIndex); + ca_context_change_device(context, dev); + + // Ideally we'd use something like ca_gtk_play_for_widget()... + ca_context_play(context, + cindex, + CA_PROP_EVENT_DESCRIPTION, + "Volume Control Feedback Sound", + CA_PROP_EVENT_ID, + "audio-volume-change", + CA_PROP_CANBERRA_CACHE_CONTROL, + "permanent", + CA_PROP_CANBERRA_ENABLE, + "1", + nullptr); + + ca_context_change_device(context, nullptr); +} diff --git a/audio/qml/volumefeedback.h b/audio/qml/volumefeedback.h new file mode 100644 index 0000000..32f7b0c --- /dev/null +++ b/audio/qml/volumefeedback.h @@ -0,0 +1,29 @@ +/* + SPDX-FileCopyrightText: 2016 David Rosca + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef VOLUMEFEEDBACK_H +#define VOLUMEFEEDBACK_H + +#include + +#include + +class VolumeFeedback : public QObject +{ + Q_OBJECT + Q_PROPERTY(bool valid READ isValid CONSTANT) + +public: + explicit VolumeFeedback(QObject *parent = nullptr); + ~VolumeFeedback() override; + + bool isValid() const; + +public Q_SLOTS: + void play(quint32 sinkIndex); +}; + +#endif // VOLUMEFEEDBACK_H diff --git a/audio/qml/volumeosd.cpp b/audio/qml/volumeosd.cpp new file mode 100644 index 0000000..01f7ad0 --- /dev/null +++ b/audio/qml/volumeosd.cpp @@ -0,0 +1,36 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "volumeosd.h" + +#include "osdservice.h" + +#define SERVICE QLatin1String("org.kde.plasmashell") +#define PATH QLatin1String("/org/kde/osdService") +#define CONNECTION QDBusConnection::sessionBus() + +VolumeOSD::VolumeOSD(QObject *parent) + : QObject(parent) +{ +} + +void VolumeOSD::show(int percent, int maximumPercent) +{ + OsdServiceInterface osdService(SERVICE, PATH, CONNECTION); + osdService.volumeChanged(percent, maximumPercent); +} + +void VolumeOSD::showMicrophone(int percent) +{ + OsdServiceInterface osdService(SERVICE, PATH, CONNECTION); + osdService.microphoneVolumeChanged(percent); +} + +void VolumeOSD::showText(const QString &iconName, const QString &text) +{ + OsdServiceInterface osdService(SERVICE, PATH, CONNECTION); + osdService.showText(iconName, text); +} diff --git a/audio/qml/volumeosd.h b/audio/qml/volumeosd.h new file mode 100644 index 0000000..e783c11 --- /dev/null +++ b/audio/qml/volumeosd.h @@ -0,0 +1,24 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef VOLUMEOSD_H +#define VOLUMEOSD_H + +#include + +class VolumeOSD : public QObject +{ + Q_OBJECT +public: + explicit VolumeOSD(QObject *parent = nullptr); + +public Q_SLOTS: + void show(int percent, int maximumPercent = 100); + void showMicrophone(int percent); + void showText(const QString &iconName, const QString &text); +}; + +#endif // VOLUMEOSD_H diff --git a/audio/server.cpp b/audio/server.cpp new file mode 100644 index 0000000..583a7c5 --- /dev/null +++ b/audio/server.cpp @@ -0,0 +1,117 @@ +/* + SPDX-FileCopyrightText: 2016 David Rosca + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "server.h" +#include "context.h" +#include "debug.h" +#include "sink.h" +#include "source.h" + +namespace QPulseAudio +{ +Server::Server(Context *context) + : QObject(context) + , m_defaultSink(nullptr) + , m_defaultSource(nullptr) + , m_isPipeWire(false) +{ + Q_ASSERT(context); + + connect(&context->sinks(), &MapBaseQObject::added, this, &Server::updateDefaultDevices); + connect(&context->sinks(), &MapBaseQObject::removed, this, &Server::updateDefaultDevices); + connect(&context->sources(), &MapBaseQObject::added, this, &Server::updateDefaultDevices); + connect(&context->sources(), &MapBaseQObject::removed, this, &Server::updateDefaultDevices); +} + +Sink *Server::defaultSink() const +{ + return m_defaultSink; +} + +void Server::setDefaultSink(Sink *sink) +{ + Q_ASSERT(sink); + Context::instance()->setDefaultSink(sink->name()); +} + +Source *Server::defaultSource() const +{ + return m_defaultSource; +} + +void Server::setDefaultSource(Source *source) +{ + Q_ASSERT(source); + Context::instance()->setDefaultSource(source->name()); +} + +void Server::reset() +{ + if (m_defaultSink) { + m_defaultSink = nullptr; + Q_EMIT defaultSinkChanged(m_defaultSink); + } + + if (m_defaultSource) { + m_defaultSource = nullptr; + Q_EMIT defaultSourceChanged(m_defaultSource); + } +} + +void Server::update(const pa_server_info *info) +{ + m_defaultSinkName = QString::fromUtf8(info->default_sink_name); + m_defaultSourceName = QString::fromUtf8(info->default_source_name); + m_isPipeWire = QString::fromUtf8(info->server_name).contains("PipeWire"); + + updateDefaultDevices(); + + Q_EMIT updated(); +} + +template +static Type *findByName(const Map &map, const QString &name) +{ + Type *out = nullptr; + if (name.isEmpty()) { + return out; + } + QMapIterator it(map); + while (it.hasNext()) { + it.next(); + out = it.value(); + if (out->name() == name) { + return out; + } + } + qCWarning(PLASMAPA) << "No object for name" << name; + return out; +} + +void Server::updateDefaultDevices() +{ + Sink *sink = findByName(Context::instance()->sinks().data(), m_defaultSinkName); + Source *source = findByName(Context::instance()->sources().data(), m_defaultSourceName); + + if (m_defaultSink != sink) { + qCDebug(PLASMAPA) << "Default sink changed" << sink; + m_defaultSink = sink; + Q_EMIT defaultSinkChanged(m_defaultSink); + } + + if (m_defaultSource != source) { + qCDebug(PLASMAPA) << "Default source changed" << source; + m_defaultSource = source; + Q_EMIT defaultSourceChanged(m_defaultSource); + } +} + +bool Server::isPipeWire() const +{ + return m_isPipeWire; +} + +} // QPulseAudio diff --git a/audio/server.h b/audio/server.h new file mode 100644 index 0000000..39091fb --- /dev/null +++ b/audio/server.h @@ -0,0 +1,54 @@ +/* + SPDX-FileCopyrightText: 2016 David Rosca + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef SERVER_H +#define SERVER_H + +#include +#include + +namespace QPulseAudio +{ +class Sink; +class Source; +class Context; + +class Server : public QObject +{ + Q_OBJECT + +public: + explicit Server(Context *context); + + Sink *defaultSink() const; + void setDefaultSink(Sink *sink); + + Source *defaultSource() const; + void setDefaultSource(Source *source); + + void reset(); + void update(const pa_server_info *info); + + bool isPipeWire() const; + +Q_SIGNALS: + void defaultSinkChanged(Sink *sink); + void defaultSourceChanged(Source *source); + void updated(); + +private: + void updateDefaultDevices(); + + QString m_defaultSinkName; + QString m_defaultSourceName; + Sink *m_defaultSink; + Source *m_defaultSource; + bool m_isPipeWire; +}; + +} // QPulseAudio + +#endif // CONTEXT_H diff --git a/audio/sink.cpp b/audio/sink.cpp new file mode 100644 index 0000000..1badc77 --- /dev/null +++ b/audio/sink.cpp @@ -0,0 +1,89 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "sink.h" + +#include "context.h" +#include "server.h" +#include "sinkinput.h" + +namespace QPulseAudio +{ +Sink::Sink(QObject *parent) + : Device(parent) +{ + connect(context()->server(), &Server::defaultSinkChanged, this, &Sink::defaultChanged); +} + +Sink::~Sink() +{ +} + +void Sink::update(const pa_sink_info *info) +{ + updateDevice(info); + if (m_monitorIndex != info->monitor_source) { + m_monitorIndex = info->monitor_source; + Q_EMIT monitorIndexChanged(); + } +} + +void Sink::setVolume(qint64 volume) +{ + context()->setGenericVolume(index(), -1, volume, cvolume(), &pa_context_set_sink_volume_by_index); +} + +void Sink::setMuted(bool muted) +{ + context()->setGenericMute(m_index, muted, &pa_context_set_sink_mute_by_index); +} + +void Sink::setActivePortIndex(quint32 port_index) +{ + Port *port = qobject_cast(ports().at(port_index)); + if (!port) { + qCWarning(PLASMAPA) << "invalid port set request" << port_index; + return; + } + context()->setGenericPort(index(), port->name(), &pa_context_set_sink_port_by_index); +} + +void Sink::setChannelVolume(int channel, qint64 volume) +{ + context()->setGenericVolume(index(), channel, volume, cvolume(), &pa_context_set_sink_volume_by_index); +} + +void Sink::setChannelVolumes(const QVector &channelVolumes) +{ + context()->setGenericVolumes(index(), channelVolumes, cvolume(), &pa_context_set_sink_volume_by_index); +} + +bool Sink::isDefault() const +{ + return context()->server()->defaultSink() == this; +} + +void Sink::setDefault(bool enable) +{ + if (!isDefault() && enable) { + context()->server()->setDefaultSink(this); + } +} + +void Sink::switchStreams() +{ + auto data = context()->sinkInputs().data(); + std::for_each(data.begin(), data.end(), [this](SinkInput *paObj) { + paObj->setDeviceIndex(m_index); + }); +} + +quint32 Sink::monitorIndex() const +{ + return m_monitorIndex; +} + +} // QPulseAudio diff --git a/audio/sink.h b/audio/sink.h new file mode 100644 index 0000000..6497a79 --- /dev/null +++ b/audio/sink.h @@ -0,0 +1,45 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef SINK_H +#define SINK_H + +#include "device.h" +#include + +namespace QPulseAudio +{ +class Sink : public Device +{ + Q_OBJECT +public: + explicit Sink(QObject *parent); + virtual ~Sink(); + + void update(const pa_sink_info *info); + void setVolume(qint64 volume) override; + void setMuted(bool muted) override; + void setActivePortIndex(quint32 port_index) override; + void setChannelVolume(int channel, qint64 volume) override; + void setChannelVolumes(const QVector &channelVolumes) override; + + bool isDefault() const override; + void setDefault(bool enable) override; + + void switchStreams() override; + + quint32 monitorIndex() const; + +Q_SIGNALS: + void monitorIndexChanged(); + +private: + quint32 m_monitorIndex = -1; +}; + +} // QPulseAudio + +#endif // SINK_H diff --git a/audio/sinkinput.cpp b/audio/sinkinput.cpp new file mode 100644 index 0000000..51ebc05 --- /dev/null +++ b/audio/sinkinput.cpp @@ -0,0 +1,54 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "sinkinput.h" + +#include "context.h" + +#include "sink.h" + +namespace QPulseAudio +{ +SinkInput::SinkInput(QObject *parent) + : Stream(parent) +{ +} + +void SinkInput::update(const pa_sink_input_info *info) +{ + updateStream(info); + if (m_deviceIndex != info->sink) { + m_deviceIndex = info->sink; + Q_EMIT deviceIndexChanged(); + } +} + +void SinkInput::setDeviceIndex(quint32 deviceIndex) +{ + context()->setGenericDeviceForStream(index(), deviceIndex, &pa_context_move_sink_input_by_index); +} + +void SinkInput::setVolume(qint64 volume) +{ + context()->setGenericVolume(index(), -1, volume, cvolume(), &pa_context_set_sink_input_volume); +} + +void SinkInput::setMuted(bool muted) +{ + context()->setGenericMute(index(), muted, &pa_context_set_sink_input_mute); +} + +void SinkInput::setChannelVolume(int channel, qint64 volume) +{ + context()->setGenericVolume(index(), channel, volume, cvolume(), &pa_context_set_sink_input_volume); +} + +void SinkInput::setChannelVolumes(const QVector &channelVolumes) +{ + context()->setGenericVolumes(index(), channelVolumes, cvolume(), &pa_context_set_sink_input_volume); +} + +} // QPulseAudio diff --git a/audio/sinkinput.h b/audio/sinkinput.h new file mode 100644 index 0000000..af10c5d --- /dev/null +++ b/audio/sinkinput.h @@ -0,0 +1,31 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef SINKINPUT_H +#define SINKINPUT_H + +#include "stream.h" + +namespace QPulseAudio +{ +class SinkInput : public Stream +{ + Q_OBJECT +public: + explicit SinkInput(QObject *parent); + + void update(const pa_sink_input_info *info); + + void setVolume(qint64 volume) override; + void setMuted(bool muted) override; + void setChannelVolume(int channel, qint64 volume) override; + void setChannelVolumes(const QVector &channelVolumes) override; + void setDeviceIndex(quint32 deviceIndex) override; +}; + +} // QPulseAudio + +#endif // SINKINPUT_H diff --git a/audio/source.cpp b/audio/source.cpp new file mode 100644 index 0000000..05ed4f9 --- /dev/null +++ b/audio/source.cpp @@ -0,0 +1,76 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "source.h" + +#include "context.h" +#include "server.h" +#include "sourceoutput.h" + +namespace QPulseAudio +{ +Source::Source(QObject *parent) + : Device(parent) +{ + connect(context()->server(), &Server::defaultSourceChanged, this, &Source::defaultChanged); +} + +void Source::update(const pa_source_info *info) +{ + updateDevice(info); +} + +void Source::setVolume(qint64 volume) +{ + context()->setGenericVolume(index(), -1, volume, cvolume(), &pa_context_set_source_volume_by_index); +} + +void Source::setMuted(bool muted) +{ + context()->setGenericMute(index(), muted, &pa_context_set_source_mute_by_index); +} + +void Source::setActivePortIndex(quint32 port_index) +{ + Port *port = qobject_cast(ports().at(port_index)); + if (!port) { + qCWarning(PLASMAPA) << "invalid port set request" << port_index; + return; + } + context()->setGenericPort(index(), port->name(), &pa_context_set_source_port_by_index); +} + +void Source::setChannelVolume(int channel, qint64 volume) +{ + context()->setGenericVolume(index(), channel, volume, cvolume(), &pa_context_set_source_volume_by_index); +} + +void Source::setChannelVolumes(const QVector &volumes) +{ + context()->setGenericVolumes(index(), volumes, cvolume(), &pa_context_set_source_volume_by_index); +} + +bool Source::isDefault() const +{ + return context()->server()->defaultSource() == this; +} + +void Source::setDefault(bool enable) +{ + if (!isDefault() && enable) { + context()->server()->setDefaultSource(this); + } +} + +void Source::switchStreams() +{ + auto data = context()->sourceOutputs().data(); + std::for_each(data.begin(), data.end(), [this](SourceOutput *paObj) { + paObj->setDeviceIndex(m_index); + }); +} + +} // QPulseAudio diff --git a/audio/source.h b/audio/source.h new file mode 100644 index 0000000..bb530b7 --- /dev/null +++ b/audio/source.h @@ -0,0 +1,35 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef SOURCE_H +#define SOURCE_H + +#include "device.h" + +namespace QPulseAudio +{ +class Source : public Device +{ + Q_OBJECT +public: + explicit Source(QObject *parent); + + void update(const pa_source_info *info); + void setVolume(qint64 volume) override; + void setMuted(bool muted) override; + void setActivePortIndex(quint32 port_index) override; + void setChannelVolume(int channel, qint64 volume) override; + void setChannelVolumes(const QVector &volumes) override; + + bool isDefault() const override; + void setDefault(bool enable) override; + + void switchStreams() override; +}; + +} // QPulseAudio + +#endif // SOURCE_H diff --git a/audio/sourceoutput.cpp b/audio/sourceoutput.cpp new file mode 100644 index 0000000..eedb778 --- /dev/null +++ b/audio/sourceoutput.cpp @@ -0,0 +1,52 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "sourceoutput.h" + +#include "context.h" + +namespace QPulseAudio +{ +SourceOutput::SourceOutput(QObject *parent) + : Stream(parent) +{ +} + +void SourceOutput::update(const pa_source_output_info *info) +{ + updateStream(info); + if (m_deviceIndex != info->source) { + m_deviceIndex = info->source; + Q_EMIT deviceIndexChanged(); + } +} + +void SourceOutput::setDeviceIndex(quint32 deviceIndex) +{ + context()->setGenericDeviceForStream(index(), deviceIndex, &pa_context_move_source_output_by_index); +} + +void SourceOutput::setVolume(qint64 volume) +{ + context()->setGenericVolume(index(), -1, volume, cvolume(), &pa_context_set_source_output_volume); +} + +void SourceOutput::setMuted(bool muted) +{ + context()->setGenericMute(index(), muted, &pa_context_set_source_output_mute); +} + +void SourceOutput::setChannelVolume(int channel, qint64 volume) +{ + context()->setGenericVolume(index(), channel, volume, cvolume(), &pa_context_set_source_output_volume); +} + +void SourceOutput::setChannelVolumes(const QVector &channelVolumes) +{ + context()->setGenericVolumes(index(), channelVolumes, cvolume(), &pa_context_set_source_output_volume); +} + +} // QPulseAudio diff --git a/audio/sourceoutput.h b/audio/sourceoutput.h new file mode 100644 index 0000000..ef3d4ef --- /dev/null +++ b/audio/sourceoutput.h @@ -0,0 +1,31 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef SOURCEOUTPUT_H +#define SOURCEOUTPUT_H + +#include "stream.h" + +namespace QPulseAudio +{ +class SourceOutput : public Stream +{ + Q_OBJECT +public: + explicit SourceOutput(QObject *parent); + + void update(const pa_source_output_info *info); + + void setVolume(qint64 volume) override; + void setMuted(bool muted) override; + void setChannelVolume(int channel, qint64 volume) override; + void setChannelVolumes(const QVector &channelVolumes) override; + void setDeviceIndex(quint32 deviceIndex) override; +}; + +} // QPulseAudio + +#endif // SOURCEOUTPUT_H diff --git a/audio/speakertest.cpp b/audio/speakertest.cpp new file mode 100644 index 0000000..7291c47 --- /dev/null +++ b/audio/speakertest.cpp @@ -0,0 +1,60 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + SPDX-FileCopyrightText: 2021 Nicolas Fella + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "speakertest.h" + +#include "canberracontext.h" + +QPulseAudio::Sink *SpeakerTest::sink() const +{ + return m_sink; +} + +void SpeakerTest::setSink(QPulseAudio::Sink *sink) +{ + if (m_sink != sink) { + m_sink = sink; + Q_EMIT sinkChanged(); + } +} + +void SpeakerTest::testChannel(const QString &name) +{ + auto context = QPulseAudio::CanberraContext::instance()->canberra(); + if (!context) { + return; + } + + ca_context_set_driver(context, "pulse"); + + char dev[64]; + snprintf(dev, sizeof(dev), "%lu", (unsigned long)m_sink->index()); + ca_context_change_device(context, dev); + + QString sound_name = QStringLiteral("audio-channel-") + name; + ca_proplist *proplist; + ca_proplist_create(&proplist); + + ca_proplist_sets(proplist, CA_PROP_MEDIA_ROLE, "test"); + ca_proplist_sets(proplist, CA_PROP_MEDIA_NAME, name.toLatin1().constData()); + ca_proplist_sets(proplist, CA_PROP_CANBERRA_FORCE_CHANNEL, name.toLatin1().data()); + ca_proplist_sets(proplist, CA_PROP_CANBERRA_ENABLE, "1"); + + ca_proplist_sets(proplist, CA_PROP_EVENT_ID, sound_name.toLatin1().data()); + if (ca_context_play_full(context, 0, proplist, nullptr, NULL) != CA_SUCCESS) { + // Try a different sound name. + ca_proplist_sets(proplist, CA_PROP_EVENT_ID, "audio-test-signal"); + if (ca_context_play_full(context, 0, proplist, nullptr, NULL) != CA_SUCCESS) { + // Finaly try this... if this doesn't work, then stuff it. + ca_proplist_sets(proplist, CA_PROP_EVENT_ID, "bell-window-system"); + ca_context_play_full(context, 0, proplist, nullptr, NULL); + } + } + + ca_context_change_device(context, nullptr); + ca_proplist_destroy(proplist); +} diff --git a/audio/speakertest.h b/audio/speakertest.h new file mode 100644 index 0000000..6a33bf7 --- /dev/null +++ b/audio/speakertest.h @@ -0,0 +1,27 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + SPDX-FileCopyrightText: 2021 Nicolas Fella + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#pragma once + +#include "sink.h" + +#include + +class SpeakerTest : public QObject +{ + Q_OBJECT + Q_PROPERTY(QPulseAudio::Sink *sink READ sink WRITE setSink NOTIFY sinkChanged) +public: + QPulseAudio::Sink *sink() const; + void setSink(QPulseAudio::Sink *sink); + Q_SIGNAL void sinkChanged(); + + Q_INVOKABLE void testChannel(const QString &name); + +private: + QPulseAudio::Sink *m_sink; +}; diff --git a/audio/stream.cpp b/audio/stream.cpp new file mode 100644 index 0000000..892fd06 --- /dev/null +++ b/audio/stream.cpp @@ -0,0 +1,51 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "stream.h" + +namespace QPulseAudio +{ +Stream::Stream(QObject *parent) + : VolumeObject(parent) + , m_deviceIndex(PA_INVALID_INDEX) + , m_clientIndex(PA_INVALID_INDEX) + , m_virtualStream(false) + , m_corked(false) +{ + m_volumeWritable = false; + m_hasVolume = false; +} + +Stream::~Stream() +{ +} + +QString Stream::name() const +{ + return m_name; +} + +Client *Stream::client() const +{ + return context()->clients().data().value(m_clientIndex, nullptr); +} + +bool Stream::isVirtualStream() const +{ + return m_virtualStream; +} + +quint32 Stream::deviceIndex() const +{ + return m_deviceIndex; +} + +bool Stream::isCorked() const +{ + return m_corked; +} + +} // QPulseAudio diff --git a/audio/stream.h b/audio/stream.h new file mode 100644 index 0000000..44a2482 --- /dev/null +++ b/audio/stream.h @@ -0,0 +1,93 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef STREAM_H +#define STREAM_H + +#include + +#include + +#include "pulseobject.h" +#include "volumeobject.h" + +#include "context.h" +// Properties need fully qualified classes even with pointers. +#include "client.h" + +namespace QPulseAudio +{ +class Stream : public VolumeObject +{ + Q_OBJECT + Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(QPulseAudio::Client *client READ client NOTIFY clientChanged) + Q_PROPERTY(bool virtualStream READ isVirtualStream NOTIFY virtualStreamChanged) + Q_PROPERTY(quint32 deviceIndex READ deviceIndex WRITE setDeviceIndex NOTIFY deviceIndexChanged) + Q_PROPERTY(bool corked READ isCorked NOTIFY corkedChanged) +public: + template + void updateStream(const PAInfo *info) + { + updateVolumeObject(info); + + if (m_name != QString::fromUtf8(info->name)) { + m_name = QString::fromUtf8(info->name); + Q_EMIT nameChanged(); + } + if (m_hasVolume != info->has_volume) { + m_hasVolume = info->has_volume; + Q_EMIT hasVolumeChanged(); + } + if (m_volumeWritable != info->volume_writable) { + m_volumeWritable = info->volume_writable; + Q_EMIT isVolumeWritableChanged(); + } + if (m_clientIndex != info->client) { + m_clientIndex = info->client; + Q_EMIT clientChanged(); + } + if (m_virtualStream != (info->client == PA_INVALID_INDEX)) { + m_virtualStream = info->client == PA_INVALID_INDEX; + Q_EMIT virtualStreamChanged(); + } + if (m_corked != info->corked) { + m_corked = info->corked; + Q_EMIT corkedChanged(); + } + } + + QString name() const; + Client *client() const; + bool isVirtualStream() const; + quint32 deviceIndex() const; + bool isCorked() const; + + virtual void setDeviceIndex(quint32 deviceIndex) = 0; + +Q_SIGNALS: + void nameChanged(); + void clientChanged(); + void virtualStreamChanged(); + void deviceIndexChanged(); + void corkedChanged(); + +protected: + explicit Stream(QObject *parent); + ~Stream() override; + + quint32 m_deviceIndex; + +private: + QString m_name; + quint32 m_clientIndex; + bool m_virtualStream; + bool m_corked; +}; + +} // QPulseAudio + +#endif // STREAM_H diff --git a/audio/streammeter.h b/audio/streammeter.h new file mode 100644 index 0000000..e69de29 diff --git a/audio/streamrestore.cpp b/audio/streamrestore.cpp new file mode 100644 index 0000000..2e2449a --- /dev/null +++ b/audio/streamrestore.cpp @@ -0,0 +1,200 @@ +/* + SPDX-FileCopyrightText: 2016 David Rosca + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "streamrestore.h" +#include "context.h" +#include "debug.h" + +namespace QPulseAudio +{ +StreamRestore::StreamRestore(quint32 index, const QVariantMap &properties, QObject *parent) + : PulseObject(parent) + , m_muted(false) +{ + memset(&m_volume, 0, sizeof(m_volume)); + memset(&m_channelMap, 0, sizeof(m_channelMap)); + + m_index = index; + m_properties = properties; +} + +void StreamRestore::update(const pa_ext_stream_restore_info *info) +{ + m_cache.valid = false; + const QString infoName = QString::fromUtf8(info->name); + if (m_name != infoName) { + m_name = infoName; + Q_EMIT nameChanged(); + } + const QString infoDevice = QString::fromUtf8(info->device); + if (m_device != infoDevice) { + m_device = infoDevice; + Q_EMIT deviceChanged(); + } + if (m_muted != info->mute) { + m_muted = info->mute; + Q_EMIT mutedChanged(); + } + if (!pa_cvolume_equal(&m_volume, &info->volume)) { + m_volume = info->volume; + Q_EMIT volumeChanged(); + Q_EMIT channelVolumesChanged(); + } + if (!pa_channel_map_equal(&m_channelMap, &info->channel_map)) { + m_channels.clear(); + m_channels.reserve(info->channel_map.channels); + for (int i = 0; i < info->channel_map.channels; ++i) { + m_channels << QString::fromUtf8(pa_channel_position_to_pretty_string(info->channel_map.map[i])); + } + m_channelMap = info->channel_map; + Q_EMIT channelsChanged(); + } +} + +QString StreamRestore::name() const +{ + return m_name; +} + +QString StreamRestore::device() const +{ + return m_device; +} + +void StreamRestore::setDevice(const QString &device) +{ + if (m_cache.valid) { + if (m_cache.device != device) { + writeChanges(m_cache.volume, m_cache.muted, device); + } + } else { + if (m_device != device) { + writeChanges(m_volume, m_muted, device); + } + } +} + +qint64 StreamRestore::volume() const +{ + return m_volume.values[0]; +} + +void StreamRestore::setVolume(qint64 volume) +{ + pa_cvolume vol = m_cache.valid ? m_cache.volume : m_volume; + + // If no channel exists force one. We need one to be able to control the volume + // See https://bugs.kde.org/show_bug.cgi?id=407397 + if (vol.channels == 0) { + vol.channels = 1; + } + + for (int i = 0; i < vol.channels; ++i) { + vol.values[i] = volume; + } + + if (m_cache.valid) { + writeChanges(vol, m_cache.muted, m_cache.device); + } else { + writeChanges(vol, m_muted, m_device); + } +} + +bool StreamRestore::isMuted() const +{ + return m_muted; +} + +void StreamRestore::setMuted(bool muted) +{ + if (m_cache.valid) { + if (m_cache.muted != muted) { + writeChanges(m_cache.volume, muted, m_cache.device); + } + } else { + if (m_muted != muted) { + writeChanges(m_volume, muted, m_device); + } + } +} + +bool StreamRestore::hasVolume() const +{ + return true; +} + +bool StreamRestore::isVolumeWritable() const +{ + return true; +} + +QStringList StreamRestore::channels() const +{ + return m_channels; +} + +QList StreamRestore::channelVolumes() const +{ + QList ret; + ret.reserve(m_volume.channels); + for (int i = 0; i < m_volume.channels; ++i) { + ret << m_volume.values[i]; + } + return ret; +} + +void StreamRestore::setChannelVolume(int channel, qint64 volume) +{ + Q_ASSERT(channel >= 0 && channel < m_volume.channels); + pa_cvolume vol = m_cache.valid ? m_cache.volume : m_volume; + vol.values[channel] = volume; + + if (m_cache.valid) { + writeChanges(vol, m_cache.muted, m_cache.device); + } else { + writeChanges(vol, m_muted, m_device); + } +} + +quint32 StreamRestore::deviceIndex() const +{ + return PA_INVALID_INDEX; +} + +void StreamRestore::setDeviceIndex(quint32 deviceIndex) +{ + Q_UNUSED(deviceIndex); + qCWarning(PLASMAPA) << "Not implemented"; +} + +void StreamRestore::writeChanges(const pa_cvolume &volume, bool muted, const QString &device) +{ + const QByteArray nameData = m_name.toUtf8(); + const QByteArray deviceData = device.toUtf8(); + + pa_ext_stream_restore_info info; + info.name = nameData.constData(); + info.channel_map = m_channelMap; + info.volume = volume; + info.device = deviceData.isEmpty() ? nullptr : deviceData.constData(); + info.mute = muted; + + // If no channel exists force one. We need one to be able to control the volume + // See https://bugs.kde.org/show_bug.cgi?id=407397 + if (info.channel_map.channels == 0) { + info.channel_map.channels = 1; + info.channel_map.map[0] = PA_CHANNEL_POSITION_MONO; + } + + m_cache.valid = true; + m_cache.volume = volume; + m_cache.muted = muted; + m_cache.device = device; + + context()->streamRestoreWrite(&info); +} + +} // QPulseAudio diff --git a/audio/streamrestore.h b/audio/streamrestore.h new file mode 100644 index 0000000..d8c0d1e --- /dev/null +++ b/audio/streamrestore.h @@ -0,0 +1,85 @@ +/* + SPDX-FileCopyrightText: 2016 David Rosca + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef STREAMRESTORE_H +#define STREAMRESTORE_H + +#include "pulseobject.h" + +#include + +namespace QPulseAudio +{ +class StreamRestore : public PulseObject +{ + Q_OBJECT + Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(QString device READ device WRITE setDevice NOTIFY deviceChanged) + Q_PROPERTY(qint64 volume READ volume WRITE setVolume NOTIFY volumeChanged) + Q_PROPERTY(bool muted READ isMuted WRITE setMuted NOTIFY mutedChanged) + Q_PROPERTY(bool hasVolume READ hasVolume CONSTANT) + Q_PROPERTY(bool volumeWritable READ isVolumeWritable CONSTANT) + Q_PROPERTY(QStringList channels READ channels NOTIFY channelsChanged) + Q_PROPERTY(QList channelVolumes READ channelVolumes NOTIFY channelVolumesChanged) + Q_PROPERTY(quint32 deviceIndex READ deviceIndex WRITE setDeviceIndex NOTIFY deviceIndexChanged) +public: + StreamRestore(quint32 index, const QVariantMap &properties, QObject *parent); + + void update(const pa_ext_stream_restore_info *info); + + QString name() const; + + QString device() const; + void setDevice(const QString &device); + + qint64 volume() const; + void setVolume(qint64 volume); + + bool isMuted() const; + void setMuted(bool muted); + + bool hasVolume() const; + bool isVolumeWritable() const; + + QStringList channels() const; + + QList channelVolumes() const; + + quint32 deviceIndex() const; + void setDeviceIndex(quint32 deviceIndex); + + Q_INVOKABLE void setChannelVolume(int channel, qint64 volume); + +Q_SIGNALS: + void nameChanged(); + void deviceChanged(); + void volumeChanged(); + void mutedChanged(); + void channelsChanged(); + void channelVolumesChanged(); + void deviceIndexChanged(); + +private: + void writeChanges(const pa_cvolume &volume, bool muted, const QString &device); + + QString m_name; + QString m_device; + pa_cvolume m_volume; + pa_channel_map m_channelMap; + QStringList m_channels; + bool m_muted; + + struct { + bool valid = false; + pa_cvolume volume; + bool muted; + QString device; + } m_cache; +}; + +} // QPulseAudio + +#endif // STREAMRESTORE_H diff --git a/audio/volumemonitor.cpp b/audio/volumemonitor.cpp new file mode 100644 index 0000000..ca64f4c --- /dev/null +++ b/audio/volumemonitor.cpp @@ -0,0 +1,195 @@ +/* + SPDX-FileCopyrightText: 2020 David Edmundson + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "volumemonitor.h" + +#include + +#include "context.h" +#include "debug.h" +#include "sink.h" +#include "sinkinput.h" +#include "source.h" +#include "sourceoutput.h" +#include "volumeobject.h" + +#include + +using namespace QPulseAudio; + +VolumeMonitor::VolumeMonitor(QObject *parent) + : QObject(parent) +{ + Context::instance()->ref(); +} + +VolumeMonitor::~VolumeMonitor() +{ + setTarget(nullptr); + Context::instance()->unref(); +} + +bool VolumeMonitor::isAvailable() const +{ + return m_stream != nullptr; +} + +void VolumeMonitor::updateVolume(qreal volume) +{ + // qFuzzyCompare cannot compare against 0. + if (qFuzzyCompare(1 + m_volume, 1 + volume)) { + return; + } + + m_volume = volume; + Q_EMIT volumeChanged(); +} + +QPulseAudio::VolumeObject *QPulseAudio::VolumeMonitor::target() const +{ + return m_target; +} + +void QPulseAudio::VolumeMonitor::setTarget(QPulseAudio::VolumeObject *target) +{ + if (target == m_target) { + return; + } + + if (m_stream) { + pa_stream_set_read_callback(m_stream, nullptr, nullptr); + pa_stream_set_suspended_callback(m_stream, nullptr, nullptr); + if (pa_stream_get_state(m_stream) == PA_STREAM_CREATING) { + pa_stream_set_state_callback( + m_stream, + [](pa_stream *s, void *) { + pa_stream_disconnect(s); + pa_stream_set_state_callback(s, nullptr, nullptr); + }, + nullptr); + } else { + pa_stream_disconnect(m_stream); + } + pa_stream_unref(m_stream); + m_stream = nullptr; + Q_EMIT availableChanged(); + } + + m_target = target; + + if (target) { + connect(target, &QObject::destroyed, this, [this] { + setTarget(nullptr); + }); + createStream(); + } + + Q_EMIT targetChanged(); +} + +void VolumeMonitor::createStream() +{ + Q_ASSERT(!m_stream); + + uint32_t sourceIdx = PA_INVALID_INDEX; + uint32_t streamIdx = PA_INVALID_INDEX; + + if (auto *sinkInput = qobject_cast(m_target)) { + Sink *sink = Context::instance()->sinks().data().value(sinkInput->deviceIndex()); + if (sink) { + sourceIdx = sink->monitorIndex(); + } + streamIdx = sinkInput->index(); + } else if (auto *sourceOutput = qobject_cast(m_target)) { + sourceIdx = sourceOutput->deviceIndex(); + streamIdx = sourceOutput->index(); + } else if (auto *sink = qobject_cast(m_target)) { + sourceIdx = sink->monitorIndex(); + } else if (auto *source = qobject_cast(m_target)) { + sourceIdx = source->index(); + } else { + Q_UNREACHABLE(); + } + + if (sourceIdx == PA_INVALID_INDEX) { + return; + } + + char t[16]; + pa_buffer_attr attr; + pa_sample_spec ss; + pa_stream_flags_t flags; + + ss.channels = 1; + ss.format = PA_SAMPLE_FLOAT32; + ss.rate = 25; + + memset(&attr, 0, sizeof(attr)); + attr.fragsize = sizeof(float); + attr.maxlength = (uint32_t)-1; + + snprintf(t, sizeof(t), "%u", sourceIdx); + + if (!(m_stream = pa_stream_new(Context::instance()->context(), "PlasmaPA-VolumeMeter", &ss, nullptr))) { + qCWarning(PLASMAPA) << "Failed to create stream"; + return; + } + + if (streamIdx != PA_INVALID_INDEX) { + pa_stream_set_monitor_stream(m_stream, streamIdx); + } + + pa_stream_set_read_callback(m_stream, read_callback, this); + pa_stream_set_suspended_callback(m_stream, suspended_callback, this); + + flags = (pa_stream_flags_t)(PA_STREAM_DONT_MOVE | PA_STREAM_PEAK_DETECT | PA_STREAM_ADJUST_LATENCY); + + if (pa_stream_connect_record(m_stream, t, &attr, flags) < 0) { + pa_stream_unref(m_stream); + m_stream = nullptr; + return; + } + Q_EMIT availableChanged(); +} + +void VolumeMonitor::suspended_callback(pa_stream *s, void *userdata) +{ + VolumeMonitor *w = static_cast(userdata); + if (pa_stream_is_suspended(s)) { + w->updateVolume(-1); + } +} + +void VolumeMonitor::read_callback(pa_stream *s, size_t length, void *userdata) +{ + VolumeMonitor *w = static_cast(userdata); + const void *data; + double volume; + + if (pa_stream_peek(s, &data, &length) < 0) { + qCWarning(PLASMAPA) << "Failed to read data from stream"; + return; + } + + if (!data) { + /* nullptr data means either a hole or empty buffer. + * Only drop the stream when there is a hole (length > 0) */ + if (length) { + pa_stream_drop(s); + } + return; + } + + Q_ASSERT(length > 0); + Q_ASSERT(length % sizeof(float) == 0); + + volume = ((const float *)data)[length / sizeof(float) - 1]; + + pa_stream_drop(s); + + volume = qBound(0.0, volume, 1.0); + w->updateVolume(volume); +} diff --git a/audio/volumemonitor.h b/audio/volumemonitor.h new file mode 100644 index 0000000..29fd5a6 --- /dev/null +++ b/audio/volumemonitor.h @@ -0,0 +1,68 @@ +/* + SPDX-FileCopyrightText: 2020 David Edmundson + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#pragma once + +#include +#include +#include + +struct pa_stream; + +namespace QPulseAudio +{ +class VolumeObject; + +/** + * This class provides a way to see the "peak" volume currently playing of any VolumeObject + */ +class VolumeMonitor : public QObject +{ + Q_OBJECT + /** + * Object to monitor the volume of + * This is the "PulseObject" role of any SinkInput, Sink or Output model + * Setting to null will stop streaming + */ + Q_PROPERTY(QPulseAudio::VolumeObject *target READ target WRITE setTarget NOTIFY targetChanged) + /** + * The peak output for the volume at any given moment + * Value is normalised between 0 and 1 + */ + Q_PROPERTY(qreal volume MEMBER m_volume NOTIFY volumeChanged) + + /** + * Whether monitoring is available + */ + Q_PROPERTY(bool available READ isAvailable NOTIFY availableChanged) + +public: + VolumeMonitor(QObject *parent = nullptr); + ~VolumeMonitor(); + + bool isAvailable() const; + + VolumeObject *target() const; + void setTarget(VolumeObject *target); + +Q_SIGNALS: + void volumeChanged(); + void targetChanged(); + void availableChanged(); + +private: + void createStream(); + void updateVolume(qreal volume); + static void read_callback(pa_stream *s, size_t length, void *userdata); + static void suspended_callback(pa_stream *s, void *userdata); + + VolumeObject *m_target; + pa_stream *m_stream = nullptr; + + qreal m_volume = 0; +}; + +} diff --git a/audio/volumeobject.cpp b/audio/volumeobject.cpp new file mode 100644 index 0000000..b63a6e9 --- /dev/null +++ b/audio/volumeobject.cpp @@ -0,0 +1,69 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "volumeobject.h" + +namespace QPulseAudio +{ +VolumeObject::VolumeObject(QObject *parent) + : PulseObject(parent) + , m_muted(true) + , m_hasVolume(true) + , m_volumeWritable(true) +{ + pa_cvolume_init(&m_volume); +} + +VolumeObject::~VolumeObject() +{ +} + +qint64 VolumeObject::volume() const +{ + return pa_cvolume_max(&m_volume); +} + +bool VolumeObject::isMuted() const +{ + return m_muted; +} + +pa_cvolume VolumeObject::cvolume() const +{ + return m_volume; +} + +bool VolumeObject::hasVolume() const +{ + return m_hasVolume; +} + +bool VolumeObject::isVolumeWritable() const +{ + return m_volumeWritable; +} + +QStringList VolumeObject::channels() const +{ + return m_channels; +} + +QStringList VolumeObject::rawChannels() const +{ + return m_rawChannels; +} + +QVector VolumeObject::channelVolumes() const +{ + QVector ret; + ret.reserve(m_volume.channels); + for (int i = 0; i < m_volume.channels; ++i) { + ret << m_volume.values[i]; + } + return ret; +} + +} // QPulseAudio diff --git a/audio/volumeobject.h b/audio/volumeobject.h new file mode 100644 index 0000000..ca8903a --- /dev/null +++ b/audio/volumeobject.h @@ -0,0 +1,102 @@ +/* + SPDX-FileCopyrightText: 2014-2015 Harald Sitter + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef VOLUMEOBJECT_H +#define VOLUMEOBJECT_H + +#include + +#include "pulseobject.h" + +namespace QPulseAudio +{ +class VolumeObject : public PulseObject +{ + Q_OBJECT + Q_PROPERTY(qint64 volume READ volume WRITE setVolume NOTIFY volumeChanged) + Q_PROPERTY(bool muted READ isMuted WRITE setMuted NOTIFY mutedChanged) + Q_PROPERTY(bool hasVolume READ hasVolume NOTIFY hasVolumeChanged) + Q_PROPERTY(bool volumeWritable READ isVolumeWritable NOTIFY isVolumeWritableChanged) + Q_PROPERTY(QStringList channels READ channels NOTIFY channelsChanged) + Q_PROPERTY(QStringList rawChannels READ rawChannels NOTIFY rawChannelsChanged) + Q_PROPERTY(QVector channelVolumes READ channelVolumes WRITE setChannelVolumes NOTIFY channelVolumesChanged) +public: + explicit VolumeObject(QObject *parent); + ~VolumeObject() override; + + template + void updateVolumeObject(PAInfo *info) + { + updatePulseObject(info); + if (m_muted != info->mute) { + m_muted = info->mute; + Q_EMIT mutedChanged(); + } + if (!pa_cvolume_equal(&m_volume, &info->volume)) { + m_volume = info->volume; + Q_EMIT volumeChanged(); + Q_EMIT channelVolumesChanged(); + } + QStringList infoChannels; + infoChannels.reserve(info->channel_map.channels); + for (int i = 0; i < info->channel_map.channels; ++i) { + infoChannels << QString::fromUtf8(pa_channel_position_to_pretty_string(info->channel_map.map[i])); + } + if (m_channels != infoChannels) { + m_channels = infoChannels; + Q_EMIT channelsChanged(); + } + + QStringList infoRawChannels; + infoRawChannels.reserve(info->channel_map.channels); + for (int i = 0; i < info->channel_map.channels; ++i) { + infoRawChannels << QString::fromUtf8(pa_channel_position_to_string(info->channel_map.map[i])); + } + if (m_rawChannels != infoRawChannels) { + m_rawChannels = infoRawChannels; + Q_EMIT rawChannelsChanged(); + } + } + + qint64 volume() const; + virtual void setVolume(qint64 volume) = 0; + + bool isMuted() const; + virtual void setMuted(bool muted) = 0; + + bool hasVolume() const; + bool isVolumeWritable() const; + + QStringList channels() const; + QStringList rawChannels() const; + + QVector channelVolumes() const; + virtual void setChannelVolumes(const QVector &channelVolumes) = 0; + Q_INVOKABLE virtual void setChannelVolume(int channel, qint64 volume) = 0; + +Q_SIGNALS: + void volumeChanged(); + void mutedChanged(); + void hasVolumeChanged(); + void isVolumeWritableChanged(); + void channelsChanged(); + void rawChannelsChanged(); + void channelVolumesChanged(); + +protected: + pa_cvolume cvolume() const; + + pa_cvolume m_volume; + bool m_muted; + bool m_hasVolume; + bool m_volumeWritable; + QStringList m_channels; + QStringList m_rawChannels; +}; + +} // QPulseAudio + +#endif // VOLUMEOBJECT_H diff --git a/cmake/FindCanberra.cmake b/cmake/FindCanberra.cmake new file mode 100644 index 0000000..c54adf9 --- /dev/null +++ b/cmake/FindCanberra.cmake @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: 2012 Raphael Kubo da Costa +# SPDX-FileCopyrightText: 2019 Harald Sitter +# +# SPDX-License-Identifier: BSD-3-Clause + +#[=======================================================================[.rst: +FindCanberra +------------ + +Try to find Canberra event sound library. + +This will define the following variables: + +``Canberra_FOUND`` + True if (the requested version of) Canberra is available +``Canberra_VERSION`` + The version of Canberra +``Canberra_LIBRARIES`` + The libraries of Canberra for use with target_link_libraries() +``Canberra_INCLUDE_DIRS`` + The include dirs of Canberra for use with target_include_directories() + +If ``Canberra_FOUND`` is TRUE, it will also define the following imported +target: + +``Canberra::Canberra`` + The Canberra library + +In general we recommend using the imported target, as it is easier to use. +Bear in mind, however, that if the target is in the link interface of an +exported library, it must be made available by the package config file. + +Since 5.56.0. +#]=======================================================================] + +find_package(PkgConfig QUIET) +pkg_check_modules(PC_Canberra libcanberra QUIET) + +find_library(Canberra_LIBRARIES + NAMES canberra + HINTS ${PC_Canberra_LIBRARY_DIRS} +) + +find_path(Canberra_INCLUDE_DIRS + NAMES canberra.h + HINTS ${PC_Canberra_INCLUDE_DIRS} +) + +set(Canberra_VERSION ${PC_Canberra_VERSION}) + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(Canberra + FOUND_VAR + Canberra_FOUND + REQUIRED_VARS + Canberra_LIBRARIES + Canberra_INCLUDE_DIRS + VERSION_VAR + Canberra_VERSION +) + +if(Canberra_FOUND AND NOT TARGET Canberra::Canberra) + add_library(Canberra::Canberra UNKNOWN IMPORTED) + set_target_properties(Canberra::Canberra PROPERTIES + IMPORTED_LOCATION "${Canberra_LIBRARIES}" + INTERFACE_COMPILE_OPTIONS "${PC_Canberra_CFLAGS}" + INTERFACE_INCLUDE_DIRECTORIES "${Canberra_INCLUDE_DIRS}" + ) +endif() + +mark_as_advanced(Canberra_LIBRARIES Canberra_INCLUDE_DIRS Canberra_VERSION) + +include(FeatureSummary) +set_package_properties(Canberra PROPERTIES + DESCRIPTION "Event sound library" + URL "http://0pointer.de/lennart/projects/libcanberra" +) + +# Compatibility variables. In a previous life FindCanberra lived +# in a number of different repos: don't break them if they use ECM but have not +# been updated for this finder. +set(CANBERRA_FOUND ${Canberra_FOUND}) +set(CANBERRA_VERSION ${Canberra_VERSION}) +set(CANBERRA_LIBRARIES ${Canberra_LIBRARIES}) +set(CANBERRA_INCLUDE_DIRS ${Canberra_INCLUDE_DIRS}) +mark_as_advanced(CANBERRA_VERSION CANBERRA_LIBRARIES CANBERRA_INCLUDE_DIRS) diff --git a/cmake/FindCanberraPulse.cmake b/cmake/FindCanberraPulse.cmake new file mode 100644 index 0000000..3db7684 --- /dev/null +++ b/cmake/FindCanberraPulse.cmake @@ -0,0 +1,24 @@ +# - Find libcanberra's pulseaudio backend. +# This module defines the following variables: +# +# CanberraPulse_FOUND - true if the backend was found +# +# SPDX-FileCopyrightText: 2019 Harald Sitter +# +# SPDX-License-Identifier: BSD-3-Clause + +find_package(Canberra) + +find_library(CanberraPulse_LIBRARY canberra-pulse + PATH_SUFFIXES libcanberra libcanberra-${Canberra_VERSION} +) + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(CanberraPulse + FOUND_VAR CanberraPulse_FOUND + REQUIRED_VARS CanberraPulse_LIBRARY +) +mark_as_advanced(CanberraPulse_LIBRARY) + +# NB: CanberraPulse_LIBRARY is intentionally not documented as it serves no +# public purpose (it's a plugin, not a library). diff --git a/cmake/FindSoundThemeFreedesktop.cmake b/cmake/FindSoundThemeFreedesktop.cmake new file mode 100644 index 0000000..44b7ba9 --- /dev/null +++ b/cmake/FindSoundThemeFreedesktop.cmake @@ -0,0 +1,21 @@ +# - Find sound-theme-freedesktop via XDG_DATA_DIRS +# This module defines the following variables: +# +# SoundThemeFreeDesktop_FOUND - true if the sound theme is found +# SoundThemeFreeDesktop_PATH - path to the index.theme file +# +# SPDX-FileCopyrightText: 2019 Harald Sitter +# +# SPDX-License-Identifier: BSD-3-Clause + +find_file(SoundThemeFreeDesktop_PATH "sounds/freedesktop/index.theme" + PATHS ENV XDG_DATA_DIRS /usr/local/share/ /usr/share/ + NO_DEFAULT_PATH +) + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(SoundThemeFreeDesktop + FOUND_VAR SoundThemeFreeDesktop_FOUND + REQUIRED_VARS SoundThemeFreeDesktop_PATH +) +mark_as_advanced(SoundThemeFreeDesktop_PATH) \ No newline at end of file diff --git a/debian/control b/debian/control index b33fb74..3f303d3 100644 --- a/debian/control +++ b/debian/control @@ -11,6 +11,9 @@ Build-Depends: cmake, libkf5bluezqt-dev, modemmanager-qt-dev, libqt5sensors5-dev, + libcanberra-dev, + libpulse-dev, + sound-theme-freedesktop, qtbase5-dev, qtdeclarative5-dev, qtquickcontrols2-5-dev,