File indexing completed on 2024-12-08 08:02:45
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 }