diff --git a/Modules/Bar/Widgets/Bluetooth.qml b/Modules/Bar/Widgets/Bluetooth.qml index 38c9050..0b8b4f9 100644 --- a/Modules/Bar/Widgets/Bluetooth.qml +++ b/Modules/Bar/Widgets/Bluetooth.qml @@ -20,16 +20,7 @@ NIconButton { colorBorder: Color.transparent colorBorderHover: Color.transparent - icon: { - // Show different icons based on connection status - if (BluetoothService.pairedDevices.length > 0) { - return "bluetooth_connected" - } else if (BluetoothService.discovering) { - return "bluetooth_searching" - } else { - return "bluetooth" - } - } + icon: "bluetooth" tooltipText: "Bluetooth Devices" onClicked: PanelService.getPanel("bluetoothPanel")?.toggle(screen, this) } diff --git a/Modules/BluetoothPanel/BluetoothDevicesList.qml b/Modules/BluetoothPanel/BluetoothDevicesList.qml new file mode 100644 index 0000000..9d4f981 --- /dev/null +++ b/Modules/BluetoothPanel/BluetoothDevicesList.qml @@ -0,0 +1,262 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Wayland +import qs.Commons +import qs.Services +import qs.Widgets + +ColumnLayout { + id: root + + property string label: "" + property var model: { + + } + + Layout.fillWidth: true + spacing: Style.marginM * scaling + + NText { + text: root.label + font.pointSize: Style.fontSizeL * scaling + color: Color.mSecondary + font.weight: Style.fontWeightMedium + Layout.fillWidth: true + visible: root.model.length > 0 + } + + Repeater { + Layout.fillWidth: true + model: root.model + visible: BluetoothService.adapter && BluetoothService.adapter.enabled + + Rectangle { + property bool canConnect: BluetoothService.canConnect(modelData) + property bool isBusy: BluetoothService.isDeviceBusy(modelData) + + Layout.fillWidth: true + Layout.preferredHeight: 64 * scaling + (10 * scaling * modelData.batteryAvailable) + radius: Style.radiusM * scaling + + color: { + if (availableDeviceArea.containsMouse && !isBusy) + return Color.mTertiary + + if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) + return Color.mPrimary + + if (modelData.blocked) + return Color.mError + + return Color.mSurfaceVariant + } + border.color: Color.mOutline + border.width: Math.max(1, Style.borderS * scaling) + + RowLayout { + anchors.fill: parent + anchors.margins: Style.marginM * scaling + spacing: Style.marginS * scaling + Layout.alignment: Qt.AlignVCenter + + // One device BT icon + NIcon { + text: BluetoothService.getDeviceIcon(modelData) + font.pointSize: Style.fontSizeXXL * scaling + color: { + if (availableDeviceArea.containsMouse) + return Color.mOnTertiary + + if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) + return Color.mOnPrimary + + if (modelData.blocked) + return Color.mOnError + + return Color.mOnSurface + } + Layout.alignment: Qt.AlignVCenter + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS * scaling + + // Device name + NText { + text: modelData.name || modelData.deviceName + font.pointSize: Style.fontSizeM * scaling + elide: Text.ElideRight + color: { + if (availableDeviceArea.containsMouse) + return Color.mOnTertiary + + if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) + return Color.mOnPrimary + + if (modelData.blocked) + return Color.mOnError + + return Color.mOnSurface + } + font.weight: Style.fontWeightMedium + Layout.fillWidth: true + } + + // Signal Strength + RowLayout { + Layout.fillWidth: true + spacing: Style.marginXS * scaling + + // Device signal strength - "Unknown" when not connected + NText { + text: BluetoothService.getSignalStrength(modelData) + font.pointSize: Style.fontSizeXS * scaling + color: { + if (availableDeviceArea.containsMouse) + return Color.mOnTertiary + + if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) + return Color.mOnPrimary + + if (modelData.blocked) + return Color.mOnError + + return Color.mOnSurfaceVariant + } + } + + NIcon { + text: BluetoothService.getSignalIcon(modelData) + font.pointSize: Style.fontSizeXS * scaling + color: { + if (availableDeviceArea.containsMouse) + return Color.mOnTertiary + + if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) + return Color.mOnPrimary + + if (modelData.blocked) + return Color.mOnError + + return Color.mOnSurface + } + visible: modelData.signalStrength !== undefined && modelData.signalStrength > 0 && !modelData.pairing + && !modelData.blocked + } + + NText { + text: (modelData.signalStrength !== undefined + && modelData.signalStrength > 0) ? modelData.signalStrength + "%" : "" + font.pointSize: Style.fontSizeXS * scaling + color: { + if (availableDeviceArea.containsMouse) + return Color.mOnTertiary + + if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) + return Color.mOnPrimary + + if (modelData.blocked) + return Color.mOnError + + return Color.mOnSurface + } + visible: modelData.signalStrength !== undefined && modelData.signalStrength > 0 && !modelData.pairing + && !modelData.blocked + } + } + + NText { + visible: modelData.batteryAvailable + text: BluetoothService.getBattery(modelData) + font.pointSize: Style.fontSizeXS * scaling + color: { + if (availableDeviceArea.containsMouse) + return Color.mOnTertiary + + if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) + return Color.mOnPrimary + + if (modelData.blocked) + return Color.mOnError + + return Color.mOnSurfaceVariant + } + } + } + + // Spacer to push connect button to the right + Item { + Layout.fillWidth: true + } + + // Call to action + Rectangle { + Layout.preferredWidth: 80 * scaling + Layout.preferredHeight: 28 * scaling + radius: Style.radiusM * scaling + visible: (modelData.state !== BluetoothDeviceState.Connecting) + color: Color.transparent + + border.color: { + if (availableDeviceArea.containsMouse) { + return Color.mOnTertiary + } else { + return Color.mPrimary + } + } + border.width: Math.max(1, Style.borderS * scaling) + opacity: canConnect || isBusy ? 1 : 0.5 + + NText { + anchors.centerIn: parent + text: { + if (modelData.pairing) { + return "Pairing..." + } + if (modelData.blocked) { + return "Blocked" + } + if (modelData.paired || modelData.trusted) { + return "Disconnect" + } + return "Connect" + } + font.pointSize: Style.fontSizeXS * scaling + font.weight: Style.fontWeightMedium + color: { + if (availableDeviceArea.containsMouse) { + return Color.mOnTertiary + } else { + return Color.mPrimary + } + } + } + } + } + + MouseArea { + id: availableDeviceArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: canConnect && !isBusy ? Qt.PointingHandCursor : (isBusy ? Qt.BusyCursor : Qt.ArrowCursor) + enabled: canConnect && !isBusy + onClicked: { + if (!modelData || modelData.pairing) { + return + } + + if (modelData.paired || modelData.trusted) { + BluetoothService.disconnectDevice(modelData) + } else { + BluetoothService.connectDeviceWithTrust(modelData) + } + } + } + } + } +} diff --git a/Modules/BluetoothPanel/BluetoothPanel.qml b/Modules/BluetoothPanel/BluetoothPanel.qml index 3518d9b..77ed79c 100644 --- a/Modules/BluetoothPanel/BluetoothPanel.qml +++ b/Modules/BluetoothPanel/BluetoothPanel.qml @@ -68,259 +68,56 @@ NPanel { } ScrollView { - id: scrollView - Layout.fillWidth: true Layout.fillHeight: true - clip: true ScrollBar.horizontal.policy: ScrollBar.AlwaysOff ScrollBar.vertical.policy: ScrollBar.AsNeeded + clip: true + contentWidth: availableWidth - // Available devices - Column { - id: column - + ColumnLayout { + visible: BluetoothService.adapter && BluetoothService.adapter.enabled width: parent.width spacing: Style.marginM * scaling - visible: BluetoothService.adapter && BluetoothService.adapter.enabled - RowLayout { - width: parent.width - spacing: Style.marginM * scaling - - NText { - text: "Available Devices" - font.pointSize: Style.fontSizeL * scaling - color: Color.mOnSurface - font.weight: Style.fontWeightMedium - } - } - - Repeater { + // Connected devices + BluetoothDevicesList { + label: "Connected devices" model: { - if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices) + if (!BluetoothService.adapter || !Bluetooth.devices) return [] var filtered = Bluetooth.devices.values.filter(dev => { - return dev && !dev.paired && !dev.pairing && !dev.blocked - && (dev.signalStrength === undefined - || dev.signalStrength > 0) + return dev && !dev.blocked && (dev.paired || dev.trusted) }) return BluetoothService.sortDevices(filtered) } - - Rectangle { - property bool canConnect: BluetoothService.canConnect(modelData) - property bool isBusy: BluetoothService.isDeviceBusy(modelData) - - width: parent.width - height: 70 - radius: Style.radiusM * scaling - color: { - if (availableDeviceArea.containsMouse && !isBusy) - return Color.mTertiary - - if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) - return Color.mPrimary - - if (modelData.blocked) - return Color.mError - - return Color.mSurfaceVariant - } - border.color: Color.mOutline - border.width: Math.max(1, Style.borderS * scaling) - - Row { - anchors.left: parent.left - anchors.leftMargin: Style.marginM * scaling - anchors.verticalCenter: parent.verticalCenter - spacing: Style.marginS * scaling - - // One device BT icon - NIcon { - text: BluetoothService.getDeviceIcon(modelData) - font.pointSize: Style.fontSizeXXL * scaling - color: { - if (availableDeviceArea.containsMouse) - return Color.mOnTertiary - - if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) - return Color.mOnPrimary - - if (modelData.blocked) - return Color.mOnError - - return Color.mOnSurface - } - anchors.verticalCenter: parent.verticalCenter - } - - Column { - spacing: Style.marginXXS * scaling - anchors.verticalCenter: parent.verticalCenter - - // One device name - NText { - text: modelData.name || modelData.deviceName - font.pointSize: Style.fonttSizeMedium * scaling - elide: Text.ElideRight - color: { - if (availableDeviceArea.containsMouse) - return Color.mOnTertiary - - if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) - return Color.mOnPrimary - - if (modelData.blocked) - return Color.mOnError - - return Color.mOnSurface - } - font.weight: Style.fontWeightMedium - } - - Row { - spacing: Style.marginXS * scaling - - Row { - spacing: Style.marginS * spacing - - // One device signal strength - "Unknown" when not connected - NText { - text: { - if (modelData.pairing) - return "Pairing..." - - if (modelData.blocked) - return "Blocked" - - return BluetoothService.getSignalStrength(modelData) - } - font.pointSize: Style.fontSizeXS * scaling - color: { - if (availableDeviceArea.containsMouse) - return Color.mOnTertiary - - if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) - return Color.mOnPrimary - - if (modelData.blocked) - return Color.mOnError - - return Color.mOnSurface - } - } - - NIcon { - text: BluetoothService.getSignalIcon(modelData) - font.pointSize: Style.fontSizeXS * scaling - color: { - if (availableDeviceArea.containsMouse) - return Color.mOnTertiary - - if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) - return Color.mOnPrimary - - if (modelData.blocked) - return Color.mOnError - - return Color.mOnSurface - } - visible: modelData.signalStrength !== undefined && modelData.signalStrength > 0 - && !modelData.pairing && !modelData.blocked - } - - NText { - text: (modelData.signalStrength !== undefined - && modelData.signalStrength > 0) ? modelData.signalStrength + "%" : "" - font.pointSize: Style.fontSizeXS * scaling - color: { - if (availableDeviceArea.containsMouse) - return Color.mOnTertiary - - if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) - return Color.mOnPrimary - - if (modelData.blocked) - return Color.mOnError - - return Color.mOnSurface - } - visible: modelData.signalStrength !== undefined && modelData.signalStrength > 0 - && !modelData.pairing && !modelData.blocked - } - } - } - } - } - - Rectangle { - width: 80 * scaling - height: 28 * scaling - radius: Style.radiusM * scaling - anchors.right: parent.right - anchors.rightMargin: Style.marginM * scaling - anchors.verticalCenter: parent.verticalCenter - visible: modelData.state !== BluetoothDeviceState.Connecting - color: Color.transparent - - border.color: { - if (availableDeviceArea.containsMouse) { - return Color.mOnTertiary - } else { - return Color.mPrimary - } - } - border.width: Math.max(1, Style.borderS * scaling) - opacity: canConnect || isBusy ? 1 : 0.5 - - // On device connect button - NText { - anchors.centerIn: parent - text: { - if (modelData.pairing) - return "Pairing..." - - if (modelData.blocked) - return "Blocked" - - return "Connect" - } - font.pointSize: Style.fontSizeXS * scaling - font.weight: Style.fontWeightMedium - color: { - if (availableDeviceArea.containsMouse) { - return Color.mOnTertiary - } else { - return Color.mPrimary - } - } - } - } - - MouseArea { - id: availableDeviceArea - - anchors.fill: parent - hoverEnabled: true - cursorShape: canConnect && !isBusy ? Qt.PointingHandCursor : (isBusy ? Qt.BusyCursor : Qt.ArrowCursor) - enabled: canConnect && !isBusy - onClicked: { - if (modelData) - BluetoothService.connectDeviceWithTrust(modelData) - } - } - } + Layout.fillWidth: true } - // Fallback if nothing available - Column { - width: parent.width + // Available devices + BluetoothDevicesList { + label: "Available devices" + model: { + if (!BluetoothService.adapter || !Bluetooth.devices) + return [] + + var filtered = Bluetooth.devices.values.filter(dev => { + return dev && !dev.blocked && !dev.paired && !dev.trusted + }) + return BluetoothService.sortDevices(filtered) + } + Layout.fillWidth: true + } + + // Fallback + ColumnLayout { + Layout.fillWidth: true spacing: Style.marginM * scaling visible: { - if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices) + if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices) { return false + } var availableCount = Bluetooth.devices.values.filter(dev => { return dev && !dev.paired && !dev.pairing @@ -328,18 +125,17 @@ NPanel { && (dev.signalStrength === undefined || dev.signalStrength > 0) }).length - return availableCount === 0 + return (availableCount === 0) } - Row { - anchors.horizontalCenter: parent.horizontalCenter + RowLayout { + Layout.alignment: Qt.AlignHCenter spacing: Style.marginM * scaling NIcon { text: "sync" font.pointSize: Style.fontSizeXLL * 1.5 * scaling color: Color.mPrimary - anchors.verticalCenter: parent.verticalCenter RotationAnimation on rotation { running: true @@ -355,7 +151,6 @@ NPanel { font.pointSize: Style.fontSizeL * scaling color: Color.mOnSurface font.weight: Style.fontWeightMedium - anchors.verticalCenter: parent.verticalCenter } } @@ -363,36 +158,15 @@ NPanel { text: "Make sure your device is in pairing mode" font.pointSize: Style.fontSizeM * scaling color: Color.mOnSurfaceVariant - anchors.horizontalCenter: parent.horizontalCenter + Layout.alignment: Qt.AlignHCenter } } - NText { - text: "No devices found. Put your device in pairing mode and click Start Scanning." - font.pointSize: Style.fontSizeM * scaling - color: Color.mOnSurfaceVariant - visible: { - if (!BluetoothService.adapter || !Bluetooth.devices) - return true - - var availableCount = Bluetooth.devices.values.filter(dev => { - return dev && !dev.paired && !dev.pairing - && !dev.blocked - && (dev.signalStrength === undefined - || dev.signalStrength > 0) - }).length - return availableCount === 0 && !BluetoothService.adapter.discovering - } - wrapMode: Text.WordWrap - width: parent.width - horizontalAlignment: Text.AlignHCenter + Item { + Layout.fillHeight: true } } } - // This item takes up all the remaining vertical space. - Item { - Layout.fillHeight: true - } } } } diff --git a/Services/BluetoothService.qml b/Services/BluetoothService.qml index aac2b53..368534c 100644 --- a/Services/BluetoothService.qml +++ b/Services/BluetoothService.qml @@ -13,17 +13,17 @@ Singleton { readonly property bool discovering: (adapter && adapter.discovering) ?? false readonly property var devices: adapter ? adapter.devices : null readonly property var pairedDevices: { - if (!adapter || !adapter.devices) - return [] - + if (!adapter || !adapter.devices) { + return [] + } return adapter.devices.values.filter(dev => { return dev && (dev.paired || dev.trusted) }) } readonly property var allDevicesWithBattery: { - if (!adapter || !adapter.devices) - return [] - + if (!adapter || !adapter.devices) { + return [] + } return adapter.devices.values.filter(dev => { return dev && dev.batteryAvailable && dev.battery > 0 }) @@ -49,34 +49,36 @@ Singleton { } function getDeviceIcon(device) { - if (!device) + if (!device) { return "bluetooth" + } var name = (device.name || device.deviceName || "").toLowerCase() var icon = (device.icon || "").toLowerCase() if (icon.includes("headset") || icon.includes("audio") || name.includes("headphone") || name.includes("airpod") - || name.includes("headset") || name.includes("arctis")) + || name.includes("headset") || name.includes("arctis")) { return "headset" + } - if (icon.includes("mouse") || name.includes("mouse")) + if (icon.includes("mouse") || name.includes("mouse")) { return "mouse" - - if (icon.includes("keyboard") || name.includes("keyboard")) + } + if (icon.includes("keyboard") || name.includes("keyboard")) { return "keyboard" - + } if (icon.includes("phone") || name.includes("phone") || name.includes("iphone") || name.includes("android") - || name.includes("samsung")) + || name.includes("samsung")) { return "smartphone" - - if (icon.includes("watch") || name.includes("watch")) + } + if (icon.includes("watch") || name.includes("watch")) { return "watch" - - if (icon.includes("speaker") || name.includes("speaker")) + } + if (icon.includes("speaker") || name.includes("speaker")) { return "speaker" - - if (icon.includes("display") || name.includes("tv")) + } + if (icon.includes("display") || name.includes("tv")) { return "tv" - + } return "bluetooth" } @@ -88,60 +90,91 @@ Singleton { } function getSignalStrength(device) { - if (!device || device.signalStrength === undefined || device.signalStrength <= 0) - return "Unknown" - + if (device.pairing) { + return "Pairing..." + } + if (device.blocked) { + return "Blocked" + } + if (!device || device.signalStrength === undefined || device.signalStrength <= 0) { + return "Signal: Unknown" + } var signal = device.signalStrength - if (signal >= 80) - return "Excellent" + if (signal >= 80) { + return "Signal: Excellent" + } + if (signal >= 60) { + return "Signal: Good" + } + if (signal >= 40) { + return "Signal: Fair" + } + if (signal >= 20) { + return "Signal: Poor" + } + return "Signal: Very Poor" + } - if (signal >= 60) - return "Good" - - if (signal >= 40) - return "Fair" - - if (signal >= 20) - return "Poor" - - return "Very Poor" + function getBattery(device) { + return `Battery: ${Math.round(device.battery * 100)}` } function getSignalIcon(device) { - if (!device || device.signalStrength === undefined || device.signalStrength <= 0) + if (!device || device.signalStrength === undefined || device.signalStrength <= 0) { return "signal_cellular_null" - + } var signal = device.signalStrength - if (signal >= 80) + if (signal >= 80) { return "signal_cellular_4_bar" - - if (signal >= 60) + } + if (signal >= 60) { return "signal_cellular_3_bar" - - if (signal >= 40) + } + if (signal >= 40) { return "signal_cellular_2_bar" - - if (signal >= 20) + } + if (signal >= 20) { return "signal_cellular_1_bar" - + } return "signal_cellular_0_bar" } function isDeviceBusy(device) { - if (!device) + if (!device) { return false + } + return device.pairing || device.state === BluetoothDeviceState.Disconnecting || device.state === BluetoothDeviceState.Connecting } function connectDeviceWithTrust(device) { - if (!device) + if (!device) { return + } device.trusted = true device.connect() } + function disconnectDevice(device) { + if (!device) { + return + } + + device.trusted = false + device.disconnect() + } + + function forgetDevice(device) { + if (!device) { + return + } + + device.trusted = false + device.forget() + } + function setBluetoothEnabled(enabled) { if (!adapter) { Logger.warn("Bluetooth", "No adapter available")