mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-30 15:07:49 -04:00
feat(audio): add output port selection (#2779)
* feat(audio): add output port selection * fix(audio): handle onShowPortSelector in AudioOutputDetail * i18n: add translator context for all audio port selector strings * fix(audio): set isLoading = false in the callback of refreshSinkPorts * chore: remove unused property in AudioPortSelector * fix(audio): parse availability correctly from `pactl list sinks` * fix(audio): address output port review feedback
This commit is contained in:
1 parent
b6436196be
commit
c694d9938c
6 files changed
+501
-7
No files matched your search
@@ -9,6 +9,7 @@ Item {
|
||||
property string expandedSection: ""
|
||||
property var expandedWidgetData: null
|
||||
property var bluetoothCodecSelector: null
|
||||
property var audioPortSelector: null
|
||||
property string screenName: ""
|
||||
property string screenModel: ""
|
||||
|
||||
@@ -212,7 +213,12 @@ Item {
|
||||
|
||||
Component {
|
||||
id: audioOutputDetailComponent
|
||||
AudioOutputDetail {}
|
||||
AudioOutputDetail {
|
||||
onShowPortSelector: node => {
|
||||
if (root.audioPortSelector)
|
||||
root.audioPortSelector.show(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
|
||||
@@ -16,6 +16,7 @@ Column {
|
||||
property var model: null
|
||||
property var expandedWidgetData: null
|
||||
property var bluetoothCodecSelector: null
|
||||
property var audioPortSelector: null
|
||||
property bool darkModeTransitionPending: false
|
||||
property string screenName: ""
|
||||
property string screenModel: ""
|
||||
@@ -379,6 +380,7 @@ Column {
|
||||
expandedSection: active ? root.expandedSection : retainedSection
|
||||
expandedWidgetData: active ? root.expandedWidgetData : retainedWidgetData
|
||||
bluetoothCodecSelector: root.bluetoothCodecSelector
|
||||
audioPortSelector: root.audioPortSelector
|
||||
widgetModel: root.model
|
||||
collapseCallback: root.collapseRequested
|
||||
screenName: root.screenName
|
||||
|
||||
@@ -181,6 +181,7 @@ DankPopout {
|
||||
}
|
||||
implicitHeight: targetImplicitHeight
|
||||
property alias bluetoothCodecSelector: bluetoothCodecSelector
|
||||
property alias audioPortSelector: audioPortSelector
|
||||
|
||||
color: "transparent"
|
||||
clip: true
|
||||
@@ -253,6 +254,7 @@ DankPopout {
|
||||
expandedWidgetData: root.expandedWidgetData
|
||||
model: widgetModel
|
||||
bluetoothCodecSelector: bluetoothCodecSelector
|
||||
audioPortSelector: audioPortSelector
|
||||
colorPickerModal: root.colorPickerModal
|
||||
screenName: root.triggerScreen?.name || ""
|
||||
screenModel: root.triggerScreen?.model || ""
|
||||
@@ -304,6 +306,12 @@ DankPopout {
|
||||
z: 10000
|
||||
}
|
||||
|
||||
AudioPortSelector {
|
||||
id: audioPortSelector
|
||||
anchors.fill: parent
|
||||
z: 10000
|
||||
}
|
||||
|
||||
WidgetConfigOverlay {
|
||||
id: widgetConfigOverlay
|
||||
anchors.fill: parent
|
||||
@@ -332,10 +340,6 @@ DankPopout {
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: audioOutputDetailComponent
|
||||
AudioOutputDetail {}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: audioInputDetailComponent
|
||||
|
||||
@@ -17,6 +17,10 @@ Rectangle {
|
||||
return widgets.some(widget => widget.id === "volumeSlider");
|
||||
}
|
||||
|
||||
signal showPortSelector(var node)
|
||||
|
||||
Component.onCompleted: AudioService.refreshSinkPorts()
|
||||
|
||||
implicitHeight: headerRow.height + (!hasVolumeSliderInCC ? volumeSlider.height : 0) + audioContent.height + Theme.spacingM
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.nestedSurface
|
||||
@@ -210,6 +214,9 @@ Rectangle {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool hasMultiplePorts: AudioService.sinkHasMultiplePorts(modelData)
|
||||
readonly property real portButtonReserve: hasMultiplePorts ? portButton.width + Theme.spacingXS : 0
|
||||
|
||||
width: parent.width
|
||||
height: 50
|
||||
radius: Theme.cornerRadius
|
||||
@@ -239,7 +246,7 @@ Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: {
|
||||
const iconWidth = Theme.iconSize;
|
||||
const pinButtonWidth = pinOutputRow.width + Theme.spacingS * 4 + Theme.spacingM;
|
||||
const pinButtonWidth = pinOutputRow.width + Theme.spacingS * 4 + Theme.spacingM + outputDelegate.portButtonReserve;
|
||||
return parent.parent.width - iconWidth - parent.spacing - pinButtonWidth - Theme.spacingM * 2;
|
||||
}
|
||||
|
||||
@@ -266,7 +273,22 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
id: portButton
|
||||
anchors.right: pinPill.left
|
||||
anchors.rightMargin: Theme.spacingXS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: outputDelegate.hasMultiplePorts
|
||||
buttonSize: 28
|
||||
iconName: "tune"
|
||||
iconSize: 16
|
||||
iconColor: Theme.surfaceText
|
||||
tooltipText: I18n.tr("Port Selection", "audio output port selector button tooltip")
|
||||
onClicked: root.showPortSelector(modelData)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: pinPill
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -342,7 +364,7 @@ Rectangle {
|
||||
MouseArea {
|
||||
id: deviceMouseArea
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: pinOutputRow.width + Theme.spacingS * 4
|
||||
anchors.rightMargin: pinOutputRow.width + Theme.spacingS * 4 + outputDelegate.portButtonReserve
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onPressed: mouse => {
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
LayoutMirroring.enabled: I18n.isRtl
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
property var node: null
|
||||
property bool modalVisible: false
|
||||
property var availablePorts: []
|
||||
property string currentPort: ""
|
||||
property bool isLoading: false
|
||||
|
||||
readonly property bool nodeValid: node !== null && node.isSink
|
||||
|
||||
signal portSelected(string sinkName, string portName)
|
||||
|
||||
function show(sinkNode) {
|
||||
if (!sinkNode || !sinkNode.isSink)
|
||||
return;
|
||||
node = sinkNode;
|
||||
isLoading = true;
|
||||
availablePorts = [];
|
||||
currentPort = "";
|
||||
visible = true;
|
||||
modalVisible = true;
|
||||
|
||||
populateFromCache();
|
||||
AudioService.refreshSinkPorts(() => {
|
||||
root.isLoading = false;
|
||||
});
|
||||
|
||||
Qt.callLater(() => {
|
||||
focusScope.forceActiveFocus();
|
||||
});
|
||||
}
|
||||
|
||||
function populateFromCache() {
|
||||
const info = AudioService.getSinkPorts(node);
|
||||
if (!info) {
|
||||
availablePorts = [];
|
||||
currentPort = "";
|
||||
return;
|
||||
}
|
||||
availablePorts = (info.ports || []).slice().sort((a, b) => b.priority - a.priority);
|
||||
currentPort = info.active || "";
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (!modalVisible)
|
||||
return;
|
||||
modalVisible = false;
|
||||
}
|
||||
|
||||
function portDescription(portName) {
|
||||
const port = availablePorts.find(p => p.name === portName);
|
||||
return port ? port.description : portName;
|
||||
}
|
||||
|
||||
function selectPort(portName) {
|
||||
if (!nodeValid || isLoading)
|
||||
return;
|
||||
|
||||
const port = availablePorts.find(p => p.name === portName);
|
||||
if (!port || port.availability === "no")
|
||||
return;
|
||||
|
||||
const capturedName = node.name;
|
||||
isLoading = true;
|
||||
AudioService.setSinkPort(capturedName, portName, function (success, message) {
|
||||
isLoading = false;
|
||||
if (!root.node || root.node.name !== capturedName)
|
||||
return;
|
||||
|
||||
if (success) {
|
||||
root.portSelected(capturedName, portName);
|
||||
ToastService.showToast(message, ToastService.levelInfo);
|
||||
Qt.callLater(root.hide);
|
||||
return;
|
||||
}
|
||||
ToastService.showToast(message, ToastService.levelError);
|
||||
});
|
||||
}
|
||||
|
||||
onNodeValidChanged: {
|
||||
if (modalVisible && !nodeValid) {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: AudioService
|
||||
function onSinkPortsChanged() {
|
||||
if (!root.nodeValid)
|
||||
return;
|
||||
root.populateFromCache();
|
||||
root.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
visible: false
|
||||
anchors.fill: parent
|
||||
z: 2000
|
||||
|
||||
MouseArea {
|
||||
id: modalBlocker
|
||||
anchors.fill: parent
|
||||
visible: root.visible
|
||||
enabled: root.visible
|
||||
hoverEnabled: true
|
||||
preventStealing: true
|
||||
propagateComposedEvents: false
|
||||
|
||||
onClicked: root.hide()
|
||||
onWheel: wheel => {
|
||||
wheel.accepted = true;
|
||||
}
|
||||
onPositionChanged: mouse => {
|
||||
mouse.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: modalBackground
|
||||
anchors.fill: parent
|
||||
color: Qt.rgba(0, 0, 0, BlurService.enabled ? 0.72 : 0.5)
|
||||
opacity: modalVisible ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Theme.mediumDuration
|
||||
easing.type: Theme.emphasizedEasing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
id: focusScope
|
||||
|
||||
anchors.fill: parent
|
||||
focus: root.visible
|
||||
enabled: root.visible
|
||||
|
||||
Keys.onEscapePressed: event => {
|
||||
root.hide();
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: modalContent
|
||||
anchors.centerIn: parent
|
||||
width: 320
|
||||
height: contentColumn.implicitHeight + Theme.spacingL * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.withAlpha(Theme.surfaceContainer, BlurService.enabled ? 0.96 : Theme.popupTransparency)
|
||||
border.color: BlurService.enabled ? BlurService.borderColor : Theme.outlineMedium
|
||||
border.width: BlurService.enabled ? BlurService.borderWidth : Theme.layerOutlineWidth
|
||||
opacity: modalVisible ? 1 : 0
|
||||
scale: modalVisible ? 1 : 0.9
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
preventStealing: true
|
||||
propagateComposedEvents: false
|
||||
onClicked: mouse => {
|
||||
mouse.accepted = true;
|
||||
}
|
||||
onWheel: wheel => {
|
||||
wheel.accepted = true;
|
||||
}
|
||||
onPositionChanged: mouse => {
|
||||
mouse.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: contentColumn
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Theme.spacingL
|
||||
spacing: Theme.spacingM
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
DankIcon {
|
||||
name: root.node ? AudioService.sinkIcon(root.node) : "speaker"
|
||||
size: Theme.iconSize + 4
|
||||
color: Theme.primary
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXXS
|
||||
|
||||
StyledText {
|
||||
text: root.node ? AudioService.displayName(root.node) : ""
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Port Selection", "audio port selector modal subtitle")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceTextMedium
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.outlineLight
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: {
|
||||
if (root.isLoading)
|
||||
return I18n.tr("Loading ports...", "audio port selector loading state");
|
||||
if (root.availablePorts.length === 0)
|
||||
return I18n.tr("No ports found", "audio port selector empty state");
|
||||
return I18n.tr("Current: %1", "audio port selector active port label, %1 is the port name").arg(root.portDescription(root.currentPort));
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: root.isLoading ? Theme.primary : Theme.surfaceTextMedium
|
||||
font.weight: Font.Medium
|
||||
wrapMode: Text.WordWrap
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
visible: root.availablePorts.length > 0
|
||||
|
||||
Repeater {
|
||||
model: root.availablePorts
|
||||
|
||||
Rectangle {
|
||||
required property var modelData
|
||||
|
||||
readonly property bool isCurrent: modelData.name === root.currentPort
|
||||
readonly property bool unavailable: modelData.availability === "no"
|
||||
|
||||
width: parent.width
|
||||
height: 48
|
||||
radius: Theme.cornerRadius
|
||||
opacity: unavailable ? 0.45 : 1
|
||||
color: {
|
||||
if (isCurrent)
|
||||
return Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency);
|
||||
else if (portMouseArea.containsMouse)
|
||||
return Theme.surfaceHover;
|
||||
else
|
||||
return "transparent";
|
||||
}
|
||||
border.color: "transparent"
|
||||
border.width: 0
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.right: portCheck.left
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXXS
|
||||
|
||||
StyledText {
|
||||
text: modelData.description
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: isCurrent ? Theme.primary : Theme.surfaceText
|
||||
font.weight: isCurrent ? Font.Medium : Font.Normal
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: {
|
||||
if (modelData.availability === "no")
|
||||
return I18n.tr("Unavailable", "audio port availability status");
|
||||
if (modelData.availability === "yes")
|
||||
return I18n.tr("Available", "audio port availability status");
|
||||
return "";
|
||||
}
|
||||
visible: text.length > 0
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceTextMedium
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
DankIcon {
|
||||
id: portCheck
|
||||
name: "check"
|
||||
size: Theme.iconSize - 4
|
||||
color: Theme.primary
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: isCurrent
|
||||
}
|
||||
|
||||
DankRipple {
|
||||
id: portRipple
|
||||
cornerRadius: parent.radius
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: portMouseArea
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
enabled: !isCurrent && !unavailable && !root.isLoading
|
||||
onPressed: mouse => portRipple.trigger(mouse.x, mouse.y)
|
||||
onClicked: {
|
||||
root.selectPort(modelData.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Theme.mediumDuration
|
||||
easing.type: Theme.emphasizedEasing
|
||||
onRunningChanged: {
|
||||
if (!running && !root.modalVisible) {
|
||||
root.visible = false;
|
||||
root.node = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on scale {
|
||||
NumberAnimation {
|
||||
duration: Theme.mediumDuration
|
||||
easing.type: Theme.emphasizedEasing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,8 @@ Singleton {
|
||||
property string wireplumberConfigPath: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + "/wireplumber/wireplumber.conf.d/51-dms-audio-aliases.conf"
|
||||
property bool wireplumberReloading: false
|
||||
|
||||
property var sinkPorts: ({})
|
||||
|
||||
readonly property int sinkMaxVolume: {
|
||||
const name = sink?.name ?? "";
|
||||
if (!name)
|
||||
@@ -158,6 +160,106 @@ Singleton {
|
||||
return false;
|
||||
}
|
||||
|
||||
function refreshSinkPorts(callback) {
|
||||
// ensure that parsed labels are in English
|
||||
Proc.runCommand("audio-list-sink-ports", ["env", "LC_ALL=C", "pactl", "list", "sinks"], (output, exitCode) => {
|
||||
if (exitCode === 0)
|
||||
root.sinkPorts = root.parseSinkPorts(output);
|
||||
if (callback)
|
||||
callback();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function getSinkPorts(node) {
|
||||
return sinkPorts[node?.name] ?? null;
|
||||
}
|
||||
|
||||
function sinkHasMultiplePorts(node) {
|
||||
return (sinkPorts[node?.name]?.ports?.length ?? 0) > 1;
|
||||
}
|
||||
|
||||
function setSinkPort(sinkName, portName, callback) {
|
||||
Proc.runCommand("audio-set-sink-port", ["env", "LC_ALL=C", "pactl", "set-sink-port", sinkName, portName], (output, exitCode) => {
|
||||
const ok = exitCode === 0;
|
||||
if (callback)
|
||||
callback(ok, ok ? I18n.tr("Port switched", "audio sink port switched successful message") : (output || I18n.tr("Failed to switch port", "audio sink port switch failure message")));
|
||||
if (ok)
|
||||
Qt.callLater(() => root.refreshSinkPorts());
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function parseSinkPorts(text) {
|
||||
const result = {};
|
||||
const lines = (text || "").split("\n");
|
||||
let current = null;
|
||||
let inPorts = false;
|
||||
|
||||
function commit() {
|
||||
if (current && current.name)
|
||||
result[current.name] = {
|
||||
active: current.active,
|
||||
ports: current.ports
|
||||
};
|
||||
}
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (/^Sink #\d+/.test(line)) {
|
||||
commit();
|
||||
current = {
|
||||
name: null,
|
||||
active: "",
|
||||
ports: []
|
||||
};
|
||||
inPorts = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!current)
|
||||
continue;
|
||||
|
||||
if (line.startsWith("Name:")) {
|
||||
current.name = line.substring(5).trim();
|
||||
inPorts = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line === "Ports:") {
|
||||
inPorts = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("Active Port:")) {
|
||||
current.active = line.substring(12).trim();
|
||||
inPorts = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inPorts) {
|
||||
// name up to first ": ", meta is the trailing "(...)", description is whatever's in between
|
||||
const match = line.match(/^(.+?):\s+(.*)\s+\(([^()]*)\)$/);
|
||||
if (!match)
|
||||
continue;
|
||||
const meta = match[3];
|
||||
let availability = "unknown";
|
||||
if (meta.includes("not available"))
|
||||
availability = "no";
|
||||
else if (/\bavailable\b/.test(meta))
|
||||
availability = "yes";
|
||||
current.ports.push({
|
||||
name: match[1],
|
||||
description: match[2],
|
||||
availability: availability,
|
||||
priority: parseInt(meta.match(/priority:?\s*(\d+)/)?.[1] ?? "0", 10)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
commit();
|
||||
return result;
|
||||
}
|
||||
|
||||
function cycleAudioOutputDirection(forward) {
|
||||
const sinks = getAvailableSinks();
|
||||
if (sinks.length < 2)
|
||||
|
||||
Reference in new issue
Block a user