From 8188f53da5eec2d64c145923e46c05be4370aee8 Mon Sep 17 00:00:00 2001 From: bbedward Date: Sun, 6 Sep 2026 13:14:21 -0400 Subject: [PATCH] audio: add output card selection port 1.6 --- .../Details/AudioOutputDetail.qml | 77 +++++- quickshell/Services/AudioService.qml | 223 ++++++++++++++++++ 2 files changed, 299 insertions(+), 1 deletion(-) diff --git a/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml b/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml index 59369581a..442e38e6b 100644 --- a/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml +++ b/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml @@ -19,7 +19,10 @@ Rectangle { signal showPortSelector(var node) - Component.onCompleted: AudioService.refreshSinkPorts() + Component.onCompleted: { + AudioService.refreshSinkPorts(); + AudioService.refreshCards(); + } implicitHeight: headerRow.height + (!hasVolumeSliderInCC ? volumeSlider.height : 0) + audioContent.height + Theme.spacingM radius: Theme.cornerRadius @@ -365,6 +368,78 @@ Rectangle { } } } + Repeater { + model: ScriptModel { + values: AudioService.switchableOutputPorts + } + + delegate: Rectangle { + id: portDelegate + required property var modelData + + width: parent.width + height: 50 + radius: Theme.cornerRadius + color: portMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight + border.color: Theme.outlineLight + border.width: 1 + + DankRipple { + id: portRipple + cornerRadius: portDelegate.radius + } + + Row { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + spacing: Theme.spacingS + + DankIcon { + name: portDelegate.modelData.icon + size: Theme.iconSize - 4 + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - Theme.iconSize - parent.spacing + + StyledText { + text: portDelegate.modelData.title + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + elide: Text.ElideRight + width: parent.width + wrapMode: Text.NoWrap + horizontalAlignment: Text.AlignLeft + } + + StyledText { + text: portDelegate.modelData.cardDescription + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + elide: Text.ElideRight + width: parent.width + wrapMode: Text.NoWrap + horizontalAlignment: Text.AlignLeft + } + } + } + + MouseArea { + id: portMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: mouse => portRipple.trigger(mouse.x, mouse.y) + onClicked: AudioService.activateOutputPort(portDelegate.modelData, (ok, message) => ToastService.showToast(message, ok ? ToastService.levelInfo : ToastService.levelError)) + } + } + } Row { id: playbackHeaderRow anchors.left: parent.left diff --git a/quickshell/Services/AudioService.qml b/quickshell/Services/AudioService.qml index 5e3ee9df3..a4b910ac0 100644 --- a/quickshell/Services/AudioService.qml +++ b/quickshell/Services/AudioService.qml @@ -56,6 +56,9 @@ Singleton { property bool wireplumberReloading: false property var sinkPorts: ({}) + property var cards: [] + property var pendingCardSwitch: null + readonly property var switchableOutputPorts: root.collectSwitchableOutputPorts(root.cards) readonly property int sinkMaxVolume: { const name = sink?.name ?? ""; @@ -127,6 +130,7 @@ Singleton { target: Pipewire.nodes function onValuesChanged() { root.rebuildTypedNodeLists(); + root.adoptPendingCardSink(); } } @@ -271,6 +275,225 @@ Singleton { return result; } + function refreshCards(callback) { + Proc.runCommand("audio-list-cards", ["env", "LC_ALL=C", "pactl", "list", "cards"], (output, exitCode) => { + root.cards = exitCode === 0 ? root.parseCards(output) : []; + if (callback) + callback(); + }, 0); + } + + function parseCards(text) { + const cards = []; + let card = null; + let port = null; + let section = ""; + + function commit() { + if (!card?.name) + return; + const props = card.props; + card.description = props["device.description"] || props["device.nick"] || props["alsa.card_name"] || card.name; + cards.push(card); + } + + function applyProperty(target, line) { + const match = line.match(/^([^=\s]+)\s*=\s*"(.*)"$/); + if (match) + target[match[1]] = match[2]; + } + + function addProfile(target, line) { + const match = line.match(/^(.+?):\s+(.*)\s+\(([^()]*)\)$/); + if (!match) + return; + const meta = match[3]; + target.profiles[match[1]] = { + description: match[2], + priority: parseInt(meta.match(/priority:?\s*(\d+)/)?.[1] ?? "0", 10), + available: !/available:\s*no/.test(meta) + }; + } + + function addPort(target, line) { + const match = line.match(/^(.+?):\s+(.*)\s+\(([^()]*)\)$/); + if (!match) + return null; + const meta = match[3]; + let availability = "unknown"; + if (meta.includes("not available")) + availability = "no"; + else if (/\bavailable\b/.test(meta)) + availability = "yes"; + const entry = { + name: match[1], + description: match[2], + type: (meta.match(/type:\s*([^,]+)/)?.[1] ?? "").trim().toLowerCase(), + availability: availability, + profiles: [], + props: {} + }; + target.ports.push(entry); + return entry; + } + + for (const rawLine of (text || "").split("\n")) { + const line = rawLine.trim(); + + if (/^Card #\d+/.test(line)) { + commit(); + card = { + name: "", + description: "", + activeProfile: "", + profiles: {}, + ports: [], + props: {} + }; + port = null; + section = ""; + continue; + } + + if (!card) + continue; + + if (line.startsWith("Name:")) { + card.name = line.substring(5).trim(); + section = ""; + continue; + } + if (line === "Properties:") { + section = port ? "portProps" : "cardProps"; + continue; + } + if (line === "Profiles:") { + section = "profiles"; + continue; + } + if (line === "Ports:") { + section = "ports"; + continue; + } + if (line.startsWith("Active Profile:")) { + card.activeProfile = line.substring(15).trim(); + section = ""; + continue; + } + if (line.startsWith("Part of profile(s):")) { + if (port) + port.profiles = line.substring(19).split(",").map(s => s.trim()).filter(s => s); + section = "ports"; + continue; + } + + switch (section) { + case "cardProps": + applyProperty(card.props, line); + break; + case "portProps": + if (port) + applyProperty(port.props, line); + break; + case "profiles": + addProfile(card, line); + break; + case "ports": + port = addPort(card, line) ?? port; + break; + } + } + + commit(); + return cards; + } + + function bestOutputProfile(card, port) { + const candidates = port.profiles.filter(name => name.startsWith("output:") && card.profiles[name]?.available); + if (candidates.length === 0) + return ""; + candidates.sort((a, b) => card.profiles[b].priority - card.profiles[a].priority); + return candidates[0]; + } + + function collectSwitchableOutputPorts(cards) { + const entries = []; + for (const card of cards || []) { + if (!card.activeProfile || card.activeProfile === "pro-audio") + continue; + for (const port of card.ports) { + if (port.availability === "no" || port.profiles.includes(card.activeProfile)) + continue; + const profile = bestOutputProfile(card, port); + if (!profile) + continue; + const product = port.props["device.product.name"] || ""; + entries.push({ + cardName: card.name, + cardDescription: card.description, + portName: port.name, + profile: profile, + icon: port.type === "hdmi" ? "tv" : "speaker", + title: product ? `${port.description} [${product}]` : port.description + }); + } + } + return entries; + } + + function sinkBelongsToCard(node, cardName) { + if (!node || node.isStream || !cardName) + return false; + if (node.properties?.["device.name"] === cardName) + return true; + if (!cardName.startsWith("alsa_card.")) + return false; + return (node.name || "").startsWith("alsa_output." + cardName.substring(10) + "."); + } + + function activateOutputPort(entry, callback) { + if (!entry?.cardName || !entry.profile) + return; + const previousSinks = Pipewire.nodes.values.filter(n => n.isSink && sinkBelongsToCard(n, entry.cardName)).map(n => n.name); + Proc.runCommand("audio-set-card-profile", ["env", "LC_ALL=C", "pactl", "set-card-profile", entry.cardName, entry.profile], (output, exitCode) => { + const ok = exitCode === 0; + if (ok) { + root.pendingCardSwitch = { + cardName: entry.cardName, + portName: entry.portName, + exclude: previousSinks + }; + pendingCardSwitchTimer.restart(); + root.adoptPendingCardSink(); + } + if (callback) + callback(ok, ok ? I18n.tr("Output switched", "audio card profile switched successful message") : (output || I18n.tr("Failed to switch output", "audio card profile switch failure message"))); + Qt.callLater(() => { + root.refreshCards(); + root.refreshSinkPorts(); + }); + }, 0); + } + + function adoptPendingCardSink() { + const pending = pendingCardSwitch; + if (!pending) + return; + const node = Pipewire.nodes.values.find(n => n.isSink && sinkBelongsToCard(n, pending.cardName) && !pending.exclude.includes(n.name)); + if (!node) + return; + pendingCardSwitch = null; + pendingCardSwitchTimer.stop(); + setSink(node); + setSinkPort(node.name, pending.portName); + } + + Timer { + id: pendingCardSwitchTimer + interval: 5000 + onTriggered: root.pendingCardSwitch = null + } + function cycleAudioOutputDirection(forward) { const sinks = getAvailableSinks(); if (sinks.length < 2)