mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-21 10:44:01 -04:00
audio: add output card selection
port 1.6
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user