diff --git a/quickshell/Modules/ControlCenter/Components/DetailHost.qml b/quickshell/Modules/ControlCenter/Components/DetailHost.qml index 5013d110f..8dac628c2 100644 --- a/quickshell/Modules/ControlCenter/Components/DetailHost.qml +++ b/quickshell/Modules/ControlCenter/Components/DetailHost.qml @@ -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 { diff --git a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml index e3e8d60d3..0980b0b6c 100644 --- a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml +++ b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml @@ -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 diff --git a/quickshell/Modules/ControlCenter/ControlCenterPopout.qml b/quickshell/Modules/ControlCenter/ControlCenterPopout.qml index c2e6e79cb..567bb0dcd 100644 --- a/quickshell/Modules/ControlCenter/ControlCenterPopout.qml +++ b/quickshell/Modules/ControlCenter/ControlCenterPopout.qml @@ -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 diff --git a/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml b/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml index 974377838..33e5d4f68 100644 --- a/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml +++ b/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml @@ -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 => { diff --git a/quickshell/Modules/ControlCenter/Details/AudioPortSelector.qml b/quickshell/Modules/ControlCenter/Details/AudioPortSelector.qml new file mode 100644 index 000000000..91378e111 --- /dev/null +++ b/quickshell/Modules/ControlCenter/Details/AudioPortSelector.qml @@ -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 + } + } + } +} diff --git a/quickshell/Services/AudioService.qml b/quickshell/Services/AudioService.qml index f01f20e6f..fd5f1d413 100644 --- a/quickshell/Services/AudioService.qml +++ b/quickshell/Services/AudioService.qml @@ -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)