From 73d47390c4e5a763a35e026d09f98db755b2920a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 8 Jun 2026 17:20:59 +0200 Subject: [PATCH] Improve Bluetooth panel connected devices --- shell/plugins/panels/bluetooth/Model.js | 44 ++++++++ shell/plugins/panels/bluetooth/Panel.qml | 134 ++++++++++++++++++----- test/shell.d/bluetooth-test.sh | 32 ++++++ 3 files changed, 182 insertions(+), 28 deletions(-) diff --git a/shell/plugins/panels/bluetooth/Model.js b/shell/plugins/panels/bluetooth/Model.js index c463b924..92961700 100644 --- a/shell/plugins/panels/bluetooth/Model.js +++ b/shell/plugins/panels/bluetooth/Model.js @@ -29,11 +29,51 @@ function isAddressLike(value) { return /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(text) } +function normalizedAddress(value) { + return String(value || "").trim().toLowerCase().replace(/[^0-9a-f]/g, "") +} + function hasHumanName(device) { var label = deviceLabel(device) return label !== "" && !isUuidLike(label) && !isAddressLike(label) } +function nodeProps(node) { + return node && node.ready && node.properties ? node.properties : {} +} + +function nodeText(node) { + var props = nodeProps(node) + return [ + node ? node.name : "", + node ? node.description : "", + node ? node.nickname : "", + node ? node.nick : "", + props["node.name"], + props["node.description"], + props["node.nick"], + props["device.name"], + props["device.description"], + props["device.product.name"], + props["device.alias"], + props["device.string"], + props["api.bluez5.address"], + props["bluez5.address"], + props["media.name"] + ].join(" ").toLowerCase() +} + +function bluetoothSinkMatchesDevice(node, device) { + if (!node || !node.isSink || node.isStream || !device) return false + + var address = normalizedAddress(device.address) + var text = nodeText(node) + if (address !== "" && normalizedAddress(text).indexOf(address) !== -1) return true + + var label = deviceLabel(device).toLowerCase() + return label !== "" && text.indexOf(label) !== -1 +} + function sortedByLabel(devices) { var list = toArray(devices) list.sort(function(a, b) { return deviceLabel(a).localeCompare(deviceLabel(b)) }) @@ -101,7 +141,11 @@ if (typeof module !== "undefined") { toArray: toArray, isUuidLike: isUuidLike, isAddressLike: isAddressLike, + normalizedAddress: normalizedAddress, hasHumanName: hasHumanName, + nodeProps: nodeProps, + nodeText: nodeText, + bluetoothSinkMatchesDevice: bluetoothSinkMatchesDevice, sortedByLabel: sortedByLabel, deviceLists: deviceLists, cloneMap: cloneMap, diff --git a/shell/plugins/panels/bluetooth/Panel.qml b/shell/plugins/panels/bluetooth/Panel.qml index c1bbd51c..b66a62ed 100644 --- a/shell/plugins/panels/bluetooth/Panel.qml +++ b/shell/plugins/panels/bluetooth/Panel.qml @@ -3,6 +3,7 @@ import QtQuick.Controls import Quickshell import Quickshell.Io import Quickshell.Bluetooth +import Quickshell.Services.Pipewire import qs.Ui import qs.Commons import "Model.js" as Model @@ -19,6 +20,9 @@ Panel { readonly property var adapter: Bluetooth.defaultAdapter readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : [] + readonly property var pipewireNodes: Pipewire.nodes ? Pipewire.nodes.values : [] + property var pendingAudioOutputDevice: null + property int pendingAudioOutputAttempts: 0 function deviceLabel(device) { return Model.deviceLabel(device) @@ -112,6 +116,64 @@ Panel { return Model.sectionDevices(deviceGroups, section) } + function audioSinks() { + var sinks = [] + for (var i = 0; i < pipewireNodes.length; i++) { + var node = pipewireNodes[i] + if (node && node.isSink && !node.isStream) sinks.push(node) + } + return sinks + } + + function bluetoothAudioSink(device) { + var sinks = audioSinks() + for (var i = 0; i < sinks.length; i++) { + if (Model.bluetoothSinkMatchesDevice(sinks[i], device)) return sinks[i] + } + return null + } + + function setDefaultAudioSink(sink) { + if (!sink) return + Pipewire.preferredDefaultAudioSink = sink + if (root.bar && root.bar.omarchyPath && sink.id !== undefined && sink.name) { + Quickshell.execDetached([ + root.bar.omarchyPath + "/bin/omarchy-audio-output-set-default", + String(sink.id), + String(sink.name) + ]) + } + } + + function scheduleAudioOutputSwitch(device) { + pendingAudioOutputDevice = { + address: device && device.address ? device.address : "", + name: device && device.name ? device.name : "", + deviceName: device && device.deviceName ? device.deviceName : "" + } + pendingAudioOutputAttempts = 0 + audioSwitchTimer.restart() + } + + function switchPendingAudioOutput() { + if (!pendingAudioOutputDevice) return + + var sink = bluetoothAudioSink(pendingAudioOutputDevice) + if (sink) { + setDefaultAudioSink(sink) + pendingAudioOutputDevice = null + audioSwitchTimer.stop() + return + } + + pendingAudioOutputAttempts += 1 + if (pendingAudioOutputAttempts >= 8) { + pendingAudioOutputDevice = null + return + } + audioSwitchTimer.restart() + } + function deviceAt(section, index) { var list = devicesForSection(section) return index >= 0 && index < list.length ? list[index] : null @@ -179,9 +241,11 @@ Panel { } } - if ((action === "connecting" && found && found.connected) + var finishedConnecting = action === "connecting" && found && found.connected + if (finishedConnecting || (action === "disconnecting" && found && !found.connected) || (action === "forgetting" && (!found || (!found.paired && !found.bonded && !found.trusted)))) { + if (finishedConnecting) scheduleAudioOutputSwitch(found) delete next[address] changed = true } @@ -360,6 +424,13 @@ Panel { onTriggered: root.pendingActions = ({}) } + Timer { + id: audioSwitchTimer + interval: 500 + repeat: false + onTriggered: root.switchPendingAudioOutput() + } + Timer { id: phraseTimer interval: 2800 @@ -512,6 +583,39 @@ Panel { foreground: root.bar.foreground } + Column { + id: connectedList + visible: root.connectedDevices.length > 0 + width: parent.width + spacing: Style.space(10) + + PanelSectionHeader { + text: "CONNECTED" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + } + + Repeater { + model: root.connectedDevices + DeviceRow { + required property var modelData + required property int index + width: connectedList.width + dev: modelData + rowIndex: index + sectionName: "connected" + isDiscovered: false + } + } + } + + PanelSeparator { + visible: root.connectedDevices.length > 0 + && (root.knownDevices.length > 0 + || (root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0)) + foreground: root.bar.foreground + } + Flickable { id: deviceFlick width: parent.width @@ -528,33 +632,7 @@ Panel { width: parent.width spacing: Style.space(10) - // Connected devices. - PanelSectionHeader { - visible: root.connectedDevices.length > 0 - text: "CONNECTED" - foreground: root.bar.foreground - fontFamily: root.bar.fontFamily - } - - Repeater { - model: root.connectedDevices - DeviceRow { - required property var modelData - required property int index - width: deviceList.width - dev: modelData - rowIndex: index - sectionName: "connected" - isDiscovered: false - } - } - // Remembered devices. - PanelSeparator { - visible: root.connectedDevices.length > 0 && root.knownDevices.length > 0 - foreground: root.bar.foreground - } - PanelSectionHeader { visible: root.knownDevices.length > 0 text: "PAIRED" @@ -578,7 +656,7 @@ Panel { // Discovered (unpaired) devices, only shown while scanning. PanelSeparator { visible: root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0 - && (root.connectedDevices.length > 0 || root.knownDevices.length > 0) + && root.knownDevices.length > 0 foreground: root.bar.foreground } diff --git a/test/shell.d/bluetooth-test.sh b/test/shell.d/bluetooth-test.sh index 00848673..6c52038b 100644 --- a/test/shell.d/bluetooth-test.sh +++ b/test/shell.d/bluetooth-test.sh @@ -13,6 +13,7 @@ const bluetooth = requireFromRoot('shell/plugins/panels/bluetooth/Model.js') assert(bluetooth.isUuidLike('0000110b-0000-1000-8000-00805f9b34fb'), 'bluetooth detects UUID-like names') assert(bluetooth.isAddressLike('AA:BB:CC:DD:EE:FF'), 'bluetooth detects address-like names') +assertEqual(bluetooth.normalizedAddress('AA:BB_CC-dd-ee-ff'), 'aabbccddeeff', 'bluetooth normalizes BlueZ and PipeWire address formats') assert(!bluetooth.hasHumanName({ name: 'AA:BB:CC:DD:EE:FF' }), 'bluetooth rejects address-only device labels') assert(bluetooth.hasHumanName({ deviceName: 'MX Master 3S' }), 'bluetooth accepts human device labels') @@ -58,4 +59,35 @@ assertDeepEqual( 'bluetooth adds pending actions immutably' ) assertDeepEqual(bluetooth.withPendingAction({ a: 'connecting' }, 'a', ''), {}, 'bluetooth clears pending actions immutably') + +const bluetoothSink = { + isSink: true, + isStream: false, + ready: true, + name: 'bluez_output.AA_BB_CC_DD_EE_FF.1', + properties: { + 'device.product.name': 'JBL Go 3' + } +} +assert( + bluetooth.bluetoothSinkMatchesDevice(bluetoothSink, { address: 'AA:BB:CC:DD:EE:FF', name: 'JBL Go 3' }), + 'bluetooth matches audio sinks by device address' +) +assert( + bluetooth.bluetoothSinkMatchesDevice( + { + isSink: true, + isStream: false, + ready: true, + name: 'alsa_output.usb-speaker', + properties: { 'device.product.name': 'JBL Go 3' } + }, + { address: '11:22:33:44:55:66', name: 'JBL Go 3' } + ), + 'bluetooth matches audio sinks by human device label when address is unavailable' +) +assert( + !bluetooth.bluetoothSinkMatchesDevice({ isSink: false, isStream: false, ready: true, name: 'bluez_output.AA_BB_CC_DD_EE_FF.1', properties: {} }, { address: 'AA:BB:CC:DD:EE:FF', name: 'JBL Go 3' }), + 'bluetooth ignores non-sink nodes when matching audio outputs' +) JS