File indexing completed on 2024-12-08 13:24:59

0001 /*
0002     SPDX-FileCopyrightText: 2019 Kai Uwe Broulik <kde@privat.broulik.de>
0003     SPDX-FileCopyrightText: 2020 MBition GmbH
0004         Author: Kai Uwe Broulik <kai_uwe.broulik@mbition.io>
0005 
0006     SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
0007 */
0008 
0009 #include "microphoneindicator.h"
0010 
0011 #include <QAction>
0012 #include <QIcon>
0013 #include <QMenu>
0014 #include <QTimer>
0015 
0016 #include <KLocalizedString>
0017 #include <KStatusNotifierItem>
0018 
0019 #include "client.h"
0020 #include "context.h"
0021 #include "pulseaudio.h"
0022 #include "source.h"
0023 
0024 #include "volumeosd.h"
0025 
0026 using namespace QPulseAudio;
0027 
0028 MicrophoneIndicator::MicrophoneIndicator(QObject *parent)
0029     : QObject(parent)
0030     , m_sourceModel(new SourceModel(this))
0031     , m_sourceOutputModel(new SourceOutputModel(this))
0032     , m_updateTimer(new QTimer(this))
0033 {
0034     connect(m_sourceModel, &QAbstractItemModel::rowsInserted, this, &MicrophoneIndicator::scheduleUpdate);
0035     connect(m_sourceModel, &QAbstractItemModel::rowsRemoved, this, &MicrophoneIndicator::scheduleUpdate);
0036     connect(m_sourceModel, &QAbstractItemModel::dataChanged, this, &MicrophoneIndicator::scheduleUpdate);
0037 
0038     connect(m_sourceOutputModel, &QAbstractItemModel::rowsInserted, this, &MicrophoneIndicator::scheduleUpdate);
0039     connect(m_sourceOutputModel, &QAbstractItemModel::rowsRemoved, this, &MicrophoneIndicator::scheduleUpdate);
0040     connect(m_sourceOutputModel, &QAbstractItemModel::dataChanged, this, &MicrophoneIndicator::scheduleUpdate);
0041 
0042     m_updateTimer->setInterval(0);
0043     m_updateTimer->setSingleShot(true);
0044     connect(m_updateTimer, &QTimer::timeout, this, &MicrophoneIndicator::update);
0045 
0046     scheduleUpdate();
0047 }
0048 
0049 MicrophoneIndicator::~MicrophoneIndicator() = default;
0050 
0051 void MicrophoneIndicator::init()
0052 {
0053     // does nothing, just prompts QML engine to create an instance of the singleton
0054 }
0055 
0056 void MicrophoneIndicator::scheduleUpdate()
0057 {
0058     if (!m_updateTimer->isActive()) {
0059         m_updateTimer->start();
0060     }
0061 }
0062 
0063 void MicrophoneIndicator::update()
0064 {
0065     const auto apps = recordingApplications();
0066     if (apps.isEmpty()) {
0067         m_showOsdOnUpdate = false;
0068         delete m_sni;
0069         m_sni = nullptr;
0070         return;
0071     }
0072 
0073     if (!m_sni) {
0074         m_sni = new KStatusNotifierItem(QStringLiteral("microphone"));
0075         m_sni->setCategory(KStatusNotifierItem::Hardware);
0076         // always Active since it is completely removed when microphone isn't in use
0077         m_sni->setStatus(KStatusNotifierItem::Active);
0078 
0079         // but also middle click to be consistent with volume icon
0080         connect(m_sni, &KStatusNotifierItem::secondaryActivateRequested, this, &MicrophoneIndicator::toggleMuted);
0081         connect(m_sni, &KStatusNotifierItem::activateRequested, this, &MicrophoneIndicator::toggleMuted);
0082 
0083         connect(m_sni, &KStatusNotifierItem::scrollRequested, this, [this](int delta, Qt::Orientation orientation) {
0084             if (orientation != Qt::Vertical) {
0085                 return;
0086             }
0087 
0088             m_wheelDelta += delta;
0089 
0090             while (m_wheelDelta >= 120) {
0091                 m_wheelDelta -= 120;
0092                 adjustVolume(+1);
0093             }
0094             while (m_wheelDelta <= -120) {
0095                 m_wheelDelta += 120;
0096                 adjustVolume(-1);
0097             }
0098         });
0099 
0100         QMenu *menu = m_sni->contextMenu();
0101 
0102         m_muteAction = menu->addAction(QIcon::fromTheme(QStringLiteral("microphone-sensitivity-muted")), i18n("Mute"));
0103         m_muteAction->setCheckable(true);
0104         connect(m_muteAction.data(), &QAction::triggered, this, &MicrophoneIndicator::setMuted);
0105 
0106         // don't let it quit plasmashell
0107         m_sni->setStandardActionsEnabled(false);
0108     }
0109 
0110     const bool allMuted = muted();
0111 
0112     QString iconName;
0113     if (allMuted) {
0114         iconName = QStringLiteral("microphone-sensitivity-muted");
0115     } else {
0116         if (Source *defaultSource = m_sourceModel->defaultSource()) {
0117             const int percent = volumePercent(defaultSource);
0118             iconName = QStringLiteral("microphone-sensitivity");
0119             // it deliberately never shows the "muted" icon unless *all* microphones are muted
0120             if (percent <= 25) {
0121                 iconName.append(QStringLiteral("-low"));
0122             } else if (percent <= 75) {
0123                 iconName.append(QStringLiteral("-medium"));
0124             } else {
0125                 iconName.append(QStringLiteral("-high"));
0126             }
0127         } else {
0128             iconName = QStringLiteral("microphone-sensitivity-high");
0129         }
0130     }
0131 
0132     m_sni->setTitle(i18n("Microphone"));
0133     m_sni->setIconByName(iconName);
0134     m_sni->setToolTip(QIcon::fromTheme(iconName), allMuted ? i18n("Microphone Muted") : i18n("Microphone"), toolTipForApps(apps));
0135 
0136     if (m_muteAction) {
0137         m_muteAction->setChecked(allMuted);
0138     }
0139 
0140     if (m_showOsdOnUpdate) {
0141         showOsd();
0142         m_showOsdOnUpdate = false;
0143     }
0144 }
0145 
0146 bool MicrophoneIndicator::muted() const
0147 {
0148     static const int s_mutedRole = m_sourceModel->role(QByteArrayLiteral("Muted"));
0149     Q_ASSERT(s_mutedRole > -1);
0150 
0151     for (int row = 0; row < m_sourceModel->rowCount(); ++row) {
0152         const QModelIndex idx = m_sourceModel->index(row);
0153         if (!idx.data(s_mutedRole).toBool()) {
0154             // this is deliberately checking if *all* microphones are muted rather than the preferred one
0155             return false;
0156         }
0157     }
0158 
0159     return true;
0160 }
0161 
0162 void MicrophoneIndicator::setMuted(bool muted)
0163 {
0164     static const int s_mutedRole = m_sourceModel->role(QByteArrayLiteral("Muted"));
0165     Q_ASSERT(s_mutedRole > -1);
0166 
0167     m_showOsdOnUpdate = true;
0168 
0169     if (muted) {
0170         for (int row = 0; row < m_sourceModel->rowCount(); ++row) {
0171             const QModelIndex idx = m_sourceModel->index(row);
0172             if (!idx.data(s_mutedRole).toBool()) {
0173                 m_sourceModel->setData(idx, true, s_mutedRole);
0174                 m_mutedIndices.append(QPersistentModelIndex(idx));
0175                 continue;
0176             }
0177         }
0178         return;
0179     }
0180 
0181     // If we didn't mute it, unmute all
0182     if (m_mutedIndices.isEmpty()) {
0183         for (int i = 0; i < m_sourceModel->rowCount(); ++i) {
0184             m_sourceModel->setData(m_sourceModel->index(i), false, s_mutedRole);
0185         }
0186         return;
0187     }
0188 
0189     // Otherwise unmute the devices we muted
0190     for (auto &idx : qAsConst(m_mutedIndices)) {
0191         if (!idx.isValid()) {
0192             continue;
0193         }
0194         m_sourceModel->setData(idx, false, s_mutedRole);
0195     }
0196     m_mutedIndices.clear();
0197 
0198     // no update() needed as the model signals a change
0199 }
0200 
0201 void MicrophoneIndicator::toggleMuted()
0202 {
0203     setMuted(!muted());
0204 }
0205 
0206 void MicrophoneIndicator::adjustVolume(int direction)
0207 {
0208     Source *source = m_sourceModel->defaultSource();
0209     if (!source) {
0210         return;
0211     }
0212 
0213     const int step = qRound(5 * Context::NormalVolume / 100.0);
0214 
0215     const auto newVolume = qBound(Context::MinimalVolume, //
0216                                   source->volume() + direction * step, //
0217                                   Context::NormalVolume);
0218 
0219     source->setVolume(newVolume);
0220     source->setMuted(newVolume == Context::MinimalVolume);
0221 
0222     m_showOsdOnUpdate = true;
0223 }
0224 
0225 int MicrophoneIndicator::volumePercent(Source *source)
0226 {
0227     return source->isMuted() ? 0 : qRound(source->volume() / static_cast<qreal>(Context::NormalVolume) * 100);
0228 }
0229 
0230 void MicrophoneIndicator::showOsd()
0231 {
0232     if (!m_osd) {
0233         m_osd = new VolumeOSD(this);
0234     }
0235 
0236     auto *preferredSource = m_sourceModel->defaultSource();
0237     if (!preferredSource) {
0238         return;
0239     }
0240 
0241     m_osd->showMicrophone(volumePercent(preferredSource));
0242 }
0243 
0244 QVector<QModelIndex> MicrophoneIndicator::recordingApplications() const
0245 {
0246     QVector<QModelIndex> indices;
0247 
0248     // If there are no microphones present, there's nothing to record
0249     if (m_sourceModel->rowCount() == 0) {
0250         return indices;
0251     }
0252 
0253     static const int s_virtualStreamRole = m_sourceOutputModel->role(QByteArrayLiteral("VirtualStream"));
0254     Q_ASSERT(s_virtualStreamRole > -1);
0255 
0256     indices.reserve(m_sourceOutputModel->rowCount());
0257 
0258     for (int i = 0; i < m_sourceOutputModel->rowCount(); ++i) {
0259         const QModelIndex idx = m_sourceOutputModel->index(i);
0260 
0261         if (idx.data(s_virtualStreamRole).toBool()) {
0262             continue;
0263         }
0264 
0265         indices.append(idx);
0266     }
0267 
0268     return indices;
0269 }
0270 
0271 QString MicrophoneIndicator::toolTipForApps(const QVector<QModelIndex> &apps) const
0272 {
0273     Q_ASSERT(!apps.isEmpty());
0274 
0275     if (apps.count() > 1) {
0276         QStringList names;
0277         names.reserve(apps.count());
0278         for (const QModelIndex &idx : apps) {
0279             names.append(sourceOutputDisplayName(idx));
0280         }
0281         names.removeDuplicates();
0282         // Still more than one app?
0283         if (names.count() > 1) {
0284             return i18nc("List of apps is using mic", "%1 are using the microphone", names.join(i18nc("list separator", ", ")));
0285         }
0286     }
0287 
0288     const QModelIndex appIdx = apps.constFirst();
0289 
0290     // If there is more than one microphone, show which one is being used.
0291     // An app could record multiple microphones simultaneously, or the user having the same app running
0292     // multiple times recording the same microphone, but this isn't covered here for simplicity.
0293     if (apps.count() == 1 && m_sourceModel->rowCount() > 1) {
0294         static const int s_sourceModelDescriptionRole = m_sourceModel->role(QByteArrayLiteral("Description"));
0295         Q_ASSERT(s_sourceModelDescriptionRole > -1);
0296         static const int s_sourceModelIndexRole = m_sourceModel->role("Index");
0297         Q_ASSERT(s_sourceModelIndexRole > -1);
0298 
0299         static const int s_sourceOutputModelDeviceIndexRole = m_sourceOutputModel->role("DeviceIndex");
0300         Q_ASSERT(s_sourceOutputModelDeviceIndexRole > -1);
0301 
0302         const int sourceOutputDeviceIndex = appIdx.data(s_sourceOutputModelDeviceIndexRole).toInt();
0303 
0304         for (int i = 0; i < m_sourceModel->rowCount(); ++i) {
0305             const QModelIndex sourceDeviceIdx = m_sourceModel->index(i, 0);
0306             const int sourceDeviceIndex = sourceDeviceIdx.data(s_sourceModelIndexRole).toInt();
0307 
0308             if (sourceDeviceIndex == sourceOutputDeviceIndex) {
0309                 return i18nc("App %1 is using mic with name %2",
0310                              "%1 is using the microphone (%2)",
0311                              sourceOutputDisplayName(appIdx),
0312                              sourceDeviceIdx.data(s_sourceModelDescriptionRole).toString());
0313             }
0314         }
0315     }
0316 
0317     return i18nc("App is using mic", "%1 is using the microphone", sourceOutputDisplayName(appIdx));
0318 }
0319 
0320 QString MicrophoneIndicator::sourceOutputDisplayName(const QModelIndex &idx) const
0321 {
0322     Q_ASSERT(idx.model() == m_sourceOutputModel);
0323 
0324     static const int s_nameRole = m_sourceOutputModel->role(QByteArrayLiteral("Name"));
0325     Q_ASSERT(s_nameRole > -1);
0326     static const int s_clientRole = m_sourceOutputModel->role(QByteArrayLiteral("Client"));
0327     Q_ASSERT(s_clientRole > -1);
0328 
0329     auto *client = qobject_cast<Client *>(idx.data(s_clientRole).value<QObject *>());
0330 
0331     return client ? client->name() : idx.data(s_nameRole).toString();
0332 }