1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-07 22:15:38 -05:00

Add bluetooth codec switching, via @Vantesh

This commit is contained in:
bbedward
2025-08-21 22:26:26 -04:00
parent ca352e5c52
commit 491d0a6f68
5 changed files with 541 additions and 112 deletions

View File

@@ -0,0 +1,363 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
import qs.Widgets
Rectangle {
id: root
property var device: null
property bool modalVisible: false
property var parentItem
property var availableCodecs: []
property string currentCodec: ""
property bool isLoading: false
property bool parsingTargetCard: false
function show(bluetoothDevice) {
device = bluetoothDevice;
isLoading = true;
availableCodecs = [];
currentCodec = "";
visible = true;
modalVisible = true;
queryCodecs();
Qt.callLater(() => {
focusScope.forceActiveFocus();
});
}
function hide() {
modalVisible = false;
Qt.callLater(() => {
visible = false;
});
}
function queryCodecs() {
if (!device)
return ;
codecQueryProcess.cardName = BluetoothService.getCardName(device);
codecQueryProcess.running = true;
}
function selectCodec(profileName) {
if (!device || isLoading)
return ;
isLoading = true;
codecSwitchProcess.cardName = BluetoothService.getCardName(device);
codecSwitchProcess.profile = profileName;
codecSwitchProcess.running = true;
}
function parseCodecLine(line) {
if (!codecQueryProcess.cardName)
return ;
if (line.includes(`Name: ${codecQueryProcess.cardName}`)) {
parsingTargetCard = true;
return ;
}
if (parsingTargetCard && line.startsWith("Name: ") && !line.includes(codecQueryProcess.cardName)) {
parsingTargetCard = false;
return ;
}
if (parsingTargetCard) {
if (line.startsWith("Active Profile:")) {
let profile = line.split(": ")[1] || "";
let activeCodec = availableCodecs.find((c) => {
return c.profile === profile;
});
if (activeCodec)
currentCodec = activeCodec.name;
return ;
}
if (line.includes("codec") && line.includes("available: yes")) {
let parts = line.split(": ");
if (parts.length >= 2) {
let profile = parts[0].trim();
let description = parts[1];
let codecMatch = description.match(/codec ([^\)\s]+)/i);
let codecName = codecMatch ? codecMatch[1].toUpperCase() : "UNKNOWN";
let codecInfo = BluetoothService.getCodecInfo(codecName);
if (codecInfo && !availableCodecs.some((c) => {
return c.profile === profile;
})) {
let newCodecs = availableCodecs.slice();
newCodecs.push({
"name": codecInfo.name,
"profile": profile,
"description": codecInfo.description,
"qualityColor": codecInfo.qualityColor
});
availableCodecs = newCodecs;
}
}
}
}
}
visible: false
anchors.fill: parent
color: "transparent"
z: 2000
opacity: modalVisible ? 1 : 0
FocusScope {
id: focusScope
anchors.fill: parent
focus: root.visible
enabled: root.visible
MouseArea {
anchors.fill: parent
onClicked: root.hide()
onWheel: (wheel) => {
return wheel.accepted = true;
}
}
}
Rectangle {
anchors.centerIn: parent
width: 320
height: Math.min(contentColumn.implicitHeight + Theme.spacingL * 2, 400)
radius: Theme.cornerRadius
color: Theme.surfaceContainer
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: 1
opacity: modalVisible ? 1 : 0
scale: modalVisible ? 1 : 0.9
MouseArea {
anchors.fill: parent
onClicked: {
}
}
Column {
id: contentColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: device ? BluetoothService.getDeviceIcon(device) : "headset"
size: Theme.iconSize + 4
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
StyledText {
text: device ? (device.name || device.deviceName) : ""
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
}
StyledText {
text: "Audio Codec Selection"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
}
}
}
Rectangle {
width: parent.width
height: 1
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05)
}
StyledText {
text: isLoading ? "Loading codecs..." : `Current: ${currentCodec}`
font.pixelSize: Theme.fontSizeSmall
color: isLoading ? Theme.primary : Theme.surfaceTextMedium
font.weight: Font.Medium
}
Column {
width: parent.width
spacing: Theme.spacingXS
visible: !isLoading
Repeater {
model: availableCodecs
Rectangle {
width: parent.width
height: 48
radius: Theme.cornerRadius
color: {
if (modelData.name === currentCodec)
return Theme.surfaceContainerHigh;
else if (codecMouseArea.containsMouse)
return Theme.surfaceHover;
else
return "transparent";
}
border.color: "transparent"
border.width: 1
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
Rectangle {
width: 6
height: 6
radius: 3
color: modelData.qualityColor
anchors.verticalCenter: parent.verticalCenter
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
StyledText {
text: modelData.name
font.pixelSize: Theme.fontSizeMedium
color: modelData.name === currentCodec ? Theme.primary : Theme.surfaceText
font.weight: modelData.name === currentCodec ? Font.Medium : Font.Normal
}
StyledText {
text: modelData.description
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
}
}
}
DankIcon {
name: "check"
size: Theme.iconSize - 4
color: Theme.primary
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
visible: modelData.name === currentCodec
}
MouseArea {
id: codecMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: modelData.name !== currentCodec && !isLoading
onClicked: {
selectCodec(modelData.profile);
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
}
}
}
}
}
}
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
Behavior on scale {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
}
Process {
id: codecQueryProcess
property string cardName: ""
command: ["pactl", "list", "cards"]
onExited: function(exitCode, exitStatus) {
isLoading = false;
if (exitCode !== 0)
console.warn("Failed to query codecs:", exitCode);
}
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
return parseCodecLine(data.trim());
}
}
}
Process {
id: codecSwitchProcess
property string cardName: ""
property string profile: ""
command: ["pactl", "set-card-profile", cardName, profile]
onExited: function(exitCode, exitStatus) {
isLoading = false;
if (exitCode === 0) {
queryCodecs();
ToastService.showToast("Codec switched successfully", ToastService.levelInfo);
Qt.callLater(root.hide);
} else {
ToastService.showToast("Failed to switch codec", ToastService.levelError);
console.warn("Failed to switch codec:", exitCode);
}
}
}
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
}

View File

@@ -14,25 +14,26 @@ Rectangle {
property var deviceData: null
property bool menuVisible: false
property var parentItem
property var codecSelector
function show(x, y) {
const menuWidth = 160
const menuHeight = menuColumn.implicitHeight + Theme.spacingS * 2
let finalX = x - menuWidth / 2
let finalY = y
finalX = Math.max(0, Math.min(finalX, parentItem.width - menuWidth))
finalY = Math.max(0, Math.min(finalY, parentItem.height - menuHeight))
root.x = finalX
root.y = finalY
root.visible = true
root.menuVisible = true
const menuWidth = 160;
const menuHeight = menuColumn.implicitHeight + Theme.spacingS * 2;
let finalX = x - menuWidth / 2;
let finalY = y;
finalX = Math.max(0, Math.min(finalX, parentItem.width - menuWidth));
finalY = Math.max(0, Math.min(finalY, parentItem.height - menuHeight));
root.x = finalX;
root.y = finalY;
root.visible = true;
root.menuVisible = true;
}
function hide() {
root.menuVisible = false
root.menuVisible = false;
Qt.callLater(() => {
root.visible = false
})
root.visible = false;
});
}
visible: false
@@ -40,8 +41,7 @@ Rectangle {
height: menuColumn.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius
color: Theme.popupBackground()
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g,
Theme.outline.b, 0.08)
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: 1
z: 1000
opacity: menuVisible ? 1 : 0
@@ -69,10 +69,7 @@ Rectangle {
width: parent.width
height: 32
radius: Theme.cornerRadius
color: connectArea.containsMouse ? Qt.rgba(Theme.primary.r,
Theme.primary.g,
Theme.primary.b,
0.12) : "transparent"
color: connectArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
Row {
anchors.left: parent.left
@@ -81,8 +78,7 @@ Rectangle {
spacing: Theme.spacingS
DankIcon {
name: root.deviceData
&& root.deviceData.connected ? "link_off" : "link"
name: root.deviceData && root.deviceData.connected ? "link_off" : "link"
size: Theme.iconSize - 2
color: Theme.surfaceText
opacity: 0.7
@@ -90,13 +86,13 @@ Rectangle {
}
StyledText {
text: root.deviceData
&& root.deviceData.connected ? "Disconnect" : "Connect"
text: root.deviceData && root.deviceData.connected ? "Disconnect" : "Connect"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Normal
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
@@ -108,12 +104,11 @@ Rectangle {
onClicked: {
if (root.deviceData) {
if (root.deviceData.connected)
root.deviceData.disconnect()
root.deviceData.disconnect();
else
BluetoothService.connectDeviceWithTrust(
root.deviceData)
BluetoothService.connectDeviceWithTrust(root.deviceData);
}
root.hide()
root.hide();
}
}
@@ -122,7 +117,62 @@ Rectangle {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
Rectangle {
width: parent.width
height: 32
radius: Theme.cornerRadius
color: codecArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
visible: root.deviceData && BluetoothService.isAudioDevice(root.deviceData) && root.deviceData.connected
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: "high_quality"
size: Theme.iconSize - 2
color: Theme.surfaceText
opacity: 0.7
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: "Audio Codec"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Normal
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: codecArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
codecSelector.show(root.deviceData);
root.hide();
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
Rectangle {
@@ -135,19 +185,16 @@ Rectangle {
anchors.centerIn: parent
width: parent.width
height: 1
color: Qt.rgba(Theme.outline.r, Theme.outline.g,
Theme.outline.b, 0.2)
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
}
}
Rectangle {
width: parent.width
height: 32
radius: Theme.cornerRadius
color: forgetArea.containsMouse ? Qt.rgba(Theme.error.r,
Theme.error.g,
Theme.error.b,
0.12) : "transparent"
color: forgetArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent"
Row {
anchors.left: parent.left
@@ -170,6 +217,7 @@ Rectangle {
font.weight: Font.Normal
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
@@ -180,9 +228,9 @@ Rectangle {
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.deviceData)
root.deviceData.forget()
root.deviceData.forget();
root.hide()
root.hide();
}
}
@@ -191,8 +239,11 @@ Rectangle {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
Behavior on opacity {
@@ -200,6 +251,7 @@ Rectangle {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
Behavior on scale {
@@ -207,5 +259,7 @@ Rectangle {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
}

View File

@@ -22,6 +22,7 @@ Item {
Column {
id: mainColumn
width: parent.width
spacing: Theme.spacingL
@@ -39,19 +40,30 @@ Item {
width: parent.width
sourceComponent: availableComponent
}
}
}
BluetoothContextMenu {
id: bluetoothContextMenuWindow
parentItem: bluetoothTab
codecSelector: codecSelector
}
BluetoothCodecSelector {
id: codecSelector
parentItem: bluetoothTab
}
MouseArea {
anchors.fill: parent
visible: bluetoothContextMenuWindow.visible
visible: bluetoothContextMenuWindow.visible || codecSelector.visible
onClicked: {
bluetoothContextMenuWindow.hide()
bluetoothContextMenuWindow.hide();
codecSelector.hide();
}
MouseArea {
@@ -60,29 +72,36 @@ Item {
width: bluetoothContextMenuWindow.width
height: bluetoothContextMenuWindow.height
onClicked: {
}
}
}
Component {
id: toggleComponent
BluetoothToggle {
width: parent.width
}
}
Component {
id: pairedComponent
PairedDevicesList {
width: parent.width
}
}
Component {
id: availableComponent
AvailableDevicesList {
width: parent.width
}
}
}