diff --git a/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml b/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml index 4175169a8..2c7f06f25 100644 --- a/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml +++ b/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml @@ -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 { diff --git a/quickshell/Modules/ControlCenter/Details/AudioPortSelector.qml b/quickshell/Modules/ControlCenter/Details/AudioPortSelector.qml index a45dd608e..3ce0affc9 100644 --- a/quickshell/Modules/ControlCenter/Details/AudioPortSelector.qml +++ b/quickshell/Modules/ControlCenter/Details/AudioPortSelector.qml @@ -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) } } } diff --git a/quickshell/Modules/Settings/AudioTab.qml b/quickshell/Modules/Settings/AudioTab.qml index 31421213a..af76cb507 100644 --- a/quickshell/Modules/Settings/AudioTab.qml +++ b/quickshell/Modules/Settings/AudioTab.qml @@ -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 diff --git a/quickshell/Services/AudioService.qml b/quickshell/Services/AudioService.qml index 1d938e2b5..da0b17d6a 100644 --- a/quickshell/Services/AudioService.qml +++ b/quickshell/Services/AudioService.qml @@ -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; diff --git a/quickshell/tests/audio-disabled-cards.test.mjs b/quickshell/tests/audio-disabled-cards.test.mjs new file mode 100644 index 000000000..4bfdbdf5c --- /dev/null +++ b/quickshell/tests/audio-disabled-cards.test.mjs @@ -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"] + ]); +});