From 3462bcc46ecf74f576ea4397f16492c16bd75b10 Mon Sep 17 00:00:00 2001 From: Nippy Date: Fri, 8 May 2026 19:50:49 +0200 Subject: raveos update --- .../ControlCenter/Details/BluetoothDetail.qml | 743 +++++++++++++++++++++ 1 file changed, 743 insertions(+) create mode 100644 raveos-hyprland-theme/theme-data/dms/Modules/ControlCenter/Details/BluetoothDetail.qml (limited to 'raveos-hyprland-theme/theme-data/dms/Modules/ControlCenter/Details/BluetoothDetail.qml') diff --git a/raveos-hyprland-theme/theme-data/dms/Modules/ControlCenter/Details/BluetoothDetail.qml b/raveos-hyprland-theme/theme-data/dms/Modules/ControlCenter/Details/BluetoothDetail.qml new file mode 100644 index 0000000..580ea9f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/dms/Modules/ControlCenter/Details/BluetoothDetail.qml @@ -0,0 +1,743 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Bluetooth +import qs.Common +import qs.Services +import qs.Widgets + +Rectangle { + id: root + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + implicitHeight: { + if (height > 0) + return height; + if (!BluetoothService.adapter?.enabled) + return headerRow.height; + return headerRow.height + bluetoothContent.height + Theme.spacingM; + } + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08) + border.width: 0 + + property var bluetoothCodecModalRef: null + property var devicesBeingPaired: new Set() + + signal showCodecSelector(var device) + + function isDeviceBeingPaired(deviceAddress) { + return devicesBeingPaired.has(deviceAddress); + } + + function handlePairDevice(device) { + if (!device) + return; + const deviceAddr = device.address; + const pairingSet = devicesBeingPaired; + + pairingSet.add(deviceAddr); + devicesBeingPairedChanged(); + + BluetoothService.pairDevice(device, function (response) { + pairingSet.delete(deviceAddr); + devicesBeingPairedChanged(); + + if (response.error) { + ToastService.showError(I18n.tr("Pairing failed"), response.error); + return; + } + if (!BluetoothService.enhancedPairingAvailable) { + ToastService.showSuccess(I18n.tr("Device paired")); + } + }); + } + + function updateDeviceCodecDisplay(deviceAddress, codecName) { + for (let i = 0; i < pairedRepeater.count; i++) { + const item = pairedRepeater.itemAt(i); + if (!item?.modelData) + continue; + if (item.modelData.address !== deviceAddress) + continue; + item.currentCodec = codecName; + break; + } + } + + function normalizePinList(value) { + if (Array.isArray(value)) + return value.filter(v => v); + if (typeof value === "string" && value.length > 0) + return [value]; + return []; + } + + function getPinnedDevices() { + const pins = SettingsData.bluetoothDevicePins || {}; + return normalizePinList(pins["preferredDevice"]); + } + + Row { + id: headerRow + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + anchors.topMargin: Theme.spacingS + height: 40 + + StyledText { + id: headerText + text: I18n.tr("Bluetooth Settings") + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + + Item { + width: Math.max(0, parent.width - headerText.implicitWidth - scanButton.width - Theme.spacingM) + height: parent.height + } + + Rectangle { + id: scanButton + + readonly property bool adapterEnabled: BluetoothService.adapter?.enabled ?? false + readonly property bool isDiscovering: BluetoothService.adapter?.discovering ?? false + + width: 100 + height: 36 + radius: 18 + color: scanMouseArea.containsMouse && adapterEnabled ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : "transparent" + border.color: adapterEnabled ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) + border.width: 0 + visible: adapterEnabled + + Row { + anchors.centerIn: parent + spacing: Theme.spacingXS + + DankIcon { + name: scanButton.isDiscovering ? "stop" : "bluetooth_searching" + size: 18 + color: scanButton.adapterEnabled ? Theme.primary : Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: scanButton.isDiscovering ? I18n.tr("Scanning") : I18n.tr("Scan") + color: scanButton.adapterEnabled ? Theme.primary : Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + } + + DankRipple { + id: scanRipple + cornerRadius: scanButton.radius + } + + MouseArea { + id: scanMouseArea + anchors.fill: parent + hoverEnabled: true + enabled: scanButton.adapterEnabled + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onPressed: mouse => scanRipple.trigger(mouse.x, mouse.y) + onClicked: { + if (!BluetoothService.adapter) + return; + BluetoothService.adapter.discovering = !BluetoothService.adapter.discovering; + } + } + } + } + + DankFlickable { + id: bluetoothContent + anchors.top: headerRow.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Theme.spacingM + anchors.topMargin: Theme.spacingM + visible: BluetoothService.adapter?.enabled ?? false + contentHeight: bluetoothColumn.height + clip: true + + readonly property int maxPinnedDevices: 3 + + Column { + id: bluetoothColumn + width: parent.width + spacing: Theme.spacingS + + ScriptModel { + id: pairedDevicesModel + objectProp: "address" + values: { + if (!BluetoothService.adapter?.devices) + return []; + + const pinnedList = root.getPinnedDevices(); + const devices = [...BluetoothService.adapter.devices.values.filter(dev => dev && (dev.paired || dev.trusted))]; + + devices.sort((a, b) => { + const aPinnedIndex = pinnedList.indexOf(a.address); + const bPinnedIndex = pinnedList.indexOf(b.address); + + if (aPinnedIndex !== -1 || bPinnedIndex !== -1) { + if (aPinnedIndex === -1) + return 1; + if (bPinnedIndex === -1) + return -1; + return aPinnedIndex - bPinnedIndex; + } + + if (a.connected !== b.connected) + return a.connected ? -1 : 1; + + return (b.signalStrength || 0) - (a.signalStrength || 0); + }); + + return devices; + } + } + + Repeater { + id: pairedRepeater + model: pairedDevicesModel + + delegate: Rectangle { + id: pairedDelegate + required property var modelData + required property int index + + readonly property string currentCodec: BluetoothService.deviceCodecs[modelData.address] || "" + readonly property bool isConnecting: modelData.state === BluetoothDeviceState.Connecting + readonly property bool isConnected: modelData.connected + readonly property bool isPinned: root.getPinnedDevices().includes(modelData.address) + readonly property string deviceName: modelData.name || modelData.deviceName || I18n.tr("Unknown Device") + + width: parent.width + height: 50 + radius: Theme.cornerRadius + + Component.onCompleted: { + if (!isConnected) + return; + if (!BluetoothService.isAudioDevice(modelData)) + return; + BluetoothService.refreshDeviceCodec(modelData); + } + + color: { + if (isConnecting) + return Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.12); + if (deviceMouseArea.containsMouse) + return Theme.primaryHoverLight; + return Theme.surfaceLight; + } + + border.color: { + if (isConnecting) + return Theme.warning; + if (isConnected) + return Theme.primary; + return Theme.outlineLight; + } + border.width: (isConnecting || isConnected) ? 2 : 1 + + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.spacingM + spacing: Theme.spacingS + + DankIcon { + name: BluetoothService.getDeviceIcon(pairedDelegate.modelData) + size: Theme.iconSize - 4 + anchors.verticalCenter: parent.verticalCenter + color: { + if (pairedDelegate.isConnecting) + return Theme.warning; + if (pairedDelegate.isConnected) + return Theme.primary; + return Theme.surfaceText; + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: 200 + + StyledText { + text: pairedDelegate.deviceName + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + font.weight: pairedDelegate.isConnected ? Font.Medium : Font.Normal + elide: Text.ElideRight + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + Row { + spacing: Theme.spacingXS + + StyledText { + text: { + if (pairedDelegate.isConnecting) + return I18n.tr("Connecting..."); + if (!pairedDelegate.isConnected) + return I18n.tr("Paired"); + if (!pairedDelegate.currentCodec) + return I18n.tr("Connected"); + return I18n.tr("Connected") + " • " + pairedDelegate.currentCodec; + } + font.pixelSize: Theme.fontSizeSmall + color: pairedDelegate.isConnecting ? Theme.warning : Theme.surfaceVariantText + } + + StyledText { + readonly property var btBattery: { + const name = pairedDelegate.deviceName; + return BatteryService.bluetoothDevices.find(dev => dev.name === name || dev.name.toLowerCase().includes(name.toLowerCase()) || name.toLowerCase().includes(dev.name.toLowerCase())); + } + text: { + if (pairedDelegate.modelData.batteryAvailable && pairedDelegate.modelData.battery > 0) + return "• " + Math.round(pairedDelegate.modelData.battery * 100) + "%"; + if (btBattery) + return "• " + btBattery.percentage + "%"; + return ""; + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + visible: text.length > 0 + } + + StyledText { + text: pairedDelegate.modelData.signalStrength > 0 ? "• " + pairedDelegate.modelData.signalStrength + "%" : "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + visible: text.length > 0 + } + } + } + } + + Rectangle { + anchors.right: pairedOptionsButton.left + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + width: pinBluetoothRow.width + Theme.spacingS * 2 + height: 28 + radius: height / 2 + color: pairedDelegate.isPinned ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Theme.withAlpha(Theme.surfaceText, 0.05) + + Row { + id: pinBluetoothRow + anchors.centerIn: parent + spacing: 4 + + DankIcon { + name: "push_pin" + size: 16 + color: pairedDelegate.isPinned ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: pairedDelegate.isPinned ? I18n.tr("Pinned") : I18n.tr("Pin") + font.pixelSize: Theme.fontSizeSmall + color: pairedDelegate.isPinned ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + const pins = JSON.parse(JSON.stringify(SettingsData.bluetoothDevicePins || {})); + let pinnedList = root.normalizePinList(pins["preferredDevice"]); + const pinIndex = pinnedList.indexOf(pairedDelegate.modelData.address); + + if (pinIndex !== -1) { + pinnedList.splice(pinIndex, 1); + } else { + pinnedList.unshift(pairedDelegate.modelData.address); + if (pinnedList.length > bluetoothContent.maxPinnedDevices) + pinnedList = pinnedList.slice(0, bluetoothContent.maxPinnedDevices); + } + + if (pinnedList.length > 0) { + pins["preferredDevice"] = pinnedList; + } else { + delete pins["preferredDevice"]; + } + + SettingsData.set("bluetoothDevicePins", pins); + } + } + } + + DankActionButton { + id: pairedOptionsButton + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + iconName: "more_horiz" + buttonSize: 28 + onClicked: { + if (bluetoothContextMenu.visible) { + bluetoothContextMenu.close(); + return; + } + bluetoothContextMenu.currentDevice = pairedDelegate.modelData; + bluetoothContextMenu.popup(pairedOptionsButton, -bluetoothContextMenu.width + pairedOptionsButton.width, pairedOptionsButton.height + Theme.spacingXS); + } + } + + DankRipple { + id: deviceRipple + cornerRadius: pairedDelegate.radius + } + + MouseArea { + id: deviceMouseArea + anchors.fill: parent + anchors.rightMargin: pairedOptionsButton.width + Theme.spacingM + pinBluetoothRow.width + Theme.spacingS * 4 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: mouse => { + const pos = mapToItem(pairedDelegate, mouse.x, mouse.y); + deviceRipple.trigger(pos.x, pos.y); + } + onClicked: { + if (pairedDelegate.isConnected) { + pairedDelegate.modelData.disconnect(); + return; + } + BluetoothService.connectDeviceWithTrust(pairedDelegate.modelData); + } + } + } + } + + Rectangle { + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) + visible: pairedRepeater.count > 0 && availableRepeater.count > 0 + } + + Item { + width: parent.width + height: 80 + visible: (BluetoothService.adapter?.discovering ?? false) && availableRepeater.count === 0 + + DankIcon { + anchors.centerIn: parent + name: "sync" + size: 24 + color: Qt.rgba(Theme.surfaceText.r || 0.8, Theme.surfaceText.g || 0.8, Theme.surfaceText.b || 0.8, 0.4) + + RotationAnimation on rotation { + running: parent.visible + loops: Animation.Infinite + from: 0 + to: 360 + duration: 1500 + } + } + } + + ScriptModel { + id: availableDevicesModel + objectProp: "address" + values: { + if (!BluetoothService.adapter?.discovering) + return []; + if (!Bluetooth.devices) + return []; + + const filtered = Bluetooth.devices.values.filter(dev => dev && !dev.paired && !dev.pairing && !dev.blocked && (dev.signalStrength === undefined || dev.signalStrength > 0)); + return BluetoothService.sortDevices(filtered); + } + } + + Repeater { + id: availableRepeater + model: availableDevicesModel + + delegate: Rectangle { + id: availableDelegate + required property var modelData + required property int index + + readonly property bool canConnect: BluetoothService.canConnect(modelData) + readonly property bool isBusy: BluetoothService.isDeviceBusy(modelData) || root.isDeviceBeingPaired(modelData.address) + readonly property bool isInteractive: canConnect && !isBusy + readonly property string deviceName: modelData.name || modelData.deviceName || I18n.tr("Unknown Device") + + width: parent.width + height: 50 + radius: Theme.cornerRadius + color: availableMouseArea.containsMouse && isInteractive ? Theme.primaryHoverLight : Theme.surfaceLight + border.color: Theme.outlineLight + border.width: 1 + opacity: isInteractive ? 1 : 0.6 + + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.spacingM + spacing: Theme.spacingS + + DankIcon { + name: BluetoothService.getDeviceIcon(availableDelegate.modelData) + size: Theme.iconSize - 4 + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: 200 + + StyledText { + text: availableDelegate.deviceName + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + elide: Text.ElideRight + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + Row { + spacing: Theme.spacingXS + + StyledText { + text: { + if (availableDelegate.modelData.pairing || availableDelegate.isBusy) + return I18n.tr("Pairing..."); + if (availableDelegate.modelData.blocked) + return I18n.tr("Blocked"); + return BluetoothService.getSignalStrength(availableDelegate.modelData); + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + StyledText { + text: availableDelegate.modelData.signalStrength > 0 ? "• " + availableDelegate.modelData.signalStrength + "%" : "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + visible: text.length > 0 && !availableDelegate.modelData.pairing && !availableDelegate.modelData.blocked + } + } + } + } + + StyledText { + anchors.right: parent.right + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + text: { + if (availableDelegate.isBusy) + return I18n.tr("Pairing..."); + if (!availableDelegate.canConnect) + return I18n.tr("Cannot pair"); + return I18n.tr("Pair"); + } + font.pixelSize: Theme.fontSizeSmall + color: availableDelegate.isInteractive ? Theme.primary : Theme.surfaceVariantText + font.weight: Font.Medium + } + + DankRipple { + id: availableRipple + cornerRadius: availableDelegate.radius + } + + MouseArea { + id: availableMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: availableDelegate.isInteractive ? Qt.PointingHandCursor : Qt.ArrowCursor + enabled: availableDelegate.isInteractive + onPressed: mouse => availableRipple.trigger(mouse.x, mouse.y) + onClicked: root.handlePairDevice(availableDelegate.modelData) + } + } + } + + Item { + width: parent.width + height: 60 + visible: !BluetoothService.adapter + + StyledText { + anchors.centerIn: parent + text: I18n.tr("No Bluetooth adapter found") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + } + } + } + } + + Menu { + id: bluetoothContextMenu + width: 150 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent + + property var currentDevice: null + + readonly property bool hasDevice: currentDevice !== null + readonly property bool deviceConnected: currentDevice?.connected ?? false + readonly property bool showCodecOption: hasDevice && deviceConnected && BluetoothService.isAudioDevice(currentDevice) + + background: Rectangle { + color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + radius: Theme.cornerRadius + border.width: 0 + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) + } + + MenuItem { + text: bluetoothContextMenu.deviceConnected ? I18n.tr("Disconnect") : I18n.tr("Connect") + height: 32 + + contentItem: StyledText { + text: parent.text + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + leftPadding: Theme.spacingS + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent" + radius: Theme.cornerRadius / 2 + } + + onTriggered: { + if (!bluetoothContextMenu.hasDevice) + return; + if (bluetoothContextMenu.deviceConnected) { + bluetoothContextMenu.currentDevice.disconnect(); + return; + } + BluetoothService.connectDeviceWithTrust(bluetoothContextMenu.currentDevice); + } + } + + MenuItem { + text: I18n.tr("Audio Codec") + height: bluetoothContextMenu.showCodecOption ? 32 : 0 + visible: bluetoothContextMenu.showCodecOption + + contentItem: StyledText { + text: parent.text + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + leftPadding: Theme.spacingS + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent" + radius: Theme.cornerRadius / 2 + } + + onTriggered: { + if (!bluetoothContextMenu.hasDevice) + return; + if (!bluetoothContextMenu.currentDevice.connected) + return; + if (!BluetoothService.isAudioDevice(bluetoothContextMenu.currentDevice)) + return; + showCodecSelector(bluetoothContextMenu.currentDevice); + } + } + + MenuItem { + text: bluetoothContextMenu.currentDevice?.trusted ? I18n.tr("Untrust") : I18n.tr("Trust") + height: 32 + + contentItem: StyledText { + text: parent.text + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + leftPadding: Theme.spacingS + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent" + radius: Theme.cornerRadius / 2 + } + + onTriggered: { + if (!bluetoothContextMenu.hasDevice) + return; + bluetoothContextMenu.currentDevice.trusted = !bluetoothContextMenu.currentDevice.trusted; + } + } + + MenuItem { + text: I18n.tr("Forget Device") + height: 32 + + contentItem: StyledText { + text: parent.text + font.pixelSize: Theme.fontSizeSmall + color: Theme.error + leftPadding: Theme.spacingS + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + color: parent.hovered ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.08) : "transparent" + radius: Theme.cornerRadius / 2 + } + + onTriggered: { + if (!bluetoothContextMenu.hasDevice) + return; + if (!BluetoothService.enhancedPairingAvailable) { + bluetoothContextMenu.currentDevice.forget(); + return; + } + + const devicePath = BluetoothService.getDevicePath(bluetoothContextMenu.currentDevice); + DMSService.bluetoothRemove(devicePath, response => { + if (!response.error) + return; + ToastService.showError(I18n.tr("Failed to remove device"), response.error); + }); + } + } + } + + Connections { + target: DMSService + + function onBluetoothPairingRequest(data) { + const modal = PopoutService.bluetoothPairingModal; + if (!modal) + return; + if (modal.token === data.token) + return; + modal.show(data); + } + } +} -- cgit v1.3