/* SPDX-FileCopyrightText: 2021 Kai Uwe Broulik SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL */ #include "listitemmenu.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "debug.h" using namespace PulseAudioQt; static const auto s_offProfile = QLatin1String("off"); ListItemMenu::ListItemMenu(QObject *parent) : QObject(parent) { } ListItemMenu::~ListItemMenu() = default; void ListItemMenu::classBegin() { } void ListItemMenu::componentComplete() { m_complete = true; update(); } ListItemMenu::ItemType ListItemMenu::itemType() const { return m_itemType; } void ListItemMenu::setItemType(ItemType itemType) { if (m_itemType != itemType) { m_itemType = itemType; update(); Q_EMIT itemTypeChanged(); } } PulseAudioQt::PulseObject *ListItemMenu::pulseObject() const { return m_pulseObject.data(); } void ListItemMenu::setPulseObject(PulseAudioQt::PulseObject *pulseObject) { if (m_pulseObject.data() != pulseObject) { // TODO is Qt clever enough to catch the disconnect from base class? if (m_pulseObject) { disconnect(m_pulseObject, nullptr, this, nullptr); } m_pulseObject = pulseObject; if (auto *device = qobject_cast(m_pulseObject.data())) { connect(device, &Device::activePortIndexChanged, this, &ListItemMenu::update); connect(device, &Device::portsChanged, this, &ListItemMenu::update); } update(); Q_EMIT pulseObjectChanged(); } } QAbstractItemModel *ListItemMenu::sourceModel() const { return m_sourceModel.data(); } void ListItemMenu::setSourceModel(QAbstractItemModel *sourceModel) { if (m_sourceModel.data() == sourceModel) { return; } if (m_sourceModel) { disconnect(m_sourceModel, nullptr, this, nullptr); } m_sourceModel = sourceModel; if (m_sourceModel) { connect(m_sourceModel, &QAbstractItemModel::rowsInserted, this, &ListItemMenu::update); connect(m_sourceModel, &QAbstractItemModel::rowsRemoved, this, &ListItemMenu::update); connect(m_sourceModel, &QAbstractItemModel::modelReset, this, &ListItemMenu::update); } update(); Q_EMIT sourceModelChanged(); } PulseAudioQt::CardModel *ListItemMenu::cardModel() const { return m_cardModel.data(); } void ListItemMenu::setCardModel(PulseAudioQt::CardModel *cardModel) { if (m_cardModel.data() == cardModel) { return; } if (m_cardModel) { disconnect(m_cardModel, nullptr, this, nullptr); } m_cardModel = cardModel; if (m_cardModel) { const int profilesRole = m_cardModel->role("Profiles"); Q_ASSERT(profilesRole > -1); connect(m_cardModel, &CardModel::dataChanged, this, [this, profilesRole](const QModelIndex &, const QModelIndex &, const QList &roles) { if (roles.isEmpty() || roles.contains(profilesRole)) { update(); } }); } update(); Q_EMIT cardModelChanged(); } bool ListItemMenu::isVisible() const { return m_visible; } void ListItemMenu::setVisible(bool visible) { if (m_visible != visible) { m_visible = visible; Q_EMIT visibleChanged(); } } bool ListItemMenu::hasContent() const { return m_hasContent; } QQuickItem *ListItemMenu::visualParent() const { return m_visualParent.data(); } void ListItemMenu::setVisualParent(QQuickItem *visualParent) { if (m_visualParent.data() != visualParent) { m_visualParent = visualParent; Q_EMIT visualParentChanged(); } } bool ListItemMenu::checkHasContent() { // If there are at least two sink/source devices to choose from. if (m_sourceModel && m_sourceModel->rowCount() > 1) { return true; } auto *device = qobject_cast(m_pulseObject.data()); if (device) { const auto ports = device->ports(); if (ports.length() > 1) { // In case an unavailable port is active. if (device->activePortIndex() != static_cast(-1)) { auto *activePort = static_cast(ports.at(device->activePortIndex())); if (activePort->availability() == Port::Unavailable) { return true; } } // If there are at least two available ports. int availablePorts = 0; for (auto *portObject : ports) { auto *port = static_cast(portObject); if (port->availability() == Port::Unavailable) { continue; } if (++availablePorts == 2) { return true; } } } if (m_cardModel) { const int cardModelPulseObjectRole = m_cardModel->role("PulseObject"); Q_ASSERT(cardModelPulseObjectRole != -1); for (int i = 0; i < m_cardModel->rowCount(); ++i) { const QModelIndex cardIdx = m_cardModel->index(i, 0); Card *card = qobject_cast(cardIdx.data(cardModelPulseObjectRole).value()); if (card->index() == device->cardIndex()) { // If there are at least two available profiles on the corresponding card. const auto profiles = card->profiles(); int availableProfiles = 0; for (auto *profileObject : profiles) { auto *profile = static_cast(profileObject); if (profile->availability() == Profile::Unavailable) { continue; } if (profile->name() == s_offProfile) { continue; } // TODO should we also check "if current profile is unavailable" like with ports? if (++availableProfiles == 2) { return true; } } } } } } return false; } void ListItemMenu::update() { if (!m_complete) { return; } const bool hasContent = checkHasContent(); if (m_hasContent != hasContent) { m_hasContent = hasContent; Q_EMIT hasContentChanged(); } } void ListItemMenu::open(int x, int y) { auto *menu = createMenu(); if (!menu) { return; } const QPoint pos = m_visualParent->mapToGlobal(QPointF(x, y)).toPoint(); menu->popup(pos); setVisible(true); } // to the bottom left of visualParent void ListItemMenu::openRelative() { auto *menu = createMenu(); if (!menu) { return; } menu->adjustSize(); QPoint pos = m_visualParent->mapToGlobal(QPointF(m_visualParent->width(), m_visualParent->height())).toPoint(); pos.rx() -= menu->width(); // TODO do we still need this ungrab mouse hack? menu->popup(pos); setVisible(true); } QMenu *ListItemMenu::createMenu() { if (m_visible) { return nullptr; } if (!m_visualParent || !m_visualParent->window()) { qCWarning(PLASMAPA) << "Cannot prepare menu without visualParent or a window"; return nullptr; } auto *menu = new QMenu(); menu->setAttribute(Qt::WA_DeleteOnClose); // Breeze and Oxygen have rounded corners on menus. They set this attribute in polish() // but at that time the underlying surface has already been created where setting this // flag makes no difference anymore (Bug 385311) menu->setAttribute(Qt::WA_TranslucentBackground); connect(menu, &QMenu::aboutToHide, this, [this] { setVisible(false); }); if (auto *device = qobject_cast(m_pulseObject.data())) { // Switch all streams of the relevant kind to this device if (m_sourceModel->rowCount() > 1) { QAction *switchStreamsAction = nullptr; if (m_itemType == Sink) { switchStreamsAction = menu->addAction( QIcon::fromTheme(QStringLiteral("audio-on"), QIcon::fromTheme(QStringLiteral("audio-ready"), QIcon::fromTheme(QStringLiteral("audio-speakers-symbolic")))), i18n("Play all audio via this device")); } else if (m_itemType == Source) { switchStreamsAction = menu->addAction( QIcon::fromTheme(QStringLiteral("mic-on"), QIcon::fromTheme(QStringLiteral("mic-ready"), QIcon::fromTheme(QStringLiteral("audio-input-microphone-symbolic")))), i18n("Record all audio via this device")); } if (switchStreamsAction) { connect(switchStreamsAction, &QAction::triggered, device, &Device::switchStreams); } } // Ports const auto ports = device->ports(); bool activePortUnavailable = false; if (device->activePortIndex() != static_cast(-1)) { auto *activePort = static_cast(ports.at(device->activePortIndex())); activePortUnavailable = activePort->availability() == Port::Unavailable; } QMap availablePorts; for (int i = 0; i < ports.count(); ++i) { auto *port = static_cast(ports.at(i)); // If an unavailable port is active, show all the ports, // otherwise show only the available ones if (activePortUnavailable || port->availability() != Port::Unavailable) { availablePorts.insert(i, port); } } if (availablePorts.count() > 1) { menu->addSection(i18nc("Heading for a list of ports of a device (for example built-in laptop speakers or a plug for headphones)", "Ports")); auto *portGroup = new QActionGroup(menu); for (auto it = availablePorts.constBegin(), end = availablePorts.constEnd(); it != end; ++it) { const int i = it.key(); Port *port = it.value(); QAction *item = nullptr; if (port->availability() == Port::Unavailable) { if (port->name() == QLatin1String("analog-output-speaker") || port->name() == QLatin1String("analog-input-microphone-internal")) { item = menu->addAction(i18nc("Port is unavailable", "%1 (unavailable)", port->description())); } else { item = menu->addAction(i18nc("Port is unplugged", "%1 (unplugged)", port->description())); } } else { item = menu->addAction(port->description()); } item->setCheckable(true); item->setChecked(static_cast(i) == device->activePortIndex()); connect(item, &QAction::triggered, device, [device, i] { device->setActivePortIndex(i); }); portGroup->addAction(item); } } // Submenu with profiles if (m_cardModel) { const int cardModelPulseObjectRole = m_cardModel->role("PulseObject"); Q_ASSERT(cardModelPulseObjectRole != -1); Card *card = nullptr; for (int i = 0; i < m_cardModel->rowCount(); ++i) { const QModelIndex cardIdx = m_cardModel->index(i, 0); Card *candidateCard = qobject_cast(cardIdx.data(cardModelPulseObjectRole).value()); if (candidateCard && candidateCard->index() == device->cardIndex()) { card = candidateCard; break; } } if (card) { QMap availableProfiles; const auto profiles = card->profiles(); for (int i = 0; i < profiles.count(); ++i) { auto *profile = static_cast(profiles.at(i)); // TODO should we also check "if current profile is unavailable" like with ports? if (profile->availability() == Profile::Unavailable) { continue; } // Don't let user easily remove a device with no obvious way to get it back // Only let that be done from the KCM where one can just flip the ComboBox back. if (profile->name() == s_offProfile) { continue; } availableProfiles.insert(i, profile); } if (availableProfiles.count() > 1) { // If there's too many profiles, put them in a submenu, unless the menu is empty, otherwise as a section QMenu *profilesMenu = menu; const QString title = i18nc("Heading for a list of device profiles (5.1 surround sound, stereo, speakers only, ...)", "Profiles"); // "10" is catered around laptop speakers (internal, stereo, duplex) plus one HDMI port (stereo, surround 5.1, 7.1, in and out, etc) if (availableProfiles.count() > 10 && !menu->actions().isEmpty()) { profilesMenu = menu->addMenu(title); } else { menu->addSection(title); } auto *profileGroup = new QActionGroup(profilesMenu); for (auto it = availableProfiles.constBegin(), end = availableProfiles.constEnd(); it != end; ++it) { const int i = it.key(); Profile *profile = it.value(); auto *profileAction = profilesMenu->addAction(profile->description()); profileAction->setCheckable(true); profileAction->setChecked(static_cast(i) == card->activeProfileIndex()); connect(profileAction, &QAction::triggered, card, [card, i] { card->setActiveProfileIndex(i); }); profileGroup->addAction(profileAction); } } } else { qCWarning(PLASMAPA) << "Failed to find card at" << device->cardIndex() << "for" << device->description() << device->index(); } } } // Choose output / input device auto *stream = qobject_cast(m_pulseObject.data()); if (stream && m_sourceModel && m_sourceModel->rowCount() > 1) { if (m_itemType == SinkInput || m_itemType == SourceOutput) { if (m_itemType == SinkInput) { menu->addSection(i18nc("Heading for a list of possible output devices (speakers, headphones, ...) to choose", "Play audio using")); } else { menu->addSection(i18nc("Heading for a list of possible input devices (built-in microphone, headset, ...) to choose", "Record audio using")); } const int indexRole = m_sourceModel->roleNames().key("Index", -1); Q_ASSERT(indexRole != -1); const int descriptionRole = m_sourceModel->roleNames().key("Description", -1); Q_ASSERT(descriptionRole != -1); auto *deviceGroup = new QActionGroup(menu); for (int i = 0; i < m_sourceModel->rowCount(); ++i) { const QModelIndex idx = m_sourceModel->index(i, 0); const auto index = idx.data(indexRole).toUInt(); auto *item = menu->addAction(idx.data(descriptionRole).toString()); item->setCheckable(true); item->setChecked(index == stream->deviceIndex()); connect(item, &QAction::triggered, stream, [stream, index] { stream->setDeviceIndex(index); }); deviceGroup->addAction(item); } } } if (menu->isEmpty()) { delete menu; return nullptr; } menu->winId(); menu->windowHandle()->setTransientParent(m_visualParent->window()); return menu; }