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 }