diff --git a/shell/modules/BluetoothMenu.qml b/shell/modules/BluetoothMenu.qml index 67a2478..f348e90 100644 --- a/shell/modules/BluetoothMenu.qml +++ b/shell/modules/BluetoothMenu.qml @@ -1,6 +1,5 @@ import QtQuick import Quickshell -import Quickshell.Io import "." as M import "../services" as S @@ -19,7 +18,7 @@ M.HoverPanel { Text { anchors.centerIn: parent text: "\uF011" - color: menuWindow._btEnabled ? menuWindow.accentColor : S.Theme.base04 + color: S.BluetoothService.enabled ? menuWindow.accentColor : S.Theme.base04 font.pixelSize: S.Theme.fontSize font.family: S.Theme.iconFontFamily @@ -35,78 +34,16 @@ M.HoverPanel { } TapHandler { - onTapped: { - powerProc._action = menuWindow._btEnabled ? "off" : "on"; - powerProc.running = true; - } + onTapped: S.BluetoothService.setPower(!S.BluetoothService.enabled) } } } onVisibleChanged: if (visible) - scanner.running = true - - property var _devices: [] - property bool _btEnabled: true - - property Process _scanner: Process { - id: scanner - running: false - command: ["sh", "-c", "bluetoothctl show 2>/dev/null | awk '/Powered:/{print $2; exit}';" + "echo '---DEVICES---';" + "bluetoothctl devices Paired 2>/dev/null | while read -r _ mac name; do " + "info=$(bluetoothctl info \"$mac\" 2>/dev/null); " + "conn=$(echo \"$info\" | grep -c 'Connected: yes'); " + "bat=$(echo \"$info\" | awk -F'[(): ]' '/Battery Percentage/{for(i=1;i<=NF;i++) if($i+0==$i && $i!=\"\") print $i}'); " + "echo \"$mac:$conn:${bat:-}:$name\"; " + "done"] - stdout: StdioCollector { - onStreamFinished: { - const sections = text.split("---DEVICES---"); - menuWindow._btEnabled = (sections[0] || "").trim() === "yes"; - - const devs = []; - for (const line of (sections[1] || "").trim().split("\n")) { - if (!line) - continue; - const i1 = line.indexOf(":"); - const i2 = line.indexOf(":", i1 + 1); - const i3 = line.indexOf(":", i2 + 1); - if (i3 < 0) - continue; - devs.push({ - "mac": line.slice(0, i1), - "connected": line.slice(i1 + 1, i2) === "1", - "battery": parseInt(line.slice(i2 + 1, i3)) || -1, - "name": line.slice(i3 + 1) - }); - } - devs.sort((a, b) => { - if (a.connected !== b.connected) - return a.connected ? -1 : 1; - return a.name.localeCompare(b.name); - }); - menuWindow._devices = devs; - } - } - } - - property Process _powerProc: Process { - id: powerProc - property string _action: "" - command: ["bluetoothctl", "power", _action] - onRunningChanged: if (!running) { - scanner.running = true; - menuWindow.keepOpen(500); - } - } - - property Process _toggleProc: Process { - id: toggleProc - property string action: "" - property string mac: "" - command: ["bluetoothctl", action, mac] - onRunningChanged: if (!running) { - scanner.running = true; - menuWindow.keepOpen(500); - } - } + S.BluetoothService.refresh() Repeater { - model: menuWindow._devices + model: S.BluetoothService.devices delegate: Item { id: entry @@ -167,21 +104,20 @@ M.HoverPanel { } TapHandler { onTapped: { - toggleProc.action = entry.modelData.connected ? "disconnect" : "connect"; - toggleProc.mac = entry.modelData.mac; - toggleProc.running = true; + S.BluetoothService.toggleDevice(entry.modelData.mac, !entry.modelData.connected); + menuWindow.keepOpen(500); } } } } Text { - visible: menuWindow._devices.length === 0 + visible: S.BluetoothService.devices.length === 0 width: menuWindow.contentWidth height: 32 horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter - text: menuWindow._btEnabled ? "No paired devices" : "Bluetooth is off" + text: S.BluetoothService.enabled ? "No paired devices" : "Bluetooth is off" color: S.Theme.base04 font.pixelSize: S.Theme.fontSize font.family: S.Theme.fontFamily diff --git a/shell/modules/BluetoothModule.qml b/shell/modules/BluetoothModule.qml index 03ecf5c..81e715e 100644 --- a/shell/modules/BluetoothModule.qml +++ b/shell/modules/BluetoothModule.qml @@ -1,72 +1,24 @@ import QtQuick import Quickshell -import Quickshell.Io import "." as M import "../services" as S M.BarSection { id: root spacing: S.Theme.moduleSpacing - opacity: S.Modules.bluetooth.enable && root.state !== "unavailable" ? 1 : 0 + opacity: S.Modules.bluetooth.enable && S.BluetoothService.state !== "unavailable" ? 1 : 0 visible: opacity > 0 tooltip: { - if (root.state === "off") + if (S.BluetoothService.state === "off") return "Bluetooth: off"; - if (root.state === "connected") - return "Bluetooth: " + root.device + (root.batteryPct >= 0 ? "\nBattery: " + root.batteryPct + "%" : ""); + if (S.BluetoothService.state === "connected") + return "Bluetooth: " + S.BluetoothService.device + (S.BluetoothService.batteryPct >= 0 ? "\nBattery: " + S.BluetoothService.batteryPct + "%" : ""); return "Bluetooth: on"; } - property string state: "unavailable" - property string device: "" - property int batteryPct: -1 - - function _parse(text) { - const lines = text.trim().split("\n"); - const t = lines[0] || ""; - const sep = t.indexOf(":"); - root.state = sep === -1 ? t : t.slice(0, sep); - root.device = sep === -1 ? "" : t.slice(sep + 1); - root.batteryPct = -1; - for (let i = 1; i < lines.length; i++) { - if (lines[i].startsWith("bat:")) - root.batteryPct = parseInt(lines[i].slice(4)) || -1; - } - } - - Process { - id: proc - running: S.Modules.bluetooth.enable - command: ["sh", "-c", "s=$(bluetoothctl show 2>/dev/null); " + "[ -z \"$s\" ] && echo unavailable && exit; " + "echo \"$s\" | grep -q 'Powered: yes' || { echo off:; exit; }; " + "info=$(bluetoothctl info 2>/dev/null); " + "d=$(echo \"$info\" | awk -F': ' '/\\tName:/{n=$2}/Connected: yes/{c=1}END{if(c)print n}'); " + "[ -n \"$d\" ] && echo \"connected:$d\" || { echo on:; exit; }; " + "bat=$(echo \"$info\" | awk -F': ' '/Battery Percentage.*\\(/{gsub(/[^0-9]/,\"\",$2);print $2}'); " + "[ -n \"$bat\" ] && echo \"bat:$bat\""] - stdout: StdioCollector { - onStreamFinished: root._parse(text) - } - } - // Event-driven: watch BlueZ DBus property changes - Process { - id: btMonitor - running: S.Modules.bluetooth.enable - command: ["sh", "-c", "dbus-monitor --system \"interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path_namespace='/org/bluez'\" 2>/dev/null"] - stdout: SplitParser { - splitMarker: "\n" - onRead: _debounce.restart() - } - } - Timer { - id: _debounce - interval: 500 - onTriggered: proc.running = true - } - Timer { - interval: 60000 - running: S.Modules.bluetooth.enable - repeat: true - onTriggered: proc.running = true - } - M.BarIcon { icon: "\uF294" - color: root.state === "off" ? S.Theme.base04 : root.accentColor + color: S.BluetoothService.state === "off" ? S.Theme.base04 : root.accentColor anchors.verticalCenter: parent.verticalCenter TapHandler { onTapped: { @@ -76,8 +28,8 @@ M.BarSection { } } M.BarLabel { - visible: root.state === "connected" - label: root.device + (root.batteryPct >= 0 ? " " + root.batteryPct + "%" : "") + visible: S.BluetoothService.state === "connected" + label: S.BluetoothService.device + (S.BluetoothService.batteryPct >= 0 ? " " + S.BluetoothService.batteryPct + "%" : "") anchors.verticalCenter: parent.verticalCenter TapHandler { onTapped: { diff --git a/shell/services/BluetoothService.qml b/shell/services/BluetoothService.qml new file mode 100644 index 0000000..e648538 --- /dev/null +++ b/shell/services/BluetoothService.qml @@ -0,0 +1,126 @@ +pragma Singleton + +import QtQuick +import Quickshell.Io +import "." as S + +QtObject { + id: root + + // Adapter state: "unavailable" | "off" | "on" | "connected" + property string state: "unavailable" + property string device: "" + property int batteryPct: -1 + + // Paired device list and power state (for menu) + property var devices: [] + property bool enabled: true + + function refresh() { + _statusProc.running = true; + _scannerProc.running = true; + } + + function setPower(on) { + _powerProc._action = on ? "on" : "off"; + _powerProc.running = true; + } + + function toggleDevice(mac, connect) { + _toggleProc._action = connect ? "connect" : "disconnect"; + _toggleProc._mac = mac; + _toggleProc.running = true; + } + + // Status polling (bar icon state) + property Process _statusProc: Process { + running: S.Modules.bluetooth.enable + command: ["sh", "-c", "s=$(bluetoothctl show 2>/dev/null); " + "[ -z \"$s\" ] && echo unavailable && exit; " + "echo \"$s\" | grep -q 'Powered: yes' || { echo off:; exit; }; " + "info=$(bluetoothctl info 2>/dev/null); " + "d=$(echo \"$info\" | awk -F': ' '/\\tName:/{n=$2}/Connected: yes/{c=1}END{if(c)print n}'); " + "[ -n \"$d\" ] && echo \"connected:$d\" || { echo on:; exit; }; " + "bat=$(echo \"$info\" | awk -F': ' '/Battery Percentage.*\\(/{gsub(/[^0-9]/,\"\",$2);print $2}'); " + "[ -n \"$bat\" ] && echo \"bat:$bat\""] + stdout: StdioCollector { + onStreamFinished: { + const lines = text.trim().split("\n"); + const t = lines[0] || ""; + const sep = t.indexOf(":"); + root.state = sep === -1 ? t : t.slice(0, sep); + root.device = sep === -1 ? "" : t.slice(sep + 1); + root.batteryPct = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i].startsWith("bat:")) + root.batteryPct = parseInt(lines[i].slice(4)) || -1; + } + } + } + } + + // Event-driven: watch BlueZ DBus property changes + property Process _monitor: Process { + running: S.Modules.bluetooth.enable + command: ["sh", "-c", "dbus-monitor --system \"interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path_namespace='/org/bluez'\" 2>/dev/null"] + stdout: SplitParser { + splitMarker: "\n" + onRead: _debounce.restart() + } + } + + property Timer _debounce: Timer { + interval: 500 + onTriggered: root.refresh() + } + + property Timer _fallbackPoll: Timer { + interval: 60000 + running: S.Modules.bluetooth.enable + repeat: true + onTriggered: root.refresh() + } + + // Paired device scanner (for menu) + property Process _scannerProc: Process { + command: ["sh", "-c", "bluetoothctl show 2>/dev/null | awk '/Powered:/{print $2; exit}';" + "echo '---DEVICES---';" + "bluetoothctl devices Paired 2>/dev/null | while read -r _ mac name; do " + "info=$(bluetoothctl info \"$mac\" 2>/dev/null); " + "conn=$(echo \"$info\" | grep -c 'Connected: yes'); " + "bat=$(echo \"$info\" | awk -F'[(): ]' '/Battery Percentage/{for(i=1;i<=NF;i++) if($i+0==$i && $i!=\"\") print $i}'); " + "echo \"$mac:$conn:${bat:-}:$name\"; " + "done"] + stdout: StdioCollector { + onStreamFinished: { + const sections = text.split("---DEVICES---"); + root.enabled = (sections[0] || "").trim() === "yes"; + + const devs = []; + for (const line of (sections[1] || "").trim().split("\n")) { + if (!line) + continue; + const i1 = line.indexOf(":"); + const i2 = line.indexOf(":", i1 + 1); + const i3 = line.indexOf(":", i2 + 1); + if (i3 < 0) + continue; + devs.push({ + mac: line.slice(0, i1), + connected: line.slice(i1 + 1, i2) === "1", + battery: parseInt(line.slice(i2 + 1, i3)) || -1, + name: line.slice(i3 + 1) + }); + } + devs.sort((a, b) => { + if (a.connected !== b.connected) + return a.connected ? -1 : 1; + return a.name.localeCompare(b.name); + }); + root.devices = devs; + } + } + } + + // Action processes + property Process _powerProc: Process { + property string _action: "" + command: ["bluetoothctl", "power", _action] + onRunningChanged: if (!running) + root.refresh() + } + + property Process _toggleProc: Process { + property string _action: "" + property string _mac: "" + command: ["bluetoothctl", _action, _mac] + onRunningChanged: if (!running) + root.refresh() + } +} diff --git a/shell/services/qmldir b/shell/services/qmldir index c7765f3..59fb32a 100644 --- a/shell/services/qmldir +++ b/shell/services/qmldir @@ -10,3 +10,4 @@ singleton LockService 1.0 LockService.qml singleton BacklightService 1.0 BacklightService.qml singleton MprisService 1.0 MprisService.qml singleton NetworkService 1.0 NetworkService.qml +singleton BluetoothService 1.0 BluetoothService.qml