File indexing completed on 2024-12-08 10:59:33

0001 /*
0002     SPDX-FileCopyrightText: 2021 Kai Uwe Broulik <kde@broulik.de>
0003 
0004     SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
0005 */
0006 
0007 #include "listitemmenu.h"
0008 
0009 #include <QAbstractItemModel>
0010 #include <QActionGroup>
0011 #include <QMenu>
0012 #include <QQuickItem>
0013 #include <QQuickWindow>
0014 #include <QWindow>
0015 
0016 #include <KLocalizedString>
0017 
0018 #include "card.h"
0019 #include "debug.h"
0020 #include "device.h"
0021 #include "port.h"
0022 #include "pulseaudio.h"
0023 #include "pulseobject.h"
0024 #include "stream.h"
0025 
0026 using namespace QPulseAudio;
0027 
0028 static const auto s_offProfile = QLatin1String("off");
0029 
0030 ListItemMenu::ListItemMenu(QObject *parent)
0031     : QObject(parent)
0032 {
0033 }
0034 
0035 ListItemMenu::~ListItemMenu() = default;
0036 
0037 void ListItemMenu::classBegin()
0038 {
0039 }
0040 
0041 void ListItemMenu::componentComplete()
0042 {
0043     m_complete = true;
0044     update();
0045 }
0046 
0047 ListItemMenu::ItemType ListItemMenu::itemType() const
0048 {
0049     return m_itemType;
0050 }
0051 
0052 void ListItemMenu::setItemType(ItemType itemType)
0053 {
0054     if (m_itemType != itemType) {
0055         m_itemType = itemType;
0056         update();
0057         Q_EMIT itemTypeChanged();
0058     }
0059 }
0060 
0061 QPulseAudio::PulseObject *ListItemMenu::pulseObject() const
0062 {
0063     return m_pulseObject.data();
0064 }
0065 
0066 void ListItemMenu::setPulseObject(QPulseAudio::PulseObject *pulseObject)
0067 {
0068     if (m_pulseObject.data() != pulseObject) {
0069         // TODO is Qt clever enough to catch the disconnect from base class?
0070         if (m_pulseObject) {
0071             disconnect(m_pulseObject, nullptr, this, nullptr);
0072         }
0073 
0074         m_pulseObject = pulseObject;
0075 
0076         if (auto *device = qobject_cast<QPulseAudio::Device *>(m_pulseObject.data())) {
0077             connect(device, &Device::activePortIndexChanged, this, &ListItemMenu::update);
0078             connect(device, &Device::portsChanged, this, &ListItemMenu::update);
0079         }
0080 
0081         update();
0082         Q_EMIT pulseObjectChanged();
0083     }
0084 }
0085 
0086 QAbstractItemModel *ListItemMenu::sourceModel() const
0087 {
0088     return m_sourceModel.data();
0089 }
0090 
0091 void ListItemMenu::setSourceModel(QAbstractItemModel *sourceModel)
0092 {
0093     if (m_sourceModel.data() == sourceModel) {
0094         return;
0095     }
0096 
0097     if (m_sourceModel) {
0098         disconnect(m_sourceModel, nullptr, this, nullptr);
0099     }
0100 
0101     m_sourceModel = sourceModel;
0102 
0103     if (m_sourceModel) {
0104         connect(m_sourceModel, &QAbstractItemModel::rowsInserted, this, &ListItemMenu::update);
0105         connect(m_sourceModel, &QAbstractItemModel::rowsRemoved, this, &ListItemMenu::update);
0106         connect(m_sourceModel, &QAbstractItemModel::modelReset, this, &ListItemMenu::update);
0107     }
0108 
0109     update();
0110     Q_EMIT sourceModelChanged();
0111 }
0112 
0113 QPulseAudio::CardModel *ListItemMenu::cardModel() const
0114 {
0115     return m_cardModel.data();
0116 }
0117 
0118 void ListItemMenu::setCardModel(QPulseAudio::CardModel *cardModel)
0119 {
0120     if (m_cardModel.data() == cardModel) {
0121         return;
0122     }
0123 
0124     if (m_cardModel) {
0125         disconnect(m_cardModel, nullptr, this, nullptr);
0126     }
0127     m_cardModel = cardModel;
0128 
0129     if (m_cardModel) {
0130         const int profilesRole = m_cardModel->role("Profiles");
0131         Q_ASSERT(profilesRole > -1);
0132 
0133         connect(m_cardModel, &CardModel::dataChanged, this, [this, profilesRole](const QModelIndex &, const QModelIndex &, const QVector<int> &roles) {
0134             if (roles.isEmpty() || roles.contains(profilesRole)) {
0135                 update();
0136             }
0137         });
0138     }
0139 
0140     update();
0141     Q_EMIT cardModelChanged();
0142 }
0143 
0144 bool ListItemMenu::isVisible() const
0145 {
0146     return m_visible;
0147 }
0148 
0149 void ListItemMenu::setVisible(bool visible)
0150 {
0151     if (m_visible != visible) {
0152         m_visible = visible;
0153         Q_EMIT visibleChanged();
0154     }
0155 }
0156 
0157 bool ListItemMenu::hasContent() const
0158 {
0159     return m_hasContent;
0160 }
0161 
0162 QQuickItem *ListItemMenu::visualParent() const
0163 {
0164     return m_visualParent.data();
0165 }
0166 
0167 void ListItemMenu::setVisualParent(QQuickItem *visualParent)
0168 {
0169     if (m_visualParent.data() != visualParent) {
0170         m_visualParent = visualParent;
0171         Q_EMIT visualParentChanged();
0172     }
0173 }
0174 
0175 bool ListItemMenu::checkHasContent()
0176 {
0177     // If there are at least two sink/source devices to choose from.
0178     if (m_sourceModel && m_sourceModel->rowCount() > 1) {
0179         return true;
0180     }
0181 
0182     auto *device = qobject_cast<QPulseAudio::Device *>(m_pulseObject.data());
0183 
0184     if (device) {
0185         const auto ports = device->ports();
0186         if (ports.length() > 1) {
0187             // In case an unavailable port is active.
0188             if (device->activePortIndex() != static_cast<quint32>(-1)) {
0189                 auto *activePort = static_cast<Port *>(ports.at(device->activePortIndex()));
0190                 if (activePort->availability() == Port::Unavailable) {
0191                     return true;
0192                 }
0193             }
0194 
0195             // If there are at least two available ports.
0196             int availablePorts = 0;
0197             for (auto *portObject : ports) {
0198                 auto *port = static_cast<Port *>(portObject);
0199                 if (port->availability() == Port::Unavailable) {
0200                     continue;
0201                 }
0202 
0203                 if (++availablePorts == 2) {
0204                     return true;
0205                 }
0206             }
0207         }
0208 
0209         if (m_cardModel) {
0210             const int cardModelPulseObjectRole = m_cardModel->role("PulseObject");
0211             Q_ASSERT(cardModelPulseObjectRole != -1);
0212 
0213             for (int i = 0; i < m_cardModel->rowCount(); ++i) {
0214                 const QModelIndex cardIdx = m_cardModel->index(i, 0);
0215                 Card *card = qobject_cast<Card *>(cardIdx.data(cardModelPulseObjectRole).value<QObject *>());
0216 
0217                 if (card->index() == device->cardIndex()) {
0218                     // If there are at least two available profiles on the corresponding card.
0219                     const auto profiles = card->profiles();
0220                     int availableProfiles = 0;
0221                     for (auto *profileObject : profiles) {
0222                         auto *profile = static_cast<Profile *>(profileObject);
0223                         if (profile->availability() == Profile::Unavailable) {
0224                             continue;
0225                         }
0226 
0227                         if (profile->name() == s_offProfile) {
0228                             continue;
0229                         }
0230 
0231                         // TODO should we also check "if current profile is unavailable" like with ports?
0232                         if (++availableProfiles == 2) {
0233                             return true;
0234                         }
0235                     }
0236                 }
0237             }
0238         }
0239     }
0240 
0241     return false;
0242 }
0243 
0244 void ListItemMenu::update()
0245 {
0246     if (!m_complete) {
0247         return;
0248     }
0249 
0250     const bool hasContent = checkHasContent();
0251     if (m_hasContent != hasContent) {
0252         m_hasContent = hasContent;
0253         Q_EMIT hasContentChanged();
0254     }
0255 }
0256 
0257 void ListItemMenu::open(int x, int y)
0258 {
0259     auto *menu = createMenu();
0260     if (!menu) {
0261         return;
0262     }
0263 
0264     const QPoint pos = m_visualParent->mapToGlobal(QPointF(x, y)).toPoint();
0265 
0266     menu->popup(pos);
0267     setVisible(true);
0268 }
0269 
0270 // to the bottom left of visualParent
0271 void ListItemMenu::openRelative()
0272 {
0273     auto *menu = createMenu();
0274     if (!menu) {
0275         return;
0276     }
0277 
0278     menu->adjustSize();
0279 
0280     QPoint pos = m_visualParent->mapToGlobal(QPointF(m_visualParent->width(), m_visualParent->height())).toPoint();
0281     pos.rx() -= menu->width();
0282 
0283     // TODO do we still need this ungrab mouse hack?
0284     menu->popup(pos);
0285     setVisible(true);
0286 }
0287 
0288 static int getModelRole(QObject *model, const QByteArray &name)
0289 {
0290     // Can either be an AbstractModel, then it's easy
0291     if (auto *abstractModel = qobject_cast<AbstractModel *>(model)) {
0292         return abstractModel->role(name);
0293     }
0294 
0295     // or that PulseObjectFilterModel from QML where everything is a QVariant...
0296     QVariant roleVariant;
0297     bool ok = QMetaObject::invokeMethod(model, "role", Q_RETURN_ARG(QVariant, roleVariant), Q_ARG(QVariant, QVariant(name)));
0298     if (!ok) {
0299         qCCritical(PLASMAPA) << "Failed to invoke 'role' on" << model;
0300         return -1;
0301     }
0302 
0303     int role = roleVariant.toInt(&ok);
0304     if (!ok) {
0305         qCCritical(PLASMAPA) << "Return value from 'role' is bogus" << roleVariant;
0306         return -1;
0307     }
0308 
0309     return role;
0310 }
0311 
0312 QMenu *ListItemMenu::createMenu()
0313 {
0314     if (m_visible) {
0315         return nullptr;
0316     }
0317 
0318     if (!m_visualParent || !m_visualParent->window()) {
0319         qCWarning(PLASMAPA) << "Cannot prepare menu without visualParent or a window";
0320         return nullptr;
0321     }
0322 
0323     auto *menu = new QMenu();
0324     menu->setAttribute(Qt::WA_DeleteOnClose);
0325     // Breeze and Oxygen have rounded corners on menus. They set this attribute in polish()
0326     // but at that time the underlying surface has already been created where setting this
0327     // flag makes no difference anymore (Bug 385311)
0328     menu->setAttribute(Qt::WA_TranslucentBackground);
0329 
0330     connect(menu, &QMenu::aboutToHide, this, [this] {
0331         setVisible(false);
0332     });
0333 
0334     if (auto *device = qobject_cast<QPulseAudio::Device *>(m_pulseObject.data())) {
0335         // Switch all streams of the relevant kind to this device
0336         if (m_sourceModel->rowCount() > 1) {
0337             QAction *switchStreamsAction = nullptr;
0338             if (m_itemType == Sink) {
0339                 switchStreamsAction = menu->addAction(
0340                     QIcon::fromTheme(QStringLiteral("audio-on"),
0341                                      QIcon::fromTheme(QStringLiteral("audio-ready"), QIcon::fromTheme(QStringLiteral("audio-speakers-symbolic")))),
0342                     i18n("Play all audio via this device"));
0343             } else if (m_itemType == Source) {
0344                 switchStreamsAction = menu->addAction(
0345                     QIcon::fromTheme(QStringLiteral("mic-on"),
0346                                      QIcon::fromTheme(QStringLiteral("mic-ready"), QIcon::fromTheme(QStringLiteral("audio-input-microphone-symbolic")))),
0347                     i18n("Record all audio via this device"));
0348             }
0349 
0350             if (switchStreamsAction) {
0351                 connect(switchStreamsAction, &QAction::triggered, device, &Device::switchStreams);
0352             }
0353         }
0354 
0355         // Ports
0356         const auto ports = device->ports();
0357         bool activePortUnavailable = false;
0358         if (device->activePortIndex() != static_cast<quint32>(-1)) {
0359             auto *activePort = static_cast<Port *>(ports.at(device->activePortIndex()));
0360             activePortUnavailable = activePort->availability() == Port::Unavailable;
0361         }
0362 
0363         QMap<int, Port *> availablePorts;
0364         for (int i = 0; i < ports.count(); ++i) {
0365             auto *port = static_cast<Port *>(ports.at(i));
0366 
0367             // If an unavailable port is active, show all the ports,
0368             // otherwise show only the available ones
0369             if (activePortUnavailable || port->availability() != Port::Unavailable) {
0370                 availablePorts.insert(i, port);
0371             }
0372         }
0373 
0374         if (availablePorts.count() > 1) {
0375             menu->addSection(i18nc("Heading for a list of ports of a device (for example built-in laptop speakers or a plug for headphones)", "Ports"));
0376 
0377             auto *portGroup = new QActionGroup(menu);
0378 
0379             for (auto it = availablePorts.constBegin(), end = availablePorts.constEnd(); it != end; ++it) {
0380                 const int i = it.key();
0381                 Port *port = it.value();
0382 
0383                 QAction *item = nullptr;
0384 
0385                 if (port->availability() == Port::Unavailable) {
0386                     if (port->name() == QLatin1String("analog-output-speaker") || port->name() == QLatin1String("analog-input-microphone-internal")) {
0387                         item = menu->addAction(i18nc("Port is unavailable", "%1 (unavailable)", port->description()));
0388                     } else {
0389                         item = menu->addAction(i18nc("Port is unplugged", "%1 (unplugged)", port->description()));
0390                     }
0391                 } else {
0392                     item = menu->addAction(port->description());
0393                 }
0394 
0395                 item->setCheckable(true);
0396                 item->setChecked(static_cast<quint32>(i) == device->activePortIndex());
0397                 connect(item, &QAction::triggered, device, [device, i] {
0398                     device->setActivePortIndex(i);
0399                 });
0400 
0401                 portGroup->addAction(item);
0402             }
0403         }
0404 
0405         // Submenu with profiles
0406         if (m_cardModel) {
0407             const int cardModelPulseObjectRole = m_cardModel->role("PulseObject");
0408             Q_ASSERT(cardModelPulseObjectRole != -1);
0409 
0410             Card *card = nullptr;
0411             for (int i = 0; i < m_cardModel->rowCount(); ++i) {
0412                 const QModelIndex cardIdx = m_cardModel->index(i, 0);
0413                 Card *candidateCard = qobject_cast<Card *>(cardIdx.data(cardModelPulseObjectRole).value<QObject *>());
0414 
0415                 if (candidateCard && candidateCard->index() == device->cardIndex()) {
0416                     card = candidateCard;
0417                     break;
0418                 }
0419             }
0420 
0421             if (card) {
0422                 QMap<int, Profile *> availableProfiles;
0423 
0424                 const auto profiles = card->profiles();
0425                 for (int i = 0; i < profiles.count(); ++i) {
0426                     auto *profile = static_cast<Profile *>(profiles.at(i));
0427 
0428                     // TODO should we also check "if current profile is unavailable" like with ports?
0429                     if (profile->availability() == Profile::Unavailable) {
0430                         continue;
0431                     }
0432 
0433                     // Don't let user easily remove a device with no obvious way to get it back
0434                     // Only let that be done from the KCM where one can just flip the ComboBox back.
0435                     if (profile->name() == s_offProfile) {
0436                         continue;
0437                     }
0438 
0439                     availableProfiles.insert(i, profile);
0440                 }
0441 
0442                 if (availableProfiles.count() > 1) {
0443                     // If there's too many profiles, put them in a submenu, unless the menu is empty, otherwise as a section
0444                     QMenu *profilesMenu = menu;
0445                     const QString title = i18nc("Heading for a list of device profiles (5.1 surround sound, stereo, speakers only, ...)", "Profiles");
0446                     // "10" is catered around laptop speakers (internal, stereo, duplex) plus one HDMI port (stereo, surround 5.1, 7.1, in and out, etc)
0447                     if (availableProfiles.count() > 10 && !menu->actions().isEmpty()) {
0448                         profilesMenu = menu->addMenu(title);
0449                     } else {
0450                         menu->addSection(title);
0451                     }
0452 
0453                     auto *profileGroup = new QActionGroup(profilesMenu);
0454                     for (auto it = availableProfiles.constBegin(), end = availableProfiles.constEnd(); it != end; ++it) {
0455                         const int i = it.key();
0456                         Profile *profile = it.value();
0457 
0458                         auto *profileAction = profilesMenu->addAction(profile->description());
0459                         profileAction->setCheckable(true);
0460                         profileAction->setChecked(static_cast<quint32>(i) == card->activeProfileIndex());
0461                         connect(profileAction, &QAction::triggered, card, [card, i] {
0462                             card->setActiveProfileIndex(i);
0463                         });
0464 
0465                         profileGroup->addAction(profileAction);
0466                     }
0467                 }
0468             } else {
0469                 qCWarning(PLASMAPA) << "Failed to find card at" << device->cardIndex() << "for" << device->description() << device->index();
0470             }
0471         }
0472     }
0473 
0474     // Choose output / input device
0475     auto *stream = qobject_cast<QPulseAudio::Stream *>(m_pulseObject.data());
0476     if (stream && m_sourceModel && m_sourceModel->rowCount() > 1) {
0477         if (m_itemType == SinkInput || m_itemType == SourceOutput) {
0478             if (m_itemType == SinkInput) {
0479                 menu->addSection(i18nc("Heading for a list of possible output devices (speakers, headphones, ...) to choose", "Play audio using"));
0480             } else {
0481                 menu->addSection(i18nc("Heading for a list of possible input devices (built-in microphone, headset, ...) to choose", "Record audio using"));
0482             }
0483 
0484             const int indexRole = getModelRole(m_sourceModel, "Index");
0485             Q_ASSERT(indexRole > -1);
0486             const int descriptionRole = getModelRole(m_sourceModel, "Description");
0487             Q_ASSERT(descriptionRole > -1);
0488 
0489             auto *deviceGroup = new QActionGroup(menu);
0490 
0491             for (int i = 0; i < m_sourceModel->rowCount(); ++i) {
0492                 const QModelIndex idx = m_sourceModel->index(i, 0);
0493                 const auto index = idx.data(indexRole).toUInt();
0494 
0495                 auto *item = menu->addAction(idx.data(descriptionRole).toString());
0496                 item->setCheckable(true);
0497                 item->setChecked(index == stream->deviceIndex());
0498                 connect(item, &QAction::triggered, stream, [stream, index] {
0499                     stream->setDeviceIndex(index);
0500                 });
0501 
0502                 deviceGroup->addAction(item);
0503             }
0504         }
0505     }
0506 
0507     if (menu->isEmpty()) {
0508         delete menu;
0509         return nullptr;
0510     }
0511 
0512     menu->winId();
0513     menu->windowHandle()->setTransientParent(m_visualParent->window());
0514 
0515     return menu;
0516 }