mirror of
https://github.com/arthur-pbty/arthur-os.git
synced 2026-08-01 20:28:16 +02:00
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)
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user