mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-21 18:54:01 -04:00
386 lines
15 KiB
QML
386 lines
15 KiB
QML
pragma ComponentBehavior: Bound
|
|
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Bluetooth
|
|
import qs.Common
|
|
import qs.Modules.ControlCenter
|
|
import qs.Modules.ControlCenter.Widgets
|
|
import qs.Services
|
|
import qs.DCommon.Widgets
|
|
import qs.Widgets
|
|
import "../../../Common/QmlUtils.js" as QmlUtils
|
|
|
|
Item {
|
|
id: root
|
|
|
|
Ref {
|
|
service: BatteryService
|
|
}
|
|
|
|
implicitHeight: column.height
|
|
|
|
LayoutMirroring.enabled: I18n.isRtl
|
|
LayoutMirroring.childrenInherit: true
|
|
|
|
property var transientSurfaceTracker: null
|
|
|
|
readonly property string title: I18n.tr("Bluetooth")
|
|
readonly property var adapter: BluetoothService.adapter
|
|
readonly property bool adapterEnabled: adapter?.enabled ?? false
|
|
readonly property bool discovering: adapter?.discovering ?? false
|
|
readonly property var pinnedDevices: QmlUtils.normalizePinList((CacheData.bluetoothDevicePins || {})["preferredDevice"])
|
|
property var devicesBeingPaired: new Set()
|
|
|
|
readonly property Item headerActions: Row {
|
|
spacing: Theme.spacingS
|
|
|
|
DDropdown {
|
|
id: adapterDropdown
|
|
|
|
function adapterLabel(adapter) {
|
|
const name = adapter.name || adapter.adapterId;
|
|
const collides = BluetoothService.adapters.some(a => a !== adapter && (a.name || a.adapterId) === name);
|
|
return collides ? `${name} (${adapter.adapterId})` : name;
|
|
}
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: BluetoothService.adapters.length > 1
|
|
compactMode: true
|
|
dropdownWidth: CcMetrics.headerDropdownWidth
|
|
popupWidth: CcMetrics.headerPopupWidth
|
|
alignPopupRight: true
|
|
options: BluetoothService.adapters.map(a => adapterLabel(a))
|
|
currentValue: BluetoothService.adapter ? adapterLabel(BluetoothService.adapter) : ""
|
|
onValueChanged: value => {
|
|
const selected = BluetoothService.adapters.find(a => adapterDropdown.adapterLabel(a) === value);
|
|
SessionData.set("bluetoothAdapterOverride", selected?.dbusPath ?? "");
|
|
}
|
|
}
|
|
|
|
DButton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: root.adapterEnabled
|
|
buttonHeight: Theme.buttonHeightXS
|
|
iconName: root.discovering ? "stop" : "bluetooth_searching"
|
|
iconSize: Theme.iconSizeSmall
|
|
text: root.discovering ? I18n.tr("Scanning...") : I18n.tr("Scan")
|
|
backgroundColor: Theme.secondaryContainer
|
|
textColor: Theme.onSecondaryContainer
|
|
onClicked: {
|
|
if (!root.adapter)
|
|
return;
|
|
root.adapter.discovering = !root.adapter.discovering;
|
|
}
|
|
}
|
|
}
|
|
|
|
signal showCodecSelector(var device)
|
|
|
|
function dismissTransient() {
|
|
if (!deviceMenu.open)
|
|
return false;
|
|
deviceMenu.close();
|
|
return true;
|
|
}
|
|
|
|
readonly property bool controlCenterPopoutVisible: PopoutService.controlCenterPopout?.shouldBeVisible ?? false
|
|
|
|
onControlCenterPopoutVisibleChanged: {
|
|
if (!controlCenterPopoutVisible)
|
|
deviceMenu.close();
|
|
}
|
|
|
|
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, response => {
|
|
pairingSet.delete(deviceAddr);
|
|
devicesBeingPairedChanged();
|
|
if (response.error) {
|
|
ToastService.showError(I18n.tr("Pairing failed"), response.error);
|
|
return;
|
|
}
|
|
if (!BluetoothService.enhancedPairingAvailable)
|
|
ToastService.showInfo(I18n.tr("Device paired"));
|
|
});
|
|
}
|
|
|
|
function togglePin(address) {
|
|
CacheData.set("bluetoothDevicePins", QmlUtils.togglePinEntry(CacheData.bluetoothDevicePins, "preferredDevice", address, CcMetrics.maxPins));
|
|
}
|
|
|
|
function forgetDevice(device) {
|
|
if (!BluetoothService.enhancedPairingAvailable) {
|
|
device.forget();
|
|
return;
|
|
}
|
|
DMSService.bluetoothRemove(BluetoothService.getDevicePath(device), response => {
|
|
if (!response.error)
|
|
return;
|
|
ToastService.showError(I18n.tr("Failed to remove device"), response.error);
|
|
});
|
|
}
|
|
|
|
function openDeviceMenu(device, anchor) {
|
|
const connected = device.connected;
|
|
deviceMenu.items = [
|
|
{
|
|
"label": connected ? I18n.tr("Disconnect") : I18n.tr("Connect"),
|
|
"iconName": connected ? "bluetooth_disabled" : "bluetooth_connected",
|
|
"action": () => {
|
|
if (connected) {
|
|
device.disconnect();
|
|
return;
|
|
}
|
|
BluetoothService.connectDeviceWithTrust(device);
|
|
}
|
|
},
|
|
{
|
|
"label": I18n.tr("Audio Codec"),
|
|
"iconName": "graphic_eq",
|
|
"visible": connected && BluetoothService.isAudioDevice(device),
|
|
"action": () => root.showCodecSelector(device)
|
|
},
|
|
{
|
|
"label": device.trusted ? I18n.tr("Untrust", "verb, bluetooth device menu action removing trust") : I18n.tr("Trust"),
|
|
"iconName": device.trusted ? "shield_with_heart" : "verified_user",
|
|
"action": () => device.trusted = !device.trusted
|
|
},
|
|
{
|
|
"label": I18n.tr("Forget Device"),
|
|
"iconName": "delete",
|
|
"destructive": true,
|
|
"action": () => root.forgetDevice(device)
|
|
}
|
|
];
|
|
deviceMenu.openAt(anchor);
|
|
}
|
|
|
|
ScriptModel {
|
|
id: pairedDevicesModel
|
|
objectProp: "address"
|
|
values: {
|
|
if (!root.adapter?.devices)
|
|
return [];
|
|
const pinnedList = root.pinnedDevices;
|
|
const devices = [...root.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;
|
|
}
|
|
}
|
|
|
|
ScriptModel {
|
|
id: availableDevicesModel
|
|
objectProp: "address"
|
|
values: {
|
|
if (!root.discovering || !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);
|
|
}
|
|
}
|
|
|
|
DFlickable {
|
|
anchors.fill: parent
|
|
contentHeight: column.height
|
|
clip: true
|
|
|
|
Column {
|
|
id: column
|
|
width: parent.width
|
|
spacing: CcMetrics.detailContentGap
|
|
|
|
CcGroup {
|
|
CcToggleRow {
|
|
iconName: "bluetooth"
|
|
iconColor: root.adapterEnabled ? Theme.primary : Theme.surfaceText
|
|
text: I18n.tr("Bluetooth")
|
|
description: root.adapter ? (root.adapter.name || root.adapter.adapterId) : I18n.tr("No Bluetooth adapter found")
|
|
checked: root.adapterEnabled
|
|
enabled: !!root.adapter
|
|
onToggled: BluetoothService.toggleBluetooth()
|
|
}
|
|
}
|
|
|
|
CcSectionLabel {
|
|
text: I18n.tr("Paired", "adjective, bluetooth paired devices section label and device status")
|
|
visible: pairedGroup.visible
|
|
}
|
|
|
|
CcGroup {
|
|
id: pairedGroup
|
|
visible: root.adapterEnabled && pairedRepeater.count > 0
|
|
|
|
Repeater {
|
|
id: pairedRepeater
|
|
model: pairedDevicesModel
|
|
|
|
CcListRow {
|
|
id: pairedRow
|
|
|
|
required property var modelData
|
|
|
|
readonly property string currentCodec: BluetoothService.deviceCodecs[modelData.address] || ""
|
|
readonly property bool isConnecting: modelData.state === BluetoothDeviceState.Connecting
|
|
readonly property bool isConnected: modelData.connected
|
|
readonly property string deviceName: modelData.name || modelData.deviceName || I18n.tr("Unknown Device")
|
|
readonly property string batteryText: {
|
|
if (modelData.batteryAvailable)
|
|
return Math.round(modelData.battery * 100) + "%";
|
|
const lowered = deviceName.toLowerCase();
|
|
const btBattery = BatteryService.bluetoothDevices.find(dev => dev.name === deviceName || dev.name.toLowerCase().includes(lowered) || lowered.includes(dev.name.toLowerCase()));
|
|
return btBattery ? btBattery.percentage + "%" : "";
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
if (!isConnected || !BluetoothService.isAudioDevice(modelData))
|
|
return;
|
|
BluetoothService.refreshDeviceCodec(modelData);
|
|
}
|
|
|
|
iconName: BluetoothService.getDeviceIcon(modelData)
|
|
iconColor: {
|
|
if (isConnecting)
|
|
return Theme.warning;
|
|
return contentColor;
|
|
}
|
|
active: isConnected
|
|
title: deviceName
|
|
subtitle: {
|
|
if (isConnecting)
|
|
return I18n.tr("Connecting...");
|
|
const parts = [isConnected ? I18n.tr("Connected") : I18n.tr("Paired")];
|
|
if (isConnected && currentCodec)
|
|
parts.push(currentCodec);
|
|
if (batteryText)
|
|
parts.push(batteryText);
|
|
if (modelData.signalStrength > 0)
|
|
parts.push(modelData.signalStrength + "%");
|
|
return parts.join(" • ");
|
|
}
|
|
subtitleColor: isConnecting ? Theme.warning : Theme.surfaceVariantText
|
|
clickable: true
|
|
onClicked: {
|
|
if (isConnected) {
|
|
modelData.disconnect();
|
|
return;
|
|
}
|
|
BluetoothService.connectDeviceWithTrust(modelData);
|
|
}
|
|
|
|
CcPinChip {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
pinned: root.pinnedDevices.includes(pairedRow.modelData.address)
|
|
onToggled: root.togglePin(pairedRow.modelData.address)
|
|
}
|
|
|
|
DActionButton {
|
|
id: optionsButton
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
buttonSize: Theme.buttonHeightXS
|
|
iconSize: Theme.iconSizeMedium
|
|
iconName: "more_horiz"
|
|
Accessible.name: I18n.tr("Options")
|
|
iconColor: Theme.surfaceText
|
|
onClicked: root.openDeviceMenu(pairedRow.modelData, optionsButton)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
CcEmptyState {
|
|
visible: root.adapterEnabled && root.discovering && availableRepeater.count === 0
|
|
spinning: true
|
|
title: I18n.tr("Scanning...")
|
|
}
|
|
|
|
CcSectionLabel {
|
|
text: I18n.tr("Available")
|
|
visible: availableGroup.visible
|
|
}
|
|
|
|
CcGroup {
|
|
id: availableGroup
|
|
visible: root.adapterEnabled && availableRepeater.count > 0
|
|
|
|
Repeater {
|
|
id: availableRepeater
|
|
model: availableDevicesModel
|
|
|
|
CcListRow {
|
|
required property var modelData
|
|
|
|
readonly property bool canConnect: BluetoothService.canConnect(modelData)
|
|
readonly property bool isBusy: BluetoothService.isDeviceBusy(modelData) || root.isDeviceBeingPaired(modelData.address)
|
|
readonly property bool isInteractive: canConnect && !isBusy
|
|
|
|
iconName: BluetoothService.getDeviceIcon(modelData)
|
|
title: modelData.name || modelData.deviceName || I18n.tr("Unknown Device")
|
|
subtitle: {
|
|
if (modelData.pairing || isBusy)
|
|
return I18n.tr("Pairing...", "bluetooth device status while pairing is in progress");
|
|
if (modelData.blocked)
|
|
return I18n.tr("Blocked", "adjective, bluetooth device status");
|
|
const signal = BluetoothService.getSignalStrength(modelData);
|
|
return modelData.signalStrength > 0 ? signal + " • " + modelData.signalStrength + "%" : signal;
|
|
}
|
|
trailingBadge: {
|
|
if (isBusy)
|
|
return I18n.tr("Pairing...");
|
|
return canConnect ? I18n.tr("Pair") : I18n.tr("Cannot pair");
|
|
}
|
|
trailingBadgeColor: isInteractive ? Theme.primary : Theme.surfaceVariantText
|
|
enabled: isInteractive
|
|
clickable: true
|
|
onClicked: root.handlePairDevice(modelData)
|
|
}
|
|
}
|
|
}
|
|
|
|
CcEmptyState {
|
|
visible: !root.adapter
|
|
iconName: "bluetooth_disabled"
|
|
title: I18n.tr("No Bluetooth adapter found")
|
|
}
|
|
}
|
|
}
|
|
|
|
CcMenu {
|
|
id: deviceMenu
|
|
transientSurfaceTracker: root.transientSurfaceTracker
|
|
}
|
|
|
|
Connections {
|
|
target: DMSService
|
|
|
|
function onBluetoothPairingRequest(data) {
|
|
const modal = PopoutService.ensureBluetoothPairingModal();
|
|
if (!modal || modal.token === data.token)
|
|
return;
|
|
modal.show(data);
|
|
}
|
|
}
|
|
}
|