Commit 48037d71 authored by Kai Uwe Broulik's avatar Kai Uwe Broulik 🍇
Browse files

Rewrite ListItem context menu and add card profile selection

This moves the menu logic into C++ to make `ListItemBase` less messy.
More importantly, it adds card profile selection so that you can, for instance,
quickly switch Bluetooth devices between A2DP and HFP profiles.
It also now uses radio buttons to denote mutually excusive options.

BUG: 372562
FIXED-IN: 5.22.0
parent aa7e7806
......@@ -24,7 +24,7 @@ import QtQuick.Controls 1.0
import QtQuick.Layouts 1.0
import org.kde.kquickcontrolsaddons 2.0
import org.kde.plasma.components 2.0 as PlasmaComponents // for contextMenu and ListItem
import org.kde.plasma.components 2.0 as PlasmaComponents // for ListItem
import org.kde.plasma.components 3.0 as PlasmaComponents3
import org.kde.plasma.core 2.0 as PlasmaCore
import org.kde.plasma.extras 2.0 as PlasmaExtras
......@@ -82,7 +82,7 @@ PlasmaComponents.ListItem {
}
onDragStarted: {
draggedStream = PulseObject;
draggedStream = model.PulseObject;
beginMoveStream(type == "sink-input" ? "sink" : "source");
}
......@@ -119,9 +119,9 @@ PlasmaComponents.ListItem {
Layout.leftMargin: LayoutMirroring.enabled ? 0 : Math.round((muteButton.width - defaultButton.indicator.width) / 2)
Layout.rightMargin: LayoutMirroring.enabled ? Math.round((muteButton.width - defaultButton.indicator.width) / 2) : 0
spacing: PlasmaCore.Units.smallSpacing + Math.round((muteButton.width - defaultButton.indicator.width) / 2)
checked: PulseObject.default ? PulseObject.default : false
checked: model.PulseObject.hasOwnProperty("default") ? model.PulseObject.default : false
visible: (type == "sink" && sinkView.model.count > 1) || (type == "source" && sourceView.model.count > 1)
onClicked: PulseObject.default = true;
onClicked: model.PulseObject.default = true;
}
PlasmaComponents3.Label {
......@@ -140,35 +140,13 @@ PlasmaComponents.ListItem {
SmallToolButton {
id: contextMenuButton
icon.name: "application-menu"
checkable: true
checked: contextMenu.visible && contextMenu.visualParent === this
onClicked: {
contextMenu.visualParent = this;
contextMenu.showRelative();
}
visible: {
// if it is a sink type and there are at least two sink devices. Same for source type.
if (((type == "sink-input" || type == "sink") && sinkView.model.count > 1)
|| ((type == "source-input" || type == "source") && sourceView.model.count > 1)) {
return true;
} else if (PulseObject.ports && PulseObject.ports.length > 1) {
// In case an unavailable port is active.
if (PulseObject.ports[PulseObject.activePortIndex].availability == Port.Unavailable) {
return true;
}
// If there are at least two available ports.
var foundFirstAvailablePort = false;
for (var i = 0; i < PulseObject.ports.length; i++) {
if (PulseObject.ports[i].availability != Port.Unavailable) {
if (foundFirstAvailablePort) {
return true;
} else {
foundFirstAvailablePort = true;
}
}
}
}
return false;
contextMenu.openRelative();
}
visible: contextMenu.hasContent
PlasmaComponents3.ToolTip {
text: i18n("Show additional options for %1", defaultButton.text)
}
......@@ -228,7 +206,7 @@ PlasmaComponents.ListItem {
opacity: meter.available && (meter.volume > 0 || animation.running)
VolumeMonitor {
id: meter
target: parent.visible ? PulseObject : null
target: parent.visible ? model.PulseObject : null
}
Behavior on width {
NumberAnimation {
......@@ -285,7 +263,7 @@ PlasmaComponents.ListItem {
}
PlasmaComponents3.Label {
id: percentText
readonly property real value: PulseObject.volume > slider.to ? PulseObject.volume : slider.value
readonly property real value: model.PulseObject.volume > slider.to ? model.PulseObject.volume : slider.value
readonly property real displayValue: Math.round(value / PulseAudio.NormalVolume * 100.0)
Layout.alignment: Qt.AlignHCenter
Layout.minimumWidth: percentMetrics.advanceWidth
......@@ -341,7 +319,7 @@ PlasmaComponents.ListItem {
onPressed: {
if (mouse.button === Qt.RightButton) {
contextMenu.visualParent = this;
contextMenu.show(mouse.x, mouse.y);
contextMenu.open(mouse.x, mouse.y);
}
}
onClicked: {
......@@ -352,140 +330,28 @@ PlasmaComponents.ListItem {
}
}
PlasmaComponents.ContextMenu {
ListItemMenu {
id: contextMenu
placement: PlasmaCore.Types.BottomPosedLeftAlignedPopup
onStatusChanged: {
if (status == PlasmaComponents.DialogStatus.Closed) {
contextMenuButton.checked = false;
pulseObject: model.PulseObject
cardModel: paCardModel
itemType: {
switch (item.type) {
case "sink":
return ListItemMenu.Sink;
case "sink-input":
return ListItemMenu.SinkInput;
case "source":
return ListItemMenu.Source;
case "source-input":
return ListItemMenu.SourceOutput;
}
}
function newMenuItem() {
return Qt.createQmlObject("import org.kde.plasma.components 2.0 as PlasmaComponents; PlasmaComponents.MenuItem {}", contextMenu);
}
function loadDynamicActions() {
contextMenu.clearMenuItems();
// Switch all streams of the relevant kind to this device
if (type == "source" && sourceView.model.count > 1) {
menuItem = newMenuItem();
menuItem.text = i18n("Record all audio via this device");
menuItem.icon = "mic-on" // or "mic-ready" // or "audio-input-microphone-symbolic"
menuItem.clicked.connect(function() {
PulseObject.switchStreams();
});
contextMenu.addMenuItem(menuItem);
} else if (type == "sink" && sinkView.model.count > 1) {
menuItem = newMenuItem();
menuItem.text = i18n("Play all audio via this device");
menuItem.icon = "audio-on" // or "audio-ready" // or "audio-speakers-symbolic"
menuItem.clicked.connect(function() {
PulseObject.switchStreams();
});
contextMenu.addMenuItem(menuItem);
sourceModel: {
if (item.type.includes("sink")) {
return sinkView.model;
} else if (item.type.includes("source")) {
return sourceView.model;
}
// Ports
// Intentionally only shown when there are at least two ports.
if (PulseObject.ports && PulseObject.ports.length > 1) {
var menuItem = newMenuItem();
menuItem.text = i18nc("Heading for a list of ports of a device (for example built-in laptop speakers or a plug for headphones)", "Ports");
menuItem.section = true;
contextMenu.addMenuItem(menuItem);
var setActivePort = function(portIndex) {
return function() {
PulseObject.activePortIndex = portIndex;
};
};
// If an unavailable port is active, show all the ports.
if (PulseObject.ports[PulseObject.activePortIndex].availability == Port.Unavailable) {
for (var i = 0; i < PulseObject.ports.length; i++) {
var port = PulseObject.ports[i];
var menuItem = newMenuItem();
if (port.availability == Port.Unavailable) {
if (port.name == "analog-output-speaker" || port.name == "analog-input-microphone-internal") {
menuItem.text = i18nc("Port is unavailable", "%1 (unavailable)", port.description);
} else {
menuItem.text = i18nc("Port is unplugged", "%1 (unplugged)", port.description);
}
} else {
menuItem.text = port.description;
}
menuItem.checkable = true;
menuItem.checked = i === PulseObject.activePortIndex;
menuItem.clicked.connect(setActivePort(i));
contextMenu.addMenuItem(menuItem);
}
} else { // Hide ports that are unavailable and only show if there are at least two available
var menuItemsPorts = [];
var availablePorts = 0;
for (var i = 0; i < PulseObject.ports.length; i++) {
var port = PulseObject.ports[i];
if (port.availability != Port.Unavailable) {
menuItemsPorts[availablePorts] = newMenuItem();
menuItemsPorts[availablePorts].text = port.description;
menuItemsPorts[availablePorts].checkable = true;
menuItemsPorts[availablePorts].checked = i === PulseObject.activePortIndex;
menuItemsPorts[availablePorts].clicked.connect(setActivePort(i));
contextMenu.addMenuItem(menuItemsPorts[availablePorts]);
availablePorts++;
}
}
if (availablePorts <= 1){
menuItem.visible = false;
for (var i = 0; i < availablePorts; i++) {
menuItemsPorts[i].visible = false;
}
}
}
}
// Choose output / input device
// Intentionally only shown when there are at least two options
if ((type == "sink-input" && sinkView.model.count > 1) || (type == "source-input" && sourceView.model.count > 1)) {
var menuItem = newMenuItem();
if (type == "sink-input") {
menuItem.text = i18nc("Heading for a list of possible output devices (speakers, headphones, ...) to choose", "Play audio using");
} else {
menuItem.text = i18nc("Heading for a list of possible input devices (built-in microphone, headset, ...) to choose", "Record audio using");
}
menuItem.section = true;
contextMenu.addMenuItem(menuItem);
var sModel = type == "sink-input" ? sinkView.model : sourceView.model;
for (var i = 0; i < sModel.count; ++i) {
const modelIndex = sModel.index(i, 0)
const index = sModel.data(modelIndex, sModel.role("Index"))
var menuItem = newMenuItem();
menuItem.text = sModel.data(modelIndex, sModel.role("Description"));
menuItem.enabled = true;
menuItem.checkable = true;
menuItem.checked = index === PulseObject.deviceIndex;
var setActiveSink = function(sinkIndex) {
return function() {
PulseObject.deviceIndex = sinkIndex;
};
};
menuItem.clicked.connect(setActiveSink(index));
contextMenu.addMenuItem(menuItem);
}
}
}
function show(x, y) {
loadDynamicActions();
open(x, y);
}
function showRelative(){
loadDynamicActions();
openRelative();
}
}
}
......@@ -277,6 +277,10 @@ Item {
sourceModel: paSourceModel
}
CardModel {
id: paCardModel
}
Plasmoid.compactRepresentation: PlasmaCore.IconItem {
source: plasmoid.icon
active: mouseArea.containsMouse
......
......@@ -25,6 +25,7 @@ set(cpp_SRCS
module.cpp
canberracontext.cpp
qml/globalactioncollection.cpp
qml/listitemmenu.cpp
qml/plugin.cpp
qml/microphoneindicator.cpp
qml/volumeosd.cpp
......
/*
Copyright 2021 Kai Uwe Broulik <kde@broulik.de>
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) version 3, or any
later version accepted by the membership of KDE e.V. (or its
successor approved by the membership of KDE e.V.), which shall
act as a proxy defined in Section 6 of version 3 of the license.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library. If not, see <http://www.gnu.org/licenses/>.
*/
#include "listitemmenu.h"
#include <QAbstractItemModel>
#include <QMenu>
#include <QQuickItem>
#include <QQuickWindow>
#include <QWindow>
#include <KLocalizedString>
#include "card.h"
#include "debug.h"
#include "device.h"
#include "port.h"
#include "pulseaudio.h"
#include "pulseobject.h"
#include "stream.h"
using namespace QPulseAudio;
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();
}
}
QPulseAudio::PulseObject *ListItemMenu::pulseObject() const
{
return m_pulseObject.data();
}
void ListItemMenu::setPulseObject(QPulseAudio::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<QPulseAudio::Device *>(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();
}
QPulseAudio::CardModel *ListItemMenu::cardModel() const
{
return m_cardModel.data();
}
void ListItemMenu::setCardModel(QPulseAudio::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 QVector<int> &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<QPulseAudio::Device *>(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<quint32>(-1)) {
auto *activePort = static_cast<Port *>(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<Port *>(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<Card *>(cardIdx.data(cardModelPulseObjectRole).value<QObject *>());
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<Profile *>(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);
}
static int getModelRole(QObject *model, const QByteArray &name)
{
// Can either be an AbstractModel, then it's easy
if (auto *abstractModel = qobject_cast<AbstractModel *>(model)) {
return abstractModel->role(name);
}
// or that PulseObjectFilterModel from QML where everything is a QVariant...
QVariant roleVariant;
bool ok = QMetaObject::invokeMethod(model, "role", Q_RETURN_ARG(QVariant, roleVariant), Q_ARG(QVariant, QVariant(name)));
if (!ok) {
qCCritical(PLASMAPA) << "Failed to invoke 'role' on" << model;
return -1;
}
int role = roleVariant.toInt(&ok);
if (!ok) {
qCCritical(PLASMAPA) << "Return value from 'role' is bogus" << roleVariant;
return -1;
}
return role;
}
QMenu *ListItemMenu::createMenu()
{
if (m_visible) {
return nullptr;
}
if (!m_visualParent || !m_visualParent->window()) {
qCWarning(PLASMAPA) << "Cannot prepare menu without visualParent or a window";