File indexing completed on 2024-02-25 05:45:35

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 QList<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 QMenu *ListItemMenu::createMenu()
0289 {
0290     if (m_visible) {
0291         return nullptr;
0292     }
0293 
0294     if (!m_visualParent || !m_visualParent->window()) {
0295         qCWarning(PLASMAPA) << "Cannot prepare menu without visualParent or a window";
0296         return nullptr;
0297     }
0298 
0299     auto *menu = new QMenu();
0300     menu->setAttribute(Qt::WA_DeleteOnClose);
0301     // Breeze and Oxygen have rounded corners on menus. They set this attribute in polish()
0302     // but at that time the underlying surface has already been created where setting this
0303     // flag makes no difference anymore (Bug 385311)
0304     menu->setAttribute(Qt::WA_TranslucentBackground);
0305 
0306     connect(menu, &QMenu::aboutToHide, this, [this] {
0307         setVisible(false);
0308     });
0309 
0310     if (auto *device = qobject_cast<QPulseAudio::Device *>(m_pulseObject.data())) {
0311         // Switch all streams of the relevant kind to this device
0312         if (m_sourceModel->rowCount() > 1) {
0313             QAction *switchStreamsAction = nullptr;
0314             if (m_itemType == Sink) {
0315                 switchStreamsAction = menu->addAction(
0316                     QIcon::fromTheme(QStringLiteral("audio-on"),
0317                                      QIcon::fromTheme(QStringLiteral("audio-ready"), QIcon::fromTheme(QStringLiteral("audio-speakers-symbolic")))),
0318                     i18n("Play all audio via this device"));
0319             } else if (m_itemType == Source) {
0320                 switchStreamsAction = menu->addAction(
0321                     QIcon::fromTheme(QStringLiteral("mic-on"),
0322                                      QIcon::fromTheme(QStringLiteral("mic-ready"), QIcon::fromTheme(QStringLiteral("audio-input-microphone-symbolic")))),
0323                     i18n("Record all audio via this device"));
0324             }
0325 
0326             if (switchStreamsAction) {
0327                 connect(switchStreamsAction, &QAction::triggered, device, &Device::switchStreams);
0328             }
0329         }
0330 
0331         // Ports
0332         const auto ports = device->ports();
0333         bool activePortUnavailable = false;
0334         if (device->activePortIndex() != static_cast<quint32>(-1)) {
0335             auto *activePort = static_cast<Port *>(ports.at(device->activePortIndex()));
0336             activePortUnavailable = activePort->availability() == Port::Unavailable;
0337         }
0338 
0339         QMap<int, Port *> availablePorts;
0340         for (int i = 0; i < ports.count(); ++i) {
0341             auto *port = static_cast<Port *>(ports.at(i));
0342 
0343             // If an unavailable port is active, show all the ports,
0344             // otherwise show only the available ones
0345             if (activePortUnavailable || port->availability() != Port::Unavailable) {
0346                 availablePorts.insert(i, port);
0347             }
0348         }
0349 
0350         if (availablePorts.count() > 1) {
0351             menu->addSection(i18nc("Heading for a list of ports of a device (for example built-in laptop speakers or a plug for headphones)", "Ports"));
0352 
0353             auto *portGroup = new QActionGroup(menu);
0354 
0355             for (auto it = availablePorts.constBegin(), end = availablePorts.constEnd(); it != end; ++it) {
0356                 const int i = it.key();
0357                 Port *port = it.value();
0358 
0359                 QAction *item = nullptr;
0360 
0361                 if (port->availability() == Port::Unavailable) {
0362                     if (port->name() == QLatin1String("analog-output-speaker") || port->name() == QLatin1String("analog-input-microphone-internal")) {
0363                         item = menu->addAction(i18nc("Port is unavailable", "%1 (unavailable)", port->description()));
0364                     } else {
0365                         item = menu->addAction(i18nc("Port is unplugged", "%1 (unplugged)", port->description()));
0366                     }
0367                 } else {
0368                     item = menu->addAction(port->description());
0369                 }
0370 
0371                 item->setCheckable(true);
0372                 item->setChecked(static_cast<quint32>(i) == device->activePortIndex());
0373                 connect(item, &QAction::triggered, device, [device, i] {
0374                     device->setActivePortIndex(i);
0375                 });
0376 
0377                 portGroup->addAction(item);
0378             }
0379         }
0380 
0381         // Submenu with profiles
0382         if (m_cardModel) {
0383             const int cardModelPulseObjectRole = m_cardModel->role("PulseObject");
0384             Q_ASSERT(cardModelPulseObjectRole != -1);
0385 
0386             Card *card = nullptr;
0387             for (int i = 0; i < m_cardModel->rowCount(); ++i) {
0388                 const QModelIndex cardIdx = m_cardModel->index(i, 0);
0389                 Card *candidateCard = qobject_cast<Card *>(cardIdx.data(cardModelPulseObjectRole).value<QObject *>());
0390 
0391                 if (candidateCard && candidateCard->index() == device->cardIndex()) {
0392                     card = candidateCard;
0393                     break;
0394                 }
0395             }
0396 
0397             if (card) {
0398                 QMap<int, Profile *> availableProfiles;
0399 
0400                 const auto profiles = card->profiles();
0401                 for (int i = 0; i < profiles.count(); ++i) {
0402                     auto *profile = static_cast<Profile *>(profiles.at(i));
0403 
0404                     // TODO should we also check "if current profile is unavailable" like with ports?
0405                     if (profile->availability() == Profile::Unavailable) {
0406                         continue;
0407                     }
0408 
0409                     // Don't let user easily remove a device with no obvious way to get it back
0410                     // Only let that be done from the KCM where one can just flip the ComboBox back.
0411                     if (profile->name() == s_offProfile) {
0412                         continue;
0413                     }
0414 
0415                     availableProfiles.insert(i, profile);
0416                 }
0417 
0418                 if (availableProfiles.count() > 1) {
0419                     // If there's too many profiles, put them in a submenu, unless the menu is empty, otherwise as a section
0420                     QMenu *profilesMenu = menu;
0421                     const QString title = i18nc("Heading for a list of device profiles (5.1 surround sound, stereo, speakers only, ...)", "Profiles");
0422                     // "10" is catered around laptop speakers (internal, stereo, duplex) plus one HDMI port (stereo, surround 5.1, 7.1, in and out, etc)
0423                     if (availableProfiles.count() > 10 && !menu->actions().isEmpty()) {
0424                         profilesMenu = menu->addMenu(title);
0425                     } else {
0426                         menu->addSection(title);
0427                     }
0428 
0429                     auto *profileGroup = new QActionGroup(profilesMenu);
0430                     for (auto it = availableProfiles.constBegin(), end = availableProfiles.constEnd(); it != end; ++it) {
0431                         const int i = it.key();
0432                         Profile *profile = it.value();
0433 
0434                         auto *profileAction = profilesMenu->addAction(profile->description());
0435                         profileAction->setCheckable(true);
0436                         profileAction->setChecked(static_cast<quint32>(i) == card->activeProfileIndex());
0437                         connect(profileAction, &QAction::triggered, card, [card, i] {
0438                             card->setActiveProfileIndex(i);
0439                         });
0440 
0441                         profileGroup->addAction(profileAction);
0442                     }
0443                 }
0444             } else {
0445                 qCWarning(PLASMAPA) << "Failed to find card at" << device->cardIndex() << "for" << device->description() << device->index();
0446             }
0447         }
0448     }
0449 
0450     // Choose output / input device
0451     auto *stream = qobject_cast<QPulseAudio::Stream *>(m_pulseObject.data());
0452     if (stream && m_sourceModel && m_sourceModel->rowCount() > 1) {
0453         if (m_itemType == SinkInput || m_itemType == SourceOutput) {
0454             if (m_itemType == SinkInput) {
0455                 menu->addSection(i18nc("Heading for a list of possible output devices (speakers, headphones, ...) to choose", "Play audio using"));
0456             } else {
0457                 menu->addSection(i18nc("Heading for a list of possible input devices (built-in microphone, headset, ...) to choose", "Record audio using"));
0458             }
0459 
0460             const int indexRole = m_sourceModel->roleNames().key("Index", -1);
0461             Q_ASSERT(indexRole != -1);
0462             const int descriptionRole = m_sourceModel->roleNames().key("Description", -1);
0463             Q_ASSERT(descriptionRole != -1);
0464 
0465             auto *deviceGroup = new QActionGroup(menu);
0466 
0467             for (int i = 0; i < m_sourceModel->rowCount(); ++i) {
0468                 const QModelIndex idx = m_sourceModel->index(i, 0);
0469                 const auto index = idx.data(indexRole).toUInt();
0470 
0471                 auto *item = menu->addAction(idx.data(descriptionRole).toString());
0472                 item->setCheckable(true);
0473                 item->setChecked(index == stream->deviceIndex());
0474                 connect(item, &QAction::triggered, stream, [stream, index] {
0475                     stream->setDeviceIndex(index);
0476                 });
0477 
0478                 deviceGroup->addAction(item);
0479             }
0480         }
0481     }
0482 
0483     if (menu->isEmpty()) {
0484         delete menu;
0485         return nullptr;
0486     }
0487 
0488     menu->winId();
0489     menu->windowHandle()->setTransientParent(m_visualParent->window());
0490 
0491     return menu;
0492 }