From 42927eeb97ccab66f2b54eac1feda8bd6a0fc708 Mon Sep 17 00:00:00 2001 From: bbedward Date: Thu, 17 Jul 2025 23:20:36 -0400 Subject: [PATCH] audio: un-dumbify audio service --- Services/AudioService.qml | 156 +------------------------ Services/BluetoothService.qml | 16 +-- Widgets/ControlCenter/AudioTab.qml | 145 ++++++++++++++++------- Widgets/ControlCenter/BluetoothTab.qml | 8 +- Widgets/TopBar/ControlCenterButton.qml | 7 +- 5 files changed, 115 insertions(+), 217 deletions(-) diff --git a/Services/AudioService.qml b/Services/AudioService.qml index 1e53c027..6bebd31c 100644 --- a/Services/AudioService.qml +++ b/Services/AudioService.qml @@ -11,142 +11,10 @@ Singleton { readonly property PwNode sink: Pipewire.defaultAudioSink readonly property PwNode source: Pipewire.defaultAudioSource - readonly property bool sinkMuted: sink?.audio?.muted ?? false - readonly property bool sourceMuted: source?.audio?.muted ?? false - readonly property real volumeLevel: (sink?.audio?.volume ?? 0) * 100 - readonly property real micLevel: (source?.audio?.volume ?? 0) * 100 - - property ListModel audioSinksModel: ListModel {} - property ListModel audioSourcesModel: ListModel {} - - property var audioSinks: [] - property var audioSources: [] - - Component.onCompleted: _rebuildModels() - - Connections { - target: Pipewire - function onReadyChanged() { _rebuildModels() } - function onDefaultAudioSinkChanged() { _rebuildModels() } - function onDefaultAudioSourceChanged() { _rebuildModels() } - } - - Timer { - interval: 2000 - running: Pipewire.ready - repeat: true - onTriggered: _checkForNodeChanges() - } - - property int _lastNodeCount: 0 - - function _checkForNodeChanges() { - if (Pipewire.nodes?.values) { - let currentCount = Pipewire.nodes.values.length - if (currentCount !== _lastNodeCount) { - _lastNodeCount = currentCount - _rebuildModels() - } - } - } - - readonly property string currentAudioSink: sink?.name ?? "" - readonly property string currentAudioSource: source?.name ?? "" - - readonly property string currentSinkDisplayName: { - if (!sink) return "" - for (let i = 0; i < audioSinksModel.count; i++) { - let item = audioSinksModel.get(i) - if (item.node === sink) { - return item.displayName - } - } - return _displayName(sink) - } - - readonly property string currentSourceDisplayName: { - if (!source) return "" - for (let i = 0; i < audioSourcesModel.count; i++) { - let item = audioSourcesModel.get(i) - if (item.node === source) { - return item.displayName - } - } - return _displayName(source) - } - - function setVolume(percentage) { - if (sink?.audio) { - sink.audio.muted = false - sink.audio.volume = percentage / 100 - } - } - - function setMicLevel(percentage) { - if (source?.audio) { - source.audio.muted = false - source.audio.volume = percentage / 100 - } - } - - function toggleMute() { - if (sink?.audio) { - sink.audio.muted = !sink.audio.muted - } - } - - function toggleMicMute() { - if (source?.audio) { - source.audio.muted = !source.audio.muted - } - } - - function setAudioSink(sinkName) { - _setPreferred(sinkName, PwNodeType.AudioSink) - } - - function setAudioSource(sourceName) { - _setPreferred(sourceName, PwNodeType.AudioSource) - } - - function _rebuildModels() { - audioSinksModel.clear() - audioSourcesModel.clear() + function displayName(node) { + if (!node) return "" - let sinks = [] - let sources = [] - - if (!Pipewire.ready || !Pipewire.nodes?.values) return - - for (let i = 0; i < Pipewire.nodes.values.length; i++) { - let node = Pipewire.nodes.values[i] - if (!node || node.isStream) continue - - let entry = { - id: node.id.toString(), - name: node.name, - displayName: _displayName(node), - subtitle: _subtitle(node.name), - active: node === sink || node === source, - node: node - } - - if ((node.type & PwNodeType.AudioSink) === PwNodeType.AudioSink) { - audioSinksModel.append(entry) - sinks.push(entry) - } - if ((node.type & PwNodeType.AudioSource) === PwNodeType.AudioSource && !node.name.includes(".monitor")) { - audioSourcesModel.append(entry) - sources.push(entry) - } - } - - audioSinks = sinks - audioSources = sources - } - - function _displayName(node) { - if (node.properties?.["device.description"]) { + if (node.properties && node.properties["device.description"]) { return node.properties["device.description"] } @@ -166,7 +34,7 @@ Singleton { return node.name } - function _subtitle(name) { + function subtitle(name) { if (!name) return "" if (name.includes('usb-')) { @@ -192,22 +60,6 @@ Singleton { return "" } - function _setPreferred(name, kind) { - if (!Pipewire.nodes?.values) return - - for (let i = 0; i < Pipewire.nodes.values.length; i++) { - let node = Pipewire.nodes.values[i] - if (node && node.name === name && !node.isStream && ((node.type & kind) === kind)) { - if (kind === PwNodeType.AudioSink) { - Pipewire.preferredDefaultAudioSink = node - } else if (kind === PwNodeType.AudioSource) { - Pipewire.preferredDefaultAudioSource = node - } - break - } - } - } - PwObjectTracker { objects: [Pipewire.defaultAudioSink, Pipewire.defaultAudioSource] } diff --git a/Services/BluetoothService.qml b/Services/BluetoothService.qml index dd5e4cba..7300e800 100644 --- a/Services/BluetoothService.qml +++ b/Services/BluetoothService.qml @@ -18,7 +18,7 @@ Singleton { return []; return adapter.devices.values.filter((dev) => { - return dev && dev.paired && isValidDevice(dev); + return dev && dev.paired; }); } readonly property var allDevicesWithBattery: { @@ -47,20 +47,6 @@ Singleton { }); } - function isValidDevice(device) { - if (!device) - return false; - - var displayName = device.name || device.deviceName; - if (!displayName || displayName.length < 2) - return false; - - if (displayName.startsWith('/org/bluez') || displayName.includes('hci0')) - return false; - - return displayName.length >= 3; - } - function getDeviceIcon(device) { if (!device) return "bluetooth"; diff --git a/Widgets/ControlCenter/AudioTab.qml b/Widgets/ControlCenter/AudioTab.qml index b6fdca8e..6e7c28b6 100644 --- a/Widgets/ControlCenter/AudioTab.qml +++ b/Widgets/ControlCenter/AudioTab.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import Quickshell import Quickshell.Io +import Quickshell.Services.Pipewire import Quickshell.Widgets import qs.Common import qs.Services @@ -10,14 +11,12 @@ Item { id: audioTab property int audioSubTab: 0 // 0: Output, 1: Input - readonly property real volumeLevel: AudioService.volumeLevel - readonly property real micLevel: AudioService.micLevel - readonly property bool volumeMuted: AudioService.sinkMuted - readonly property bool micMuted: AudioService.sourceMuted - readonly property string currentAudioSink: AudioService.currentAudioSink - readonly property string currentAudioSource: AudioService.currentAudioSource - readonly property var audioSinks: AudioService.audioSinks - readonly property var audioSources: AudioService.audioSources + readonly property real volumeLevel: (AudioService.sink && AudioService.sink.audio && AudioService.sink.audio.volume * 100) || 0 + readonly property real micLevel: (AudioService.source && AudioService.source.audio && AudioService.source.audio.volume * 100) || 0 + readonly property bool volumeMuted: (AudioService.sink && AudioService.sink.audio && AudioService.sink.audio.muted) || false + readonly property bool micMuted: (AudioService.source && AudioService.source.audio && AudioService.source.audio.muted) || false + readonly property string currentSinkDisplayName: AudioService.sink ? AudioService.displayName(AudioService.sink) : "" + readonly property string currentSourceDisplayName: AudioService.source ? AudioService.displayName(AudioService.source) : "" Column { anchors.fill: parent @@ -115,7 +114,11 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: AudioService.toggleMute() + onClicked: { + if (AudioService.sink && AudioService.sink.audio) + AudioService.sink.audio.muted = !AudioService.sink.audio.muted; + + } } } @@ -191,7 +194,10 @@ Item { isDragging = true; let ratio = Math.max(0, Math.min(1, mouse.x / volumeSliderTrack.width)); let newVolume = Math.round(ratio * 100); - AudioService.setVolume(newVolume); + if (AudioService.sink && AudioService.sink.audio) { + AudioService.sink.audio.muted = false; + AudioService.sink.audio.volume = newVolume / 100; + } } onReleased: { isDragging = false; @@ -200,13 +206,19 @@ Item { if (pressed && isDragging) { let ratio = Math.max(0, Math.min(1, mouse.x / volumeSliderTrack.width)); let newVolume = Math.round(ratio * 100); - AudioService.setVolume(newVolume); + if (AudioService.sink && AudioService.sink.audio) { + AudioService.sink.audio.muted = false; + AudioService.sink.audio.volume = newVolume / 100; + } } } onClicked: (mouse) => { let ratio = Math.max(0, Math.min(1, mouse.x / volumeSliderTrack.width)); let newVolume = Math.round(ratio * 100); - AudioService.setVolume(newVolume); + if (AudioService.sink && AudioService.sink.audio) { + AudioService.sink.audio.muted = false; + AudioService.sink.audio.volume = newVolume / 100; + } } } @@ -223,7 +235,10 @@ Item { let globalPos = mapToItem(volumeSliderTrack, mouse.x, mouse.y); let ratio = Math.max(0, Math.min(1, globalPos.x / volumeSliderTrack.width)); let newVolume = Math.round(ratio * 100); - AudioService.setVolume(newVolume); + if (AudioService.sink && AudioService.sink.audio) { + AudioService.sink.audio.muted = false; + AudioService.sink.audio.volume = newVolume / 100; + } } } onReleased: { @@ -265,7 +280,7 @@ Item { color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3) border.width: 1 - visible: audioTab.currentAudioSink !== "" + visible: AudioService.sink !== null Row { anchors.left: parent.left @@ -281,7 +296,7 @@ Item { } Text { - text: "Current: " + (AudioService.currentSinkDisplayName || "None") + text: "Current: " + (audioTab.currentSinkDisplayName || "None") font.pixelSize: Theme.fontSizeMedium color: Theme.primary font.weight: Font.Medium @@ -293,14 +308,25 @@ Item { // Real audio devices Repeater { - model: audioTab.audioSinks + model: { + if (!Pipewire.ready || !Pipewire.nodes || !Pipewire.nodes.values) return [] + let sinks = [] + for (let i = 0; i < Pipewire.nodes.values.length; i++) { + let node = Pipewire.nodes.values[i] + if (!node || node.isStream) continue + if ((node.type & PwNodeType.AudioSink) === PwNodeType.AudioSink) { + sinks.push(node) + } + } + return sinks + } Rectangle { width: parent.width height: 50 radius: Theme.cornerRadius - color: deviceArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : (modelData.active ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08)) - border.color: modelData.active ? Theme.primary : "transparent" + color: deviceArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : (modelData === AudioService.sink ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08)) + border.color: modelData === AudioService.sink ? Theme.primary : "transparent" border.width: 1 Row { @@ -322,7 +348,7 @@ Item { } font.family: Theme.iconFont font.pixelSize: Theme.iconSize - color: modelData.active ? Theme.primary : Theme.surfaceText + color: modelData === AudioService.sink ? Theme.primary : Theme.surfaceText anchors.verticalCenter: parent.verticalCenter } @@ -331,18 +357,18 @@ Item { anchors.verticalCenter: parent.verticalCenter Text { - text: modelData.displayName + text: AudioService.displayName(modelData) font.pixelSize: Theme.fontSizeMedium - color: modelData.active ? Theme.primary : Theme.surfaceText - font.weight: modelData.active ? Font.Medium : Font.Normal + color: modelData === AudioService.sink ? Theme.primary : Theme.surfaceText + font.weight: modelData === AudioService.sink ? Font.Medium : Font.Normal } Text { text: { - if (modelData.subtitle && modelData.subtitle !== "") - return modelData.subtitle + (modelData.active ? " • Selected" : ""); + if (AudioService.subtitle(modelData.name) && AudioService.subtitle(modelData.name) !== "") + return AudioService.subtitle(modelData.name) + (modelData === AudioService.sink ? " • Selected" : ""); else - return modelData.active ? "Selected" : ""; + return modelData === AudioService.sink ? "Selected" : ""; } font.pixelSize: Theme.fontSizeSmall color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) @@ -360,7 +386,9 @@ Item { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - AudioService.setAudioSink(modelData.name); + if (modelData) + Pipewire.preferredDefaultAudioSink = modelData; + } } @@ -412,7 +440,11 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: AudioService.toggleMicMute() + onClicked: { + if (AudioService.source && AudioService.source.audio) + AudioService.source.audio.muted = !AudioService.source.audio.muted; + + } } } @@ -488,7 +520,10 @@ Item { isDragging = true; let ratio = Math.max(0, Math.min(1, mouse.x / micSliderTrack.width)); let newMicLevel = Math.round(ratio * 100); - AudioService.setMicLevel(newMicLevel); + if (AudioService.source && AudioService.source.audio) { + AudioService.source.audio.muted = false; + AudioService.source.audio.volume = newMicLevel / 100; + } } onReleased: { isDragging = false; @@ -497,13 +532,19 @@ Item { if (pressed && isDragging) { let ratio = Math.max(0, Math.min(1, mouse.x / micSliderTrack.width)); let newMicLevel = Math.round(ratio * 100); - AudioService.setMicLevel(newMicLevel); + if (AudioService.source && AudioService.source.audio) { + AudioService.source.audio.muted = false; + AudioService.source.audio.volume = newMicLevel / 100; + } } } onClicked: (mouse) => { let ratio = Math.max(0, Math.min(1, mouse.x / micSliderTrack.width)); let newMicLevel = Math.round(ratio * 100); - AudioService.setMicLevel(newMicLevel); + if (AudioService.source && AudioService.source.audio) { + AudioService.source.audio.muted = false; + AudioService.source.audio.volume = newMicLevel / 100; + } } } @@ -520,7 +561,10 @@ Item { let globalPos = mapToItem(micSliderTrack, mouse.x, mouse.y); let ratio = Math.max(0, Math.min(1, globalPos.x / micSliderTrack.width)); let newMicLevel = Math.round(ratio * 100); - AudioService.setMicLevel(newMicLevel); + if (AudioService.source && AudioService.source.audio) { + AudioService.source.audio.muted = false; + AudioService.source.audio.volume = newMicLevel / 100; + } } } onReleased: { @@ -562,7 +606,7 @@ Item { color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3) border.width: 1 - visible: audioTab.currentAudioSource !== "" + visible: AudioService.source !== null Row { anchors.left: parent.left @@ -578,7 +622,7 @@ Item { } Text { - text: "Current: " + (AudioService.currentSourceDisplayName || "None") + text: "Current: " + (audioTab.currentSourceDisplayName || "None") font.pixelSize: Theme.fontSizeMedium color: Theme.primary font.weight: Font.Medium @@ -590,14 +634,25 @@ Item { // Real audio input devices Repeater { - model: audioTab.audioSources + model: { + if (!Pipewire.ready || !Pipewire.nodes || !Pipewire.nodes.values) return [] + let sources = [] + for (let i = 0; i < Pipewire.nodes.values.length; i++) { + let node = Pipewire.nodes.values[i] + if (!node || node.isStream) continue + if ((node.type & PwNodeType.AudioSource) === PwNodeType.AudioSource && !node.name.includes(".monitor")) { + sources.push(node) + } + } + return sources + } Rectangle { width: parent.width height: 50 radius: Theme.cornerRadius - color: sourceArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : (modelData.active ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08)) - border.color: modelData.active ? Theme.primary : "transparent" + color: sourceArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : (modelData === AudioService.source ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08)) + border.color: modelData === AudioService.source ? Theme.primary : "transparent" border.width: 1 Row { @@ -617,7 +672,7 @@ Item { } font.family: Theme.iconFont font.pixelSize: Theme.iconSize - color: modelData.active ? Theme.primary : Theme.surfaceText + color: modelData === AudioService.source ? Theme.primary : Theme.surfaceText anchors.verticalCenter: parent.verticalCenter } @@ -626,18 +681,18 @@ Item { anchors.verticalCenter: parent.verticalCenter Text { - text: modelData.displayName + text: AudioService.displayName(modelData) font.pixelSize: Theme.fontSizeMedium - color: modelData.active ? Theme.primary : Theme.surfaceText - font.weight: modelData.active ? Font.Medium : Font.Normal + color: modelData === AudioService.source ? Theme.primary : Theme.surfaceText + font.weight: modelData === AudioService.source ? Font.Medium : Font.Normal } Text { text: { - if (modelData.subtitle && modelData.subtitle !== "") - return modelData.subtitle + (modelData.active ? " • Selected" : ""); + if (AudioService.subtitle(modelData.name) && AudioService.subtitle(modelData.name) !== "") + return AudioService.subtitle(modelData.name) + (modelData === AudioService.source ? " • Selected" : ""); else - return modelData.active ? "Selected" : ""; + return modelData === AudioService.source ? "Selected" : ""; } font.pixelSize: Theme.fontSizeSmall color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) @@ -655,7 +710,9 @@ Item { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - AudioService.setAudioSource(modelData.name); + if (modelData) + Pipewire.preferredDefaultAudioSource = modelData; + } } diff --git a/Widgets/ControlCenter/BluetoothTab.qml b/Widgets/ControlCenter/BluetoothTab.qml index 47753e59..eba6ac1b 100644 --- a/Widgets/ControlCenter/BluetoothTab.qml +++ b/Widgets/ControlCenter/BluetoothTab.qml @@ -92,7 +92,7 @@ Item { Repeater { model: BluetoothService.adapter && BluetoothService.adapter.devices ? BluetoothService.adapter.devices.values.filter((dev) => { - return dev && dev.paired && BluetoothService.isValidDevice(dev); + return dev && dev.paired; }) : [] Rectangle { @@ -301,7 +301,7 @@ Item { return []; var filtered = Bluetooth.devices.values.filter((dev) => { - return dev && !dev.paired && !dev.pairing && !dev.blocked && BluetoothService.isValidDevice(dev) && (dev.signalStrength === undefined || dev.signalStrength > 0); + return dev && !dev.paired && !dev.pairing && !dev.blocked && (dev.signalStrength === undefined || dev.signalStrength > 0); }); return BluetoothService.sortDevices(filtered); } @@ -500,7 +500,7 @@ Item { return false; var availableCount = Bluetooth.devices.values.filter((dev) => { - return dev && !dev.paired && !dev.pairing && !dev.blocked && BluetoothService.isValidDevice(dev) && (dev.signalStrength === undefined || dev.signalStrength > 0); + return dev && !dev.paired && !dev.pairing && !dev.blocked && (dev.signalStrength === undefined || dev.signalStrength > 0); }).length; return availableCount === 0; @@ -555,7 +555,7 @@ Item { return true; var availableCount = Bluetooth.devices.values.filter((dev) => { - return dev && !dev.paired && !dev.pairing && !dev.blocked && BluetoothService.isValidDevice(dev) && (dev.signalStrength === undefined || dev.signalStrength > 0); + return dev && !dev.paired && !dev.pairing && !dev.blocked && (dev.signalStrength === undefined || dev.signalStrength > 0); }).length; return availableCount === 0 && !BluetoothService.adapter.discovering; diff --git a/Widgets/TopBar/ControlCenterButton.qml b/Widgets/TopBar/ControlCenterButton.qml index 65ccdb78..dc2f5f54 100644 --- a/Widgets/TopBar/ControlCenterButton.qml +++ b/Widgets/TopBar/ControlCenterButton.qml @@ -90,13 +90,16 @@ Rectangle { acceptedButtons: Qt.NoButton onWheel: function(wheelEvent) { let delta = wheelEvent.angleDelta.y; - let currentVolume = AudioService.volumeLevel; + let currentVolume = (AudioService.sink && AudioService.sink.audio && AudioService.sink.audio.volume * 100) || 0; let newVolume; if (delta > 0) newVolume = Math.min(100, currentVolume + 5); else newVolume = Math.max(0, currentVolume - 5); - AudioService.setVolume(newVolume); + if (AudioService.sink && AudioService.sink.audio) { + AudioService.sink.audio.muted = false; + AudioService.sink.audio.volume = newVolume / 100; + } wheelEvent.accepted = true; } }