audio: add output card selection

port 1.6
This commit is contained in:
bbedward
2026-09-06 13:14:21 -04:00
parent f3d9d73db1
commit 8188f53da5
2 changed files with 299 additions and 1 deletions
@@ -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
+223
View File
@@ -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)