194 lines
6.8 KiB
TypeScript
194 lines
6.8 KiB
TypeScript
import Gtk from "gi://Gtk?version=3.0"
|
|
import Gio from "gi://Gio"
|
|
import AstalBluetooth from "gi://AstalBluetooth"
|
|
import { bind, Variable } from "astal"
|
|
import { ArrowToggleButton, Menu } from "./ToggleButton"
|
|
|
|
const bt = AstalBluetooth.get_default()
|
|
|
|
function power(on: boolean) {
|
|
if (bt.adapter) bt.adapter.powered = on
|
|
}
|
|
|
|
function setDiscovery(on: boolean) {
|
|
if (!bt.adapter) return
|
|
try {
|
|
if (on) bt.adapter.start_discovery()
|
|
else bt.adapter.stop_discovery()
|
|
} catch {
|
|
// expected when already in the requested state: bluez stops
|
|
// discovery itself on connect, auto-starts it, etc.
|
|
}
|
|
}
|
|
|
|
// bluez only registers devices that are paired or seen during discovery;
|
|
// the list is mirrored into a Variable refreshed on device add/remove
|
|
const devices = Variable<AstalBluetooth.Device[]>(bt.get_devices())
|
|
const refreshDevices = () => devices.set(bt.get_devices())
|
|
bt.connect("notify::devices", refreshDevices)
|
|
bt.connect("device_added", refreshDevices)
|
|
bt.connect("device_removed", refreshDevices)
|
|
|
|
const btIcon = bind(bt, "is_powered").as((p) =>
|
|
p ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic",
|
|
)
|
|
|
|
const connectedCount = devices().as(
|
|
(ds) => ds.filter((d) => d.connected).length,
|
|
)
|
|
|
|
const pillLabel = Variable.derive(
|
|
[bind(bt, "is_powered"), devices],
|
|
(powered, ds) => {
|
|
if (!powered) return "Disabled"
|
|
const connected = ds.filter((d) => d.connected)
|
|
if (connected.length === 1) return connected[0].alias
|
|
return `${connected.length} Connected`
|
|
},
|
|
)
|
|
|
|
// ported from AlfheimOS: icon, name, battery, busy icon while connecting,
|
|
// switch reflecting the bluez connected state
|
|
function DeviceItem(device: AstalBluetooth.Device) {
|
|
const battery = bind(device, "battery_percentage")
|
|
const connecting = bind(device, "connecting")
|
|
// bluez reports br-connection-busy when attempts pile up, so debounce
|
|
let lastAttempt = 0
|
|
return (
|
|
<box className="device-item">
|
|
<icon
|
|
icon={
|
|
device.icon
|
|
? `${device.icon}-symbolic`
|
|
: "bluetooth-active-symbolic"
|
|
}
|
|
/>
|
|
<label
|
|
className="device-name"
|
|
label={device.alias ?? device.address}
|
|
halign={Gtk.Align.START}
|
|
hexpand
|
|
/>
|
|
<label
|
|
className="device-battery"
|
|
visible={battery.as((p) => p > 0)}
|
|
label={battery.as((p) => `${Math.round(p * 100)}%`)}
|
|
/>
|
|
<icon
|
|
className="device-connecting"
|
|
icon="view-refresh-symbolic"
|
|
visible={connecting}
|
|
/>
|
|
<switch
|
|
visible={connecting.as((c) => !c)}
|
|
active={bind(device, "connected")}
|
|
onStateSet={(_, state) => {
|
|
// bluez rejects connects while the controller is busy scanning
|
|
if (state) setDiscovery(false)
|
|
// skip no-ops and retries within the debounce window
|
|
const now = Date.now()
|
|
if (state === device.connected || now - lastAttempt < 1500) {
|
|
return true
|
|
}
|
|
lastAttempt = now
|
|
// vala async: begin with a callback, finish() throws
|
|
const done = (_source: AstalBluetooth.Device, res: Gio.AsyncResult) => {
|
|
try {
|
|
if (state) device.connect_device_finish(res)
|
|
else device.disconnect_device_finish(res)
|
|
} catch (e) {
|
|
console.error(e)
|
|
}
|
|
}
|
|
if (state) device.connect_device(done)
|
|
else device.disconnect_device(done)
|
|
return true
|
|
}}
|
|
/>
|
|
</box>
|
|
)
|
|
}
|
|
|
|
export function BluetoothSection({ open }: { open: Variable<boolean> }) {
|
|
if (!bt.adapter) return <box />
|
|
|
|
const expanded = Variable(false)
|
|
open.subscribe((v) => {
|
|
if (!v) {
|
|
expanded.set(false)
|
|
setDiscovery(false)
|
|
}
|
|
})
|
|
|
|
return (
|
|
<box className="bluetooth-section" vertical>
|
|
<ArrowToggleButton
|
|
open={expanded}
|
|
icon={btIcon}
|
|
label={pillLabel()}
|
|
active={bind(bt, "is_powered")}
|
|
onMainClick={() => {
|
|
if (bt.is_powered) {
|
|
power(false)
|
|
expanded.set(false)
|
|
setDiscovery(false)
|
|
} else {
|
|
power(true)
|
|
}
|
|
}}
|
|
onArrowClick={() => {
|
|
const next = !expanded.get()
|
|
expanded.set(next)
|
|
if (next) {
|
|
if (!bt.is_powered) power(true)
|
|
setDiscovery(true)
|
|
} else {
|
|
setDiscovery(false)
|
|
}
|
|
}}
|
|
/>
|
|
<Menu open={expanded} icon="bluetooth-active-symbolic" title="Bluetooth">
|
|
<label
|
|
className="wifi-empty"
|
|
label="bluetooth is off"
|
|
visible={bind(bt, "is_powered").as((p) => !p)}
|
|
/>
|
|
<label
|
|
className="wifi-empty"
|
|
label="searching for devices…"
|
|
visible={Variable.derive(
|
|
[bind(bt, "is_powered"), devices],
|
|
(powered, ds) => powered && ds.filter((d) => d.name).length === 0,
|
|
)()}
|
|
/>
|
|
<scrollable
|
|
hscroll={Gtk.PolicyType.NEVER}
|
|
vscroll={Gtk.PolicyType.AUTOMATIC}
|
|
maxContentHeight={360}
|
|
propagateNaturalHeight
|
|
visible={bind(bt, "is_powered")}
|
|
>
|
|
<box vertical>
|
|
{devices().as((ds) =>
|
|
ds.filter((d) => d.name).map(DeviceItem),
|
|
)}
|
|
</box>
|
|
</scrollable>
|
|
</Menu>
|
|
</box>
|
|
)
|
|
}
|
|
|
|
// bar cluster indicator: shown while bluetooth is powered, with a count
|
|
// badge when devices are connected
|
|
export const bluetoothIndicator: JSX.Element | null = bt.adapter ? (
|
|
<box className="bt-indicator" spacing={3} visible={bind(bt, "is_powered")}>
|
|
<icon icon="bluetooth-active-symbolic" />
|
|
<label
|
|
className="bt-count"
|
|
visible={connectedCount.as((n) => n > 0)}
|
|
label={connectedCount.as((n) => `${n}`)}
|
|
/>
|
|
</box>
|
|
) : null
|