fix #3863 and allow user to disable / enable audio cards (#4019)

port 2.0
This commit is contained in:
Leonard authored and GitHub committed 2026-10-30 09:13:53 -04:00
1 parent bca7513825
commit f65e934e1b
5 files changed
+310 -95

No files matched your search

@@ -100,7 +100,7 @@ Item {
DActionButton {
anchors.verticalCenter: parent.verticalCenter
visible: AudioService.sinkHasMultiplePorts(deviceRow.modelData)
visible: (AudioService.outputChoices[deviceRow.modelData.name]?.length ?? 0) > 1
buttonSize: Theme.buttonHeightXS
iconSize: Theme.iconSizeMedium
iconName: "tune"
@@ -116,22 +116,6 @@ Item {
}
}
}
Repeater {
model: ScriptModel {
values: AudioService.switchableOutputPorts
}
CcListRow {
required property var modelData
iconName: modelData.icon
title: modelData.title
subtitle: modelData.cardDescription
clickable: true
onClicked: AudioService.activateOutputPort(modelData, (ok, message) => ToastService.showToast(message, ok ? ToastService.levelInfo : ToastService.levelError))
}
}
}
CcSectionLabel {
@@ -9,13 +9,10 @@ CcSheetDialog {
id: root
property var node: null
property var availablePorts: []
property string currentPort: ""
property bool isLoading: false
readonly property bool nodeValid: node !== null && node.isSink
signal portSelected(string sinkName, string portName)
readonly property var choices: nodeValid ? (AudioService.outputChoices[node.name] ?? []) : []
iconName: node ? AudioService.sinkIcon(node) : "speaker"
title: node ? AudioService.displayName(node) : ""
@@ -23,9 +20,9 @@ CcSheetDialog {
statusText: {
if (isLoading)
return I18n.tr("Loading ports...", "audio port selector loading state");
if (availablePorts.length === 0)
if (choices.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(portDescription(currentPort));
return I18n.tr("Current: %1", "audio port selector active port label, %1 is the port name").arg(choices.find(c => c.active)?.title ?? "");
}
statusColor: isLoading ? Theme.primary : Theme.surfaceVariantText
@@ -34,51 +31,22 @@ CcSheetDialog {
return;
node = sinkNode;
isLoading = true;
availablePorts = [];
currentPort = "";
populateFromCache();
AudioService.refreshCards();
AudioService.refreshSinkPorts(() => {
root.isLoading = false;
});
present();
}
function populateFromCache() {
const info = AudioService.getSinkPorts(node);
if (!info) {
availablePorts = [];
currentPort = "";
function select(choice) {
if (!nodeValid || isLoading || choice.availability === "no")
return;
}
availablePorts = (info.ports || []).slice().sort((a, b) => b.priority - a.priority);
currentPort = info.active || "";
isLoading = 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, (success, message) => {
isLoading = false;
if (!root.node || root.node.name !== capturedName)
return;
if (!success) {
ToastService.showToast(message, ToastService.levelError);
return;
}
root.portSelected(capturedName, portName);
ToastService.showToast(message, ToastService.levelInfo);
root.dismiss();
AudioService.activateOutputChoice(choice, (success, message) => {
root.isLoading = false;
ToastService.showToast(message, success ? ToastService.levelInfo : ToastService.levelError);
if (success)
root.dismiss();
});
}
@@ -89,27 +57,18 @@ CcSheetDialog {
onDismissed: node = null
readonly property var audioSinkPorts: AudioService.sinkPorts
onAudioSinkPortsChanged: {
if (!nodeValid)
return;
populateFromCache();
isLoading = false;
}
CcGroup {
visible: root.availablePorts.length > 0
visible: root.choices.length > 0
Repeater {
model: root.availablePorts
model: root.choices
CcListRow {
required property var modelData
readonly property bool unavailable: modelData.availability === "no"
title: modelData.description
title: modelData.title
subtitle: {
if (unavailable)
return I18n.tr("Unavailable", "audio port availability status");
@@ -117,10 +76,10 @@ CcSheetDialog {
return I18n.tr("Available", "audio port availability status");
return "";
}
active: modelData.name === root.currentPort
active: modelData.active
enabled: !unavailable && !root.isLoading
clickable: !active
onClicked: root.selectPort(modelData.name)
onClicked: root.select(modelData)
}
}
}
+131 -10
View File
@@ -1,5 +1,6 @@
import QtQuick
import qs.Common
import qs.Modals.Common
import qs.Services
import qs.DCommon.Widgets
import qs.Modules.Settings.Widgets
@@ -20,7 +21,14 @@ Item {
property bool isReloadingAudio: false
property var hiddenOutputDeviceNames: SessionData.hiddenOutputDeviceNames ?? []
property var hiddenInputDeviceNames: SessionData.hiddenInputDeviceNames ?? []
// Hidden names persist for absent devices (unplugged, card disabled), so count only the present ones
readonly property var shownOutputDevices: outputDevices.filter(d => !hiddenOutputDeviceNames.includes(d.name))
readonly property var hiddenOutputDevices: outputDevices.filter(d => hiddenOutputDeviceNames.includes(d.name))
readonly property var shownInputDevices: inputDevices.filter(d => !hiddenInputDeviceNames.includes(d.name))
readonly property var hiddenInputDevices: inputDevices.filter(d => hiddenInputDeviceNames.includes(d.name))
property bool showHiddenOutputDevices: false
property bool showDisabledOutputCards: false
property var deviceSetKey: null
property bool showHiddenInputDevices: false
function openRenameDialog(device, type) {
@@ -29,6 +37,19 @@ Item {
renameDialog.show(AudioService.displayName(device));
}
function confirmDisableCard(card) {
disableCardConfirm.showWithOptions({
"title": I18n.tr("Disable %1?", "audio settings confirmation title, %1 is the sound card name").arg(card.description),
"message": I18n.tr("Every output and input of this sound card turns off until you enable it again", "audio settings confirmation message before disabling a sound card"),
"confirmText": I18n.tr("Disable", "audio settings button that turns a sound card off"),
"confirmColor": Theme.error,
"onConfirm": () => AudioService.setCardProfile(card.name, "off", (ok, message) => {
if (!ok)
ToastService.showToast(message, ToastService.levelError);
})
});
}
function saveDeviceName(name) {
if (!editingDevice)
return;
@@ -82,6 +103,13 @@ Item {
};
inputDevices = inputs.sort(sortInputs);
// deviceRevision also bumps on app streams, so re-read cards only when the device set changes
const key = outputs.concat(inputs).filter(node => !node.isStream).map(node => node.name).sort().join("\n");
if (key === deviceSetKey)
return;
deviceSetKey = key;
AudioService.refreshCards();
}
Component.onCompleted: {
@@ -132,6 +160,11 @@ Item {
}
}
ConfirmDialogOverlay {
id: disableCardConfirm
parent: root.parentModal?.modalFocusScope ?? root
}
SettingsPage {
id: mainColumn
@@ -189,7 +222,7 @@ Item {
}
Repeater {
model: root.outputDevices.filter(d => !root.hiddenOutputDeviceNames.includes(d.name))
model: root.shownOutputDevices
delegate: Column {
required property var modelData
@@ -197,6 +230,10 @@ Item {
spacing: 0
DeviceAliasRow {
id: outputRow
readonly property var card: AudioService.cardForSink(modelData)
deviceNode: modelData
deviceType: "output"
showHideButton: true
@@ -206,6 +243,15 @@ Item {
onHideRequested: device => {
root.persistHiddenOutputDeviceNames([...root.hiddenOutputDeviceNames, device.name]);
}
DActionButton {
visible: outputRow.card !== null
iconName: "power_settings_new"
iconSize: Theme.iconSizeMedium
iconColor: Theme.error
tooltipText: I18n.tr("Disable card", "audio settings button that turns a sound card off")
onClicked: root.confirmDisableCard(outputRow.card)
}
}
Item {
@@ -253,7 +299,7 @@ Item {
}
SettingsRow {
visible: root.outputDevices.filter(d => !root.hiddenOutputDeviceNames.includes(d.name)).length === 0 && root.hiddenOutputDeviceNames.length === 0
visible: root.shownOutputDevices.length === 0 && root.hiddenOutputDevices.length === 0 && AudioService.disabledOutputCards.length === 0
body: StyledText {
width: parent.width
text: I18n.tr("No output devices found", "Audio settings empty state")
@@ -265,7 +311,7 @@ Item {
}
SettingsRow {
visible: root.hiddenOutputDeviceNames.length > 0
visible: root.hiddenOutputDevices.length > 0
body: Column {
width: parent.width
spacing: 0
@@ -287,7 +333,7 @@ Item {
}
StyledText {
text: I18n.tr("Hidden (%1)", "count of hidden audio devices").arg(root.hiddenOutputDeviceNames.length)
text: I18n.tr("Hidden (%1)", "count of hidden audio devices").arg(root.hiddenOutputDevices.length)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
@@ -315,7 +361,7 @@ Item {
visible: root.showHiddenOutputDevices
Repeater {
model: root.outputDevices.filter(d => root.hiddenOutputDeviceNames.includes(d.name))
model: root.hiddenOutputDevices
delegate: DeviceAliasRow {
required property var modelData
@@ -332,6 +378,81 @@ Item {
}
}
}
SettingsRow {
visible: AudioService.disabledOutputCards.length > 0
body: Column {
width: parent.width
spacing: 0
Item {
width: parent.width
height: 36
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DIcon {
name: "power_off"
size: Theme.iconSizeMedium
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Disabled (%1)", "count of disabled audio output cards").arg(AudioService.disabledOutputCards.length)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
}
DIcon {
name: root.showDisabledOutputCards ? "expand_less" : "expand_more"
size: Theme.iconSizeMedium
color: Theme.surfaceVariantText
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.showDisabledOutputCards = !root.showDisabledOutputCards
}
}
Column {
width: parent.width
spacing: 0
visible: root.showDisabledOutputCards
Repeater {
model: AudioService.disabledOutputCards
delegate: SettingsRow {
required property var modelData
iconName: "speaker"
title: modelData.cardDescription
subtitle: modelData.cardName
DActionButton {
iconName: "power_settings_new"
iconSize: Theme.iconSizeMedium
tooltipText: I18n.tr("Enable card", "audio settings button that turns a disabled sound card back on")
onClicked: AudioService.setCardProfile(modelData.cardName, modelData.profile, (ok, message) => {
if (!ok)
ToastService.showToast(message, ToastService.levelError);
})
}
}
}
}
}
}
}
SettingsCard {
@@ -356,7 +477,7 @@ Item {
}
Repeater {
model: root.inputDevices.filter(d => !root.hiddenInputDeviceNames.includes(d.name))
model: root.shownInputDevices
delegate: DeviceAliasRow {
required property var modelData
@@ -379,14 +500,14 @@ Item {
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
horizontalAlignment: Text.AlignHCenter
visible: root.inputDevices.filter(d => !root.hiddenInputDeviceNames.includes(d.name)).length === 0 && root.hiddenInputDeviceNames.length === 0
visible: root.shownInputDevices.length === 0 && root.hiddenInputDevices.length === 0
topPadding: Theme.spacingM
}
Column {
width: parent.width
spacing: 0
visible: root.hiddenInputDeviceNames.length > 0
visible: root.hiddenInputDevices.length > 0
Item {
width: parent.width
@@ -405,7 +526,7 @@ Item {
}
StyledText {
text: I18n.tr("Hidden (%1)", "count of hidden audio devices").arg(root.hiddenInputDeviceNames.length)
text: I18n.tr("Hidden (%1)", "count of hidden audio devices").arg(root.hiddenInputDevices.length)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
@@ -433,7 +554,7 @@ Item {
visible: root.showHiddenInputDevices
Repeater {
model: root.inputDevices.filter(d => root.hiddenInputDeviceNames.includes(d.name))
model: root.hiddenInputDevices
delegate: DeviceAliasRow {
required property var modelData
+86 -11
View File
@@ -138,6 +138,8 @@ Singleton {
property var cards: []
property var pendingCardSwitch: null
readonly property var switchableOutputPorts: root.collectSwitchableOutputPorts(root.cards, SessionData.hiddenOutputDeviceNames ?? [])
readonly property var disabledOutputCards: root.collectDisabledOutputCards(root.cards)
readonly property var outputChoices: root.collectOutputChoices(root.allNodes.filter(n => n.isSink && !n.isStream), root.sinkPorts, root.switchableOutputPorts)
readonly property int sinkMaxVolume: {
if (freebsdAudio)
@@ -298,14 +300,6 @@ Singleton {
}, 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) {
if (freebsdAudio) {
if (callback)
@@ -435,6 +429,7 @@ Singleton {
target.profiles[match[1]] = {
description: match[2],
priority: parseInt(meta.match(/priority:?\s*(\d+)/)?.[1] ?? "0", 10),
sinks: parseInt(meta.match(/sinks:\s*(\d+)/)?.[1] ?? "0", 10),
available: !/available:\s*no/.test(meta)
};
}
@@ -565,9 +560,11 @@ Singleton {
const product = port.props["device.product.name"] || "";
entries.push({
cardName: card.name,
cardId: card.props["object.id"] ?? "",
cardDescription: card.description,
portName: port.name,
profile: profile,
availability: port.availability,
icon: port.type === "hdmi" ? "tv" : "speaker",
title: product ? `${port.description} [${product}]` : port.description
});
@@ -576,9 +573,63 @@ Singleton {
return entries;
}
function sinkBelongsToCard(node, cardName) {
// Picks profiles by sink count, since UCM (HiFi) and Bluetooth (a2dp-sink) profiles have no output: prefix.
// pro-audio is excluded: it stays available on cards WirePlumber turned off for having nothing plugged in.
function collectDisabledOutputCards(cards) {
const entries = [];
for (const card of cards || []) {
if (card.activeProfile !== "off")
continue;
const candidates = Object.keys(card.profiles).filter(name => name !== "pro-audio" && card.profiles[name].sinks > 0 && card.profiles[name].available);
if (candidates.length === 0)
continue;
candidates.sort((a, b) => card.profiles[b].priority - card.profiles[a].priority);
entries.push({
cardName: card.name,
cardDescription: card.description,
profile: candidates[0]
});
}
return entries;
}
function collectOutputChoices(sinks, sinkPorts, switchable) {
const bySink = {};
for (const sink of sinks || []) {
const info = sinkPorts?.[sink.name];
const ports = (info?.ports || []).slice().sort((a, b) => b.priority - a.priority).map(port => ({
kind: "port",
sinkName: sink.name,
portName: port.name,
title: port.description,
availability: port.availability,
active: port.name === info.active
}));
const profiles = (switchable || []).filter(entry => sinkBelongsToCard(sink, entry.cardName, entry.cardId));
bySink[sink.name] = ports.concat(profiles.map(entry => Object.assign({
kind: "profile",
active: false
}, entry)));
}
return bySink;
}
function activateOutputChoice(choice, callback) {
if (choice?.kind !== "port") {
activateOutputPort(choice, callback);
return;
}
setDefaultSinkByName(choice.sinkName);
setSinkPort(choice.sinkName, choice.portName, callback);
}
// device.id is the card's object.id. PipeWire nodes carry no device.name (pactl adds it), so Bluetooth matches only by id
function sinkBelongsToCard(node, cardName, cardId) {
if (!node || node.isStream || !cardName)
return false;
const deviceId = node.properties?.["device.id"];
if (cardId && deviceId !== undefined)
return String(deviceId) === String(cardId);
if (node.properties?.["device.name"] === cardName)
return true;
if (!cardName.startsWith("alsa_card."))
@@ -586,6 +637,29 @@ Singleton {
return (node.name || "").startsWith("alsa_output." + cardName.substring(10) + ".");
}
function cardForSink(node) {
return cards.find(card => sinkBelongsToCard(node, card.name, card.props["object.id"])) ?? null;
}
function setCardProfile(cardName, profile, callback) {
if (freebsdAudio) {
if (callback)
callback(false, I18n.tr("Output profile switching is not available with the FreeBSD native audio backend"));
return;
}
if (!cardName || !profile)
return;
Proc.runCommand("audio-set-card-profile", ["env", "LC_ALL=C", "pactl", "set-card-profile", cardName, profile], (output, exitCode) => {
const ok = exitCode === 0;
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 activateOutputPort(entry, callback) {
if (freebsdAudio) {
if (callback)
@@ -594,12 +668,13 @@ Singleton {
}
if (!entry?.cardName || !entry.profile)
return;
const previousSinks = root.pipewireNodes.filter(n => n.isSink && sinkBelongsToCard(n, entry.cardName)).map(n => n.name);
const previousSinks = root.pipewireNodes.filter(n => n.isSink && sinkBelongsToCard(n, entry.cardName, entry.cardId)).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,
cardId: entry.cardId,
portName: entry.portName,
exclude: previousSinks
};
@@ -619,7 +694,7 @@ Singleton {
const pending = pendingCardSwitch;
if (!pending)
return;
const node = root.pipewireNodes.find(n => n.isSink && sinkBelongsToCard(n, pending.cardName) && !pending.exclude.includes(n.name));
const node = root.pipewireNodes.find(n => n.isSink && sinkBelongsToCard(n, pending.cardName, pending.cardId) && !pending.exclude.includes(n.name));
if (!node)
return;
pendingCardSwitch = null;
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
function functionSource(file, name) {
const source = readFileSync(new URL(file, import.meta.url), "utf8");
const found = source.match(new RegExp(`^ function ${name}\\([^)]*\\)(?:: \\w+)? \\{\\n[\\s\\S]*?^ \\}`, "m"));
if (!found)
throw new Error(`${name} not found in ${file}`);
return found[0];
}
const context = vm.createContext({});
for (const name of ["parseCards", "collectDisabledOutputCards"])
vm.runInContext(functionSource("../Services/AudioService.qml", name), context);
function card(name, activeProfile, profiles) {
return [
"Card #1",
`\tName: ${name}`,
"\tProperties:",
`\t\tdevice.description = "${name}"`,
"\tProfiles:",
...profiles.map(line => `\t\t${line}`),
`\tActive Profile: ${activeProfile}`,
"\tPorts:"
].join("\n");
}
const off = "off: Off (sinks: 0, sources: 0, priority: 0, available: yes)";
test("off cards re-enable on their best sink profile whatever its name, other cards are not disabled", () => {
const output = [
card("alsa_card.usb-Headset-00", "off", [
off,
"output:analog-stereo+input:mono-fallback: Analog Stereo Output + Mono Input (sinks: 1, sources: 1, priority: 6501, available: yes)",
"output:analog-stereo: Analog Stereo Output (sinks: 1, sources: 0, priority: 6500, available: yes)",
"input:mono-fallback: Mono Input (sinks: 0, sources: 1, priority: 1, available: yes)"
]),
// Nothing plugged in: every analog profile is unavailable and only pro-audio is left
card("alsa_card.pci-0000_0f_00.6", "off", [
off,
"output:analog-stereo+input:analog-stereo: Analog Stereo Duplex (sinks: 1, sources: 1, priority: 6565, available: no)",
"input:analog-stereo: Analog Stereo Input (sinks: 0, sources: 1, priority: 65, available: no)",
"pro-audio: Pro Audio (sinks: 1, sources: 2, priority: 1, available: yes)"
]),
card("alsa_card.pci-0000_00_1f.3-platform-skl_hda_dsp_generic", "off", [
"HiFi (HDMI1, Headphones, Mic1, Speaker): Play HiFi quality Music (sinks: 2, sources: 1, priority: 8200, available: yes)",
"HiFi (HDMI1, Mic1, Speaker): Play HiFi quality Music (sinks: 2, sources: 1, priority: 8100, available: yes)",
off
]),
card("bluez_card.AA_BB_CC_DD_EE_FF", "off", [
off,
"a2dp-sink-sbc: High Fidelity Playback (A2DP Sink, codec SBC) (sinks: 1, sources: 0, priority: 18, available: yes)",
"a2dp-sink: High Fidelity Playback (A2DP Sink, codec AAC) (sinks: 1, sources: 0, priority: 21, available: yes)",
"headset-head-unit: Headset Head Unit (HSP/HFP, codec mSBC) (sinks: 1, sources: 1, priority: 2, available: yes)"
]),
card("alsa_card.usb-Webcam-00", "input:mono-fallback", [
"input:mono-fallback: Mono Input (sinks: 0, sources: 1, priority: 1, available: yes)",
off
]),
card("alsa_card.pci-0000_03_00.1", "output:hdmi-stereo", [
"output:hdmi-stereo: Digital Stereo (HDMI) Output (sinks: 1, sources: 0, priority: 5900, available: yes)",
off
])
].join("\n\n");
const disabled = JSON.parse(JSON.stringify(context.collectDisabledOutputCards(context.parseCards(output)).map(entry => [entry.cardName, entry.profile])));
assert.deepEqual(disabled, [
["alsa_card.usb-Headset-00", "output:analog-stereo+input:mono-fallback"],
["alsa_card.pci-0000_00_1f.3-platform-skl_hda_dsp_generic", "HiFi (HDMI1, Headphones, Mic1, Speaker)"],
["bluez_card.AA_BB_CC_DD_EE_FF", "a2dp-sink"]
]);
});