nixos/modules/home/ags/app/widget/Bluetooth.tsx

189 lines
6.3 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 null
const expanded = Variable(false)
open.subscribe((v) => {
if (!v) {
expanded.set(false)
setDiscovery(false)
}
})
const toggle = (
<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)
}
}}
/>
)
const menu = (
<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,
)()}
/>
<box vertical visible={bind(bt, "is_powered")}>
{devices().as((ds) =>
ds.filter((d) => d.name).map(DeviceItem),
)}
</box>
</Menu>
)
return { toggle, menu }
}
// 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