Improve Bluetooth panel connected devices
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user