diff --git a/CMakeLists.txt b/CMakeLists.txt --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,6 +33,7 @@ Declarative DocTools GlobalAccel + Notifications I18n Plasma ) diff --git a/applet/contents/ui/main.qml b/applet/contents/ui/main.qml --- a/applet/contents/ui/main.qml +++ b/applet/contents/ui/main.qml @@ -504,4 +504,8 @@ } } } + + Component.onCompleted: { + MicrophoneIndicator.init(); + } } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,3 +1,5 @@ +add_definitions(-DTRANSLATION_DOMAIN=\"kcm_pulseaudio\") + include_directories(${PULSEAUDIO_INCLUDE_DIR} ${GLIB2_INCLUDE_DIR}) set(cpp_SRCS @@ -25,6 +27,7 @@ canberracontext.cpp qml/globalactioncollection.cpp qml/plugin.cpp + qml/microphoneindicator.cpp qml/volumeosd.cpp qml/volumefeedback.cpp ) @@ -54,6 +57,8 @@ Qt5::DBus Qt5::Quick KF5::GlobalAccel + KF5::I18n + KF5::Notifications Canberra::Canberra ${PULSEAUDIO_LIBRARY} ${PULSEAUDIO_MAINLOOP_LIBRARY} diff --git a/src/qml/microphoneindicator.h b/src/qml/microphoneindicator.h new file mode 100644 --- /dev/null +++ b/src/qml/microphoneindicator.h @@ -0,0 +1,86 @@ +/* + Copyright 2019 Kai Uwe Broulik + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) version 3, or any + later version accepted by the membership of KDE e.V. (or its + successor approved by the membership of KDE e.V.), which shall + act as a proxy defined in Section 6 of version 3 of the license. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library. If not, see . +*/ + +#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(); + + QStringList appNames() 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/src/qml/microphoneindicator.cpp b/src/qml/microphoneindicator.cpp new file mode 100644 --- /dev/null +++ b/src/qml/microphoneindicator.cpp @@ -0,0 +1,299 @@ +/* + Copyright 2019 Kai Uwe Broulik + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) version 3, or any + later version accepted by the membership of KDE e.V. (or its + successor approved by the membership of KDE e.V.), which shall + act as a proxy defined in Section 6 of version 3 of the license. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library. If not, see . +*/ + +#include "microphoneindicator.h" + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +#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); + + m_updateTimer->setInterval(0); + m_updateTimer->setSingleShot(true); + connect(m_updateTimer, &QTimer::timeout, this, &MicrophoneIndicator::update); +} + +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() +{ + if (m_sourceOutputModel->rowCount() == 0) { + 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::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"); + } + } + + QStringList names = appNames(); + Q_ASSERT(!names.isEmpty()); + + m_sni->setTitle(i18n("Microphone")); + m_sni->setIconByName(iconName); + + QString tooltip = i18nc("App is using mic", "%1 is using the microphone", names.constFirst()); + if (names.count() > 1) { + tooltip = i18nc("List of apps is using mic", "%1 are using the microphone", names.join(i18nc("list separator", ", "))); + } + + m_sni->setToolTip(QIcon::fromTheme(iconName), i18n("Microphone"), tooltip); + + 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); + static const int s_virtualStreamRole = m_sourceModel->role(QByteArrayLiteral("VirtualStream")); + Q_ASSERT(s_virtualStreamRole > -1); + + for (int row = 0; row < m_sourceModel->rowCount(); ++row) { + const QModelIndex idx = m_sourceModel->index(row); + if (idx.data(s_virtualStreamRole).toBool()) { + qDebug() << "iggen virt"; + continue; + } + + 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); + static const int s_virtualStreamRole = m_sourceModel->role(QByteArrayLiteral("VirtualStream")); + Q_ASSERT(s_virtualStreamRole > -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_virtualStreamRole).toBool()) { + qDebug() << "iggen virt set muted"; + continue; + } + + 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)); + +} + +QStringList MicrophoneIndicator::appNames() const +{ + 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); + + QStringList names; + names.reserve(m_sourceOutputModel->rowCount()); + + for (int i = 0; i < m_sourceOutputModel->rowCount(); ++i) { + const QModelIndex idx = m_sourceOutputModel->index(i); + + Client *client = qobject_cast(idx.data(s_clientRole).value()); + + if (client) { + names.append(client->name()); + } else { + names.append(idx.data(s_nameRole).toString()); + } + } + + return names; +} diff --git a/src/qml/plugin.cpp b/src/qml/plugin.cpp --- a/src/qml/plugin.cpp +++ b/src/qml/plugin.cpp @@ -32,6 +32,7 @@ #include "port.h" #include "globalactioncollection.h" +#include "microphoneindicator.h" #include "volumeosd.h" #include "volumefeedback.h" @@ -63,6 +64,12 @@ qmlRegisterType(uri, 0, 1, "VolumeOSD"); qmlRegisterType(uri, 0, 1, "VolumeFeedback"); qmlRegisterSingletonType(uri, 0, 1, "PulseAudio", pulseaudio_singleton); + qmlRegisterSingletonType(uri, 0, 1, "MicrophoneIndicator", + [](QQmlEngine *engine, QJSEngine *jsEngine) -> QObject* { + Q_UNUSED(engine); + Q_UNUSED(jsEngine); + return new MicrophoneIndicator(); + }); qmlRegisterType(); qmlRegisterType();