diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index 6b42bf4c..107e0534 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -458,5 +458,20 @@ FocusScope { Qt.callLater(() => item.forceActiveFocus()); } } + + Loader { + id: audioLoader + anchors.fill: parent + active: root.currentIndex === 29 + visible: active + focus: active + + sourceComponent: AudioTab {} + + onActiveChanged: { + if (active && item) + Qt.callLater(() => item.forceActiveFocus()); + } + } } } diff --git a/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml index 0c3ffcad..865d3d00 100644 --- a/quickshell/Modals/Settings/SettingsSidebar.qml +++ b/quickshell/Modals/Settings/SettingsSidebar.qml @@ -239,12 +239,12 @@ Rectangle { "icon": "computer", "collapsedByDefault": true, "children": [ + { - "id": "printers", - "text": I18n.tr("Printers"), - "icon": "print", - "tabIndex": 8, - "cupsOnly": true + "id": "audio", + "text": I18n.tr("Audio"), + "icon": "headphones", + "tabIndex": 29 }, { "id": "clipboard", @@ -253,6 +253,13 @@ Rectangle { "tabIndex": 23, "clipboardOnly": true }, + { + "id": "printers", + "text": I18n.tr("Printers"), + "icon": "print", + "tabIndex": 8, + "cupsOnly": true + }, { "id": "window_rules", "text": I18n.tr("Window Rules"), diff --git a/quickshell/Modules/Settings/AudioTab.qml b/quickshell/Modules/Settings/AudioTab.qml new file mode 100644 index 00000000..ff80f157 --- /dev/null +++ b/quickshell/Modules/Settings/AudioTab.qml @@ -0,0 +1,503 @@ +import QtQuick +import QtQuick.Controls +import Quickshell.Services.Pipewire +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.Settings.Widgets + +Item { + id: root + + property var outputDevices: [] + property var inputDevices: [] + property bool showEditDialog: false + property var editingDevice: null + property string editingDeviceType: "" + property string newDeviceName: "" + property bool isReloadingAudio: false + + function updateDeviceList() { + const allNodes = Pipewire.nodes.values; + + // Sort devices: active first, then alphabetically by name + const sortDevices = (a, b) => { + if (a === AudioService.sink && b !== AudioService.sink) + return -1; + if (b === AudioService.sink && a !== AudioService.sink) + return 1; + const nameA = AudioService.displayName(a).toLowerCase(); + const nameB = AudioService.displayName(b).toLowerCase(); + return nameA.localeCompare(nameB); + }; + + const outputs = allNodes.filter(node => { + return node.audio && node.isSink && !node.isStream; + }); + outputDevices = outputs.sort(sortDevices); + + const inputs = allNodes.filter(node => { + return node.audio && !node.isSink && !node.isStream; + }); + + const sortInputs = (a, b) => { + if (a === AudioService.source && b !== AudioService.source) + return -1; + if (b === AudioService.source && a !== AudioService.source) + return 1; + const nameA = AudioService.displayName(a).toLowerCase(); + const nameB = AudioService.displayName(b).toLowerCase(); + return nameA.localeCompare(nameB); + }; + + inputDevices = inputs.sort(sortInputs); + } + + Component.onCompleted: { + updateDeviceList(); + } + + Connections { + target: Pipewire.nodes + function onValuesChanged() { + root.updateDeviceList(); + } + } + + Connections { + target: AudioService + function onWireplumberReloadStarted() { + root.isReloadingAudio = true; + } + function onWireplumberReloadCompleted(success) { + Qt.callLater(() => { + delayTimer.start(); + }); + } + function onDeviceAliasChanged(nodeName, newAlias) { + root.updateDeviceList(); + } + } + + Timer { + id: delayTimer + interval: 2000 + repeat: false + onTriggered: { + root.isReloadingAudio = false; + root.updateDeviceList(); + } + } + + DankFlickable { + anchors.fill: parent + clip: true + contentHeight: mainColumn.height + Theme.spacingXL + contentWidth: width + + Column { + id: mainColumn + topPadding: 4 + width: Math.min(550, parent.width - Theme.spacingL * 2) + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingXL + + SettingsCard { + tab: "audio" + tags: ["audio", "device", "output", "speaker"] + title: I18n.tr("Output Devices") + settingKey: "audioOutputDevices" + iconName: "volume_up" + + Column { + width: parent.width + spacing: Theme.spacingM + + StyledText { + width: parent.width + text: I18n.tr("Set custom names for your audio output devices") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.outline + opacity: 0.2 + } + + Repeater { + model: root.outputDevices + + delegate: DeviceAliasRow { + required property var modelData + + deviceNode: modelData + deviceType: "output" + + onEditRequested: device => { + root.editingDevice = device; + root.editingDeviceType = "output"; + root.newDeviceName = AudioService.displayName(device); + root.showEditDialog = true; + } + + onResetRequested: device => { + AudioService.removeDeviceAlias(device.name); + } + } + } + + StyledText { + width: parent.width + text: I18n.tr("No output devices found") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + horizontalAlignment: Text.AlignHCenter + visible: root.outputDevices.length === 0 + topPadding: Theme.spacingM + } + } + } + + SettingsCard { + tab: "audio" + tags: ["audio", "device", "input", "microphone"] + title: I18n.tr("Input Devices") + settingKey: "audioInputDevices" + iconName: "mic" + + Column { + width: parent.width + spacing: Theme.spacingM + + StyledText { + width: parent.width + text: I18n.tr("Set custom names for your audio input devices") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.outline + opacity: 0.2 + } + + Repeater { + model: root.inputDevices + + delegate: DeviceAliasRow { + required property var modelData + + deviceNode: modelData + deviceType: "input" + + onEditRequested: device => { + root.editingDevice = device; + root.editingDeviceType = "input"; + root.newDeviceName = AudioService.displayName(device); + root.showEditDialog = true; + } + + onResetRequested: device => { + AudioService.removeDeviceAlias(device.name); + } + } + } + + StyledText { + width: parent.width + text: I18n.tr("No input devices found") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + horizontalAlignment: Text.AlignHCenter + visible: root.inputDevices.length === 0 + topPadding: Theme.spacingM + } + } + } + } + } + + Rectangle { + id: loadingOverlay + anchors.fill: parent + color: Theme.withAlpha(Theme.surface, 0.9) + visible: root.isReloadingAudio + z: 100 + + Column { + anchors.centerIn: parent + spacing: Theme.spacingL + + Rectangle { + width: 80 + height: 80 + radius: 40 + color: Theme.primaryContainer + anchors.horizontalCenter: parent.horizontalCenter + + DankIcon { + id: spinningIcon + name: "refresh" + size: 40 + color: Theme.primary + anchors.centerIn: parent + + RotationAnimator { + target: spinningIcon + from: 0 + to: 360 + duration: 1500 + loops: Animation.Infinite + running: loadingOverlay.visible + } + } + } + + Column { + spacing: Theme.spacingS + anchors.horizontalCenter: parent.horizontalCenter + + StyledText { + text: I18n.tr("Restarting audio system...") + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Medium + color: Theme.surfaceText + anchors.horizontalCenter: parent.horizontalCenter + } + + StyledText { + text: I18n.tr("This may take a few seconds") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + } + } + } + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + + Rectangle { + id: dialogOverlay + anchors.fill: parent + visible: root.showEditDialog + color: Theme.withAlpha(Theme.surface, 0.8) + z: 1000 + + MouseArea { + anchors.fill: parent + onClicked: { + root.showEditDialog = false; + } + } + + Rectangle { + id: editDialog + anchors.centerIn: parent + width: Math.min(500, parent.width - Theme.spacingL * 4) + height: dialogContent.implicitHeight + Theme.spacingL * 2 + radius: Theme.cornerRadius + color: Theme.surfaceContainerHigh + border.width: 1 + border.color: Theme.outlineMedium + + MouseArea { + anchors.fill: parent + onClicked: {} + } + + Column { + id: dialogContent + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingL + + Row { + width: parent.width + spacing: Theme.spacingM + + DankIcon { + name: root.editingDeviceType === "input" ? "mic" : "speaker" + size: Theme.iconSize + 8 + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - Theme.iconSize - Theme.spacingM - 8 + spacing: 4 + + StyledText { + text: I18n.tr("Set Custom Device Name") + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Bold + color: Theme.surfaceText + width: parent.width + wrapMode: Text.Wrap + } + + StyledText { + text: root.editingDevice?.name ?? "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + elide: Text.ElideRight + } + + StyledText { + visible: AudioService.hasDeviceAlias(root.editingDevice?.name ?? "") + text: I18n.tr("Original: %1").arg(AudioService.originalName(root.editingDevice)) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + elide: Text.ElideRight + opacity: 0.7 + } + } + } + + Column { + width: parent.width + spacing: Theme.spacingM + + StyledText { + text: I18n.tr("Custom Name") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + } + + DankTextField { + id: nameInput + width: parent.width + placeholderText: I18n.tr("Enter device name...") + text: root.newDeviceName + normalBorderColor: Theme.outlineMedium + focusedBorderColor: Theme.primary + showClearButton: true + + onTextChanged: { + root.newDeviceName = text; + } + + Keys.onReturnPressed: { + if (text.trim() !== "") { + saveButtonMouseArea.clicked(null); + } + } + + Keys.onEscapePressed: { + root.showEditDialog = false; + } + + Component.onCompleted: { + Qt.callLater(() => { + forceActiveFocus(); + selectAll(); + }); + } + } + + StyledText { + width: parent.width + text: I18n.tr("Press Enter and the audio system will restart to apply the change") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + } + + Row { + width: parent.width + spacing: Theme.spacingM + layoutDirection: Qt.RightToLeft + + Rectangle { + id: saveButton + width: saveButtonContent.width + Theme.spacingL * 2 + height: Theme.buttonHeight + radius: Theme.cornerRadius + color: saveButtonMouseArea.containsMouse ? Theme.primaryContainer : Theme.primary + enabled: root.newDeviceName.trim() !== "" + opacity: enabled ? 1.0 : 0.5 + + Row { + id: saveButtonContent + anchors.centerIn: parent + spacing: Theme.spacingS + + DankIcon { + name: "check" + size: Theme.iconSize - 4 + color: Theme.onPrimary + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Save") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.onPrimary + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: saveButtonMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: parent.enabled + onClicked: { + if (root.editingDevice && root.newDeviceName.trim() !== "") { + AudioService.setDeviceAlias(root.editingDevice.name, root.newDeviceName); + root.showEditDialog = false; + } + } + } + } + + Rectangle { + width: cancelButtonText.width + Theme.spacingL * 2 + height: Theme.buttonHeight + radius: Theme.cornerRadius + color: cancelButtonMouseArea.containsMouse ? Theme.surfaceHover : "transparent" + border.width: 1 + border.color: Theme.outline + + StyledText { + id: cancelButtonText + text: I18n.tr("Cancel") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + anchors.centerIn: parent + } + + MouseArea { + id: cancelButtonMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.showEditDialog = false; + } + } + } + } + } + } + } +} diff --git a/quickshell/Modules/Settings/Widgets/DeviceAliasRow.qml b/quickshell/Modules/Settings/Widgets/DeviceAliasRow.qml new file mode 100644 index 00000000..d6a64c3c --- /dev/null +++ b/quickshell/Modules/Settings/Widgets/DeviceAliasRow.qml @@ -0,0 +1,155 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import qs.Common +import qs.Services +import qs.Widgets + +Rectangle { + id: root + + required property var deviceNode + property string deviceType: "output" + + signal editRequested(var deviceNode) + signal resetRequested(var deviceNode) + + width: parent?.width ?? 0 + height: deviceRowContent.height + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: deviceMouseArea.containsMouse ? Theme.surfaceHover : "transparent" + + readonly property bool hasCustomAlias: AudioService.hasDeviceAlias(deviceNode?.name ?? "") + readonly property string displayedName: AudioService.displayName(deviceNode) + + Row { + id: deviceRowContent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + spacing: Theme.spacingM + + DankIcon { + name: root.deviceType === "input" ? "mic" : "speaker" + size: Theme.iconSize + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - Theme.iconSize - Theme.spacingM * 3 - buttonsRow.width + spacing: 2 + + StyledText { + text: root.displayedName + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width + elide: Text.ElideRight + } + + Column { + width: parent.width + spacing: 2 + + Row { + width: parent.width + spacing: Theme.spacingS + + StyledText { + text: root.deviceNode?.name ?? "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width - (customAliasLabel.visible ? customAliasLabel.width + Theme.spacingS : 0) + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + } + + Rectangle { + id: customAliasLabel + visible: root.hasCustomAlias + height: customAliasText.implicitHeight + 4 + width: customAliasText.implicitWidth + Theme.spacingS * 2 + radius: 3 + color: Theme.withAlpha(Theme.primary, 0.15) + anchors.verticalCenter: parent.verticalCenter + + StyledText { + id: customAliasText + text: I18n.tr("Custom") + font.pixelSize: Theme.fontSizeSmall - 1 + color: Theme.primary + font.weight: Font.Medium + anchors.centerIn: parent + } + } + } + + StyledText { + visible: root.hasCustomAlias + text: I18n.tr("Original: %1").arg(AudioService.originalName(root.deviceNode)) + font.pixelSize: Theme.fontSizeSmall - 1 + color: Theme.surfaceVariantText + width: parent.width + elide: Text.ElideRight + opacity: 0.6 + } + } + } + + Row { + id: buttonsRow + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + DankActionButton { + id: resetButton + visible: root.hasCustomAlias + buttonSize: 36 + iconName: "restart_alt" + iconSize: 20 + backgroundColor: Theme.surfaceContainerHigh + iconColor: Theme.surfaceVariantText + tooltipText: I18n.tr("Reset to default name") + anchors.verticalCenter: parent.verticalCenter + onClicked: { + root.resetRequested(root.deviceNode); + } + } + + DankActionButton { + id: editButton + buttonSize: 36 + iconName: "edit" + iconSize: 20 + backgroundColor: Theme.buttonBg + iconColor: Theme.buttonText + tooltipText: I18n.tr("Set custom name") + anchors.verticalCenter: parent.verticalCenter + onClicked: { + root.editRequested(root.deviceNode); + } + } + } + } + + MouseArea { + id: deviceMouseArea + anchors.fill: parent + hoverEnabled: true + propagateComposedEvents: true + z: -1 + } + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } +} diff --git a/quickshell/Services/AudioService.qml b/quickshell/Services/AudioService.qml index a25135ab..46479c30 100644 --- a/quickshell/Services/AudioService.qml +++ b/quickshell/Services/AudioService.qml @@ -31,8 +31,19 @@ Singleton { property var mediaDevices: null property var mediaDevicesConnections: null + property var deviceAliases: ({}) + property string wireplumberConfigPath: { + const homeUrl = StandardPaths.writableLocation(StandardPaths.HomeLocation); + const homePath = homeUrl.toString().replace("file://", ""); + return homePath + "/.config/wireplumber/wireplumber.conf.d/51-dms-audio-aliases.conf"; + } + property bool wireplumberReloading: false + signal micMuteChanged signal audioOutputCycled(string deviceName) + signal deviceAliasChanged(string nodeName, string newAlias) + signal wireplumberReloadStarted() + signal wireplumberReloadCompleted(bool success) function getAvailableSinks() { return Pipewire.nodes.values.filter(node => node.audio && node.isSink && !node.isStream); @@ -60,6 +71,226 @@ Singleton { return name; } + function getDeviceAlias(nodeName) { + if (!nodeName) + return null; + return deviceAliases[nodeName] || null; + } + + function hasDeviceAlias(nodeName) { + if (!nodeName) + return false; + return deviceAliases.hasOwnProperty(nodeName) && deviceAliases[nodeName] !== null && deviceAliases[nodeName] !== ""; + } + + function setDeviceAlias(nodeName, customAlias) { + if (!nodeName) { + console.error("AudioService: Cannot set alias - nodeName is empty"); + return false; + } + + if (!customAlias || customAlias.trim() === "") { + return removeDeviceAlias(nodeName); + } + + const trimmedAlias = customAlias.trim(); + + const updated = Object.assign({}, deviceAliases); + updated[nodeName] = trimmedAlias; + deviceAliases = updated; + + writeWireplumberConfig(); + deviceAliasChanged(nodeName, trimmedAlias); + return true; + } + + function removeDeviceAlias(nodeName) { + if (!nodeName) + return false; + + if (!hasDeviceAlias(nodeName)) + return false; + + const updated = Object.assign({}, deviceAliases); + delete updated[nodeName]; + deviceAliases = updated; + + writeWireplumberConfig(); + deviceAliasChanged(nodeName, ""); + return true; + } + + function writeWireplumberConfig() { + const homeUrl = StandardPaths.writableLocation(StandardPaths.HomeLocation); + const homePath = homeUrl.toString().replace("file://", ""); + const configDir = homePath + "/.config/wireplumber/wireplumber.conf.d"; + const configContent = generateWireplumberConfig(); + + const shellCmd = `mkdir -p "${configDir}" && cat > "${wireplumberConfigPath}" << 'EOFCONFIG' +${configContent} +EOFCONFIG +`; + + Proc.runCommand("writeWireplumberConfig", ["sh", "-c", shellCmd], (output, exitCode) => { + if (exitCode !== 0) { + console.error("AudioService: Failed to write WirePlumber config. Exit code:", exitCode); + console.error("AudioService: Error output:", output); + ToastService.showError(I18n.tr("Failed to save audio config"), output || ""); + return; + } + + reloadWireplumberConfig(); + }, 0); + } + + function generateWireplumberConfig() { + let config = "# Generated by DankMaterialShell - Audio Device Aliases\n"; + config += "# Do not edit manually - changes will be overwritten\n"; + config += "# Last updated: " + new Date().toISOString() + "\n\n"; + + const aliasKeys = Object.keys(deviceAliases); + if (aliasKeys.length === 0) { + config += "# No device aliases configured\n"; + return config; + } + + const alsaAliases = []; + const bluezAliases = []; + const otherAliases = []; + + for (const nodeName of aliasKeys) { + const alias = deviceAliases[nodeName]; + if (!alias) + continue; + + const rule = { + nodeName: nodeName, + alias: alias + }; + + if (nodeName.includes("alsa")) { + alsaAliases.push(rule); + } else if (nodeName.includes("bluez")) { + bluezAliases.push(rule); + } else { + otherAliases.push(rule); + } + } + + if (alsaAliases.length > 0) { + config += "monitor.alsa.rules = [\n"; + for (let i = 0; i < alsaAliases.length; i++) { + const rule = alsaAliases[i]; + config += " {\n"; + config += ` matches = [ { "node.name" = "${rule.nodeName}" } ]\n`; + config += ` actions = { update-props = { "node.description" = "${rule.alias}" } }\n`; + config += " }"; + if (i < alsaAliases.length - 1) + config += ","; + config += "\n"; + } + config += "]\n\n"; + } + + if (bluezAliases.length > 0) { + config += "monitor.bluez.rules = [\n"; + for (let i = 0; i < bluezAliases.length; i++) { + const rule = bluezAliases[i]; + config += " {\n"; + config += ` matches = [ { "node.name" = "${rule.nodeName}" } ]\n`; + config += ` actions = { update-props = { "node.description" = "${rule.alias}" } }\n`; + config += " }"; + if (i < bluezAliases.length - 1) + config += ","; + config += "\n"; + } + config += "]\n\n"; + } + + if (otherAliases.length > 0) { + config += "# Other device aliases (RAOP, USB, and other devices)\n"; + config += "wireplumber.rules = [\n"; + for (let i = 0; i < otherAliases.length; i++) { + const rule = otherAliases[i]; + config += " {\n"; + config += ` matches = [\n`; + config += ` { "node.name" = "${rule.nodeName}" }\n`; + config += ` ]\n`; + config += ` actions = {\n`; + config += ` update-props = {\n`; + config += ` "node.description" = "${rule.alias}"\n`; + config += ` "node.nick" = "${rule.alias}"\n`; + config += ` "device.description" = "${rule.alias}"\n`; + config += ` }\n`; + config += ` }\n`; + config += " }"; + if (i < otherAliases.length - 1) + config += ","; + config += "\n"; + } + config += "]\n"; + } + + return config; + } + + function reloadWireplumberConfig() { + if (wireplumberReloading) { + return; + } + + wireplumberReloading = true; + wireplumberReloadStarted(); + + Proc.runCommand("restartWireplumber", ["systemctl", "--user", "restart", "wireplumber"], (output, exitCode) => { + wireplumberReloading = false; + + if (exitCode === 0) { + ToastService.showInfo(I18n.tr("Audio system restarted"), I18n.tr("Device names updated")); + wireplumberReloadCompleted(true); + } else { + console.error("AudioService: Failed to restart WirePlumber:", output); + ToastService.showError(I18n.tr("Failed to restart audio system"), output); + wireplumberReloadCompleted(false); + } + }, 5000); + } + + function loadDeviceAliases() { + const homeUrl = StandardPaths.writableLocation(StandardPaths.HomeLocation); + const homePath = homeUrl.toString().replace("file://", ""); + const configPath = homePath + "/.config/wireplumber/wireplumber.conf.d/51-dms-audio-aliases.conf"; + + Proc.runCommand("readWireplumberConfig", ["cat", configPath], (output, exitCode) => { + if (exitCode !== 0) { + console.log("AudioService: No existing WirePlumber config found"); + return; + } + + const aliases = {}; + const lines = output.split('\n'); + let currentNodeName = null; + + for (const line of lines) { + const nodeNameMatch = line.match(/"node\.name"\s*=\s*"([^"]+)"/); + if (nodeNameMatch) { + currentNodeName = nodeNameMatch[1]; + } + + const descriptionMatch = line.match(/"node\.description"\s*=\s*"([^"]+)"/); + if (descriptionMatch && currentNodeName) { + aliases[currentNodeName] = descriptionMatch[1]; + currentNodeName = null; + } + } + + if (Object.keys(aliases).length > 0) { + deviceAliases = aliases; + console.log("AudioService: Loaded", Object.keys(aliases).length, "device aliases"); + } + }, 0); + } + Connections { target: root.sink?.audio ?? null @@ -443,20 +674,40 @@ Singleton { return ""; } - if (node.properties && node.properties["device.description"]) { - return node.properties["device.description"]; + // FIRST: Check if we have a custom alias in our deviceAliases map + // This ensures we always show the user's custom name, regardless of + // whether WirePlumber has applied it to the node properties yet + if (node.name && deviceAliases[node.name]) { + return deviceAliases[node.name]; } + // Check node.properties["node.description"] for WirePlumber-applied aliases + // This is the live property updated by WirePlumber rules + if (node.properties && node.properties["node.description"]) { + const desc = node.properties["node.description"]; + if (desc !== node.name) { + return desc; + } + } + + // Check cached description as fallback if (node.description && node.description !== node.name) { return node.description; } + // Fallback to device description property + if (node.properties && node.properties["device.description"]) { + return node.properties["device.description"]; + } + + // Fallback to nickname if (node.nickname && node.nickname !== node.name) { return node.nickname; } + // Fallback to friendly names based on node name patterns if (node.name.includes("analog-stereo")) { - return "Built-in Speakers"; + return "Built-in Audio Analog Stereo"; } if (node.name.includes("bluez")) { return "Bluetooth Audio"; @@ -471,6 +722,48 @@ Singleton { return node.name; } + function originalName(node) { + if (!node) { + return ""; + } + + // Get the original name without checking for custom aliases + // Check pattern-based friendly names FIRST (before device.description) + // This ensures we show user-friendly names like "Built-in Audio Analog Stereo" + // instead of hardware chip names like "ALC274 Analog" + if (node.name.includes("analog-stereo")) { + return "Built-in Audio Analog Stereo"; + } + if (node.name.includes("bluez")) { + return "Bluetooth Audio"; + } + if (node.name.includes("usb")) { + return "USB Audio"; + } + if (node.name.includes("hdmi")) { + return "HDMI Audio"; + } + if (node.name.includes("raop_sink")) { + // Extract friendly name from RAOP node name + const match = node.name.match(/raop_sink\.([^.]+)/); + if (match) { + return match[1].replace(/-/g, " "); + } + } + + // Fallback to device.description property + if (node.properties && node.properties["device.description"]) { + return node.properties["device.description"]; + } + + // Fallback to nickname + if (node.nickname && node.nickname !== node.name) { + return node.nickname; + } + + return node.name; + } + function subtitle(name) { if (!name) { return ""; @@ -658,5 +951,7 @@ Singleton { checkGsettings(); Qt.callLater(createSoundPlayers); } + + loadDeviceAliases(); } }