Improve Bluetooth panel connected devices

This commit is contained in:
David Heinemeier Hansson
2026-06-08 17:20:59 +02:00
parent 42a0b25e01
commit 73d47390c4
3 changed files with 182 additions and 28 deletions
+44
View File
@@ -29,11 +29,51 @@ function isAddressLike(value) {
return /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(text) 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) { function hasHumanName(device) {
var label = deviceLabel(device) var label = deviceLabel(device)
return label !== "" && !isUuidLike(label) && !isAddressLike(label) 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) { function sortedByLabel(devices) {
var list = toArray(devices) var list = toArray(devices)
list.sort(function(a, b) { return deviceLabel(a).localeCompare(deviceLabel(b)) }) list.sort(function(a, b) { return deviceLabel(a).localeCompare(deviceLabel(b)) })
@@ -101,7 +141,11 @@ if (typeof module !== "undefined") {
toArray: toArray, toArray: toArray,
isUuidLike: isUuidLike, isUuidLike: isUuidLike,
isAddressLike: isAddressLike, isAddressLike: isAddressLike,
normalizedAddress: normalizedAddress,
hasHumanName: hasHumanName, hasHumanName: hasHumanName,
nodeProps: nodeProps,
nodeText: nodeText,
bluetoothSinkMatchesDevice: bluetoothSinkMatchesDevice,
sortedByLabel: sortedByLabel, sortedByLabel: sortedByLabel,
deviceLists: deviceLists, deviceLists: deviceLists,
cloneMap: cloneMap, cloneMap: cloneMap,
+106 -28
View File
@@ -3,6 +3,7 @@ import QtQuick.Controls
import Quickshell import Quickshell
import Quickshell.Io import Quickshell.Io
import Quickshell.Bluetooth import Quickshell.Bluetooth
import Quickshell.Services.Pipewire
import qs.Ui import qs.Ui
import qs.Commons import qs.Commons
import "Model.js" as Model import "Model.js" as Model
@@ -19,6 +20,9 @@ Panel {
readonly property var adapter: Bluetooth.defaultAdapter readonly property var adapter: Bluetooth.defaultAdapter
readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : [] 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) { function deviceLabel(device) {
return Model.deviceLabel(device) return Model.deviceLabel(device)
@@ -112,6 +116,64 @@ Panel {
return Model.sectionDevices(deviceGroups, section) 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) { function deviceAt(section, index) {
var list = devicesForSection(section) var list = devicesForSection(section)
return index >= 0 && index < list.length ? list[index] : null 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 === "disconnecting" && found && !found.connected)
|| (action === "forgetting" && (!found || (!found.paired && !found.bonded && !found.trusted)))) { || (action === "forgetting" && (!found || (!found.paired && !found.bonded && !found.trusted)))) {
if (finishedConnecting) scheduleAudioOutputSwitch(found)
delete next[address] delete next[address]
changed = true changed = true
} }
@@ -360,6 +424,13 @@ Panel {
onTriggered: root.pendingActions = ({}) onTriggered: root.pendingActions = ({})
} }
Timer {
id: audioSwitchTimer
interval: 500
repeat: false
onTriggered: root.switchPendingAudioOutput()
}
Timer { Timer {
id: phraseTimer id: phraseTimer
interval: 2800 interval: 2800
@@ -512,6 +583,39 @@ Panel {
foreground: root.bar.foreground 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 { Flickable {
id: deviceFlick id: deviceFlick
width: parent.width width: parent.width
@@ -528,33 +632,7 @@ Panel {
width: parent.width width: parent.width
spacing: Style.space(10) 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. // Remembered devices.
PanelSeparator {
visible: root.connectedDevices.length > 0 && root.knownDevices.length > 0
foreground: root.bar.foreground
}
PanelSectionHeader { PanelSectionHeader {
visible: root.knownDevices.length > 0 visible: root.knownDevices.length > 0
text: "PAIRED" text: "PAIRED"
@@ -578,7 +656,7 @@ Panel {
// Discovered (unpaired) devices, only shown while scanning. // Discovered (unpaired) devices, only shown while scanning.
PanelSeparator { PanelSeparator {
visible: root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0 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 foreground: root.bar.foreground
} }
+32
View File
@@ -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.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') 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({ 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') assert(bluetooth.hasHumanName({ deviceName: 'MX Master 3S' }), 'bluetooth accepts human device labels')
@@ -58,4 +59,35 @@ assertDeepEqual(
'bluetooth adds pending actions immutably' 'bluetooth adds pending actions immutably'
) )
assertDeepEqual(bluetooth.withPendingAction({ a: 'connecting' }, 'a', ''), {}, 'bluetooth clears 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 JS