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)
}
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,
+106 -28
View File
@@ -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
}
+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.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