diff --git a/Modules/ControlCenter/ControlCenterPopout.qml b/Modules/ControlCenter/ControlCenterPopout.qml index 5c3dbad7..34bcec31 100644 --- a/Modules/ControlCenter/ControlCenterPopout.qml +++ b/Modules/ControlCenter/ControlCenterPopout.qml @@ -10,6 +10,7 @@ import qs.Common import qs.Modules.ControlCenter import qs.Modules.ControlCenter.Widgets import qs.Modules.ControlCenter.Details +import qs.Modules.ControlCenter.Details 1.0 as Details import qs.Services import qs.Widgets @@ -74,43 +75,48 @@ DankPopout { } content: Component { - Rectangle { - id: controlContent + Item { + implicitHeight: controlContent.implicitHeight + property alias bluetoothCodecSelector: bluetoothCodecSelector + + Rectangle { + id: controlContent - implicitHeight: mainColumn.implicitHeight + Theme.spacingM + anchors.fill: parent + implicitHeight: mainColumn.implicitHeight + Theme.spacingM - color: Theme.popupBackground() - radius: Theme.cornerRadius - border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, - Theme.outline.b, 0.08) - border.width: 1 - antialiasing: true - smooth: true - focus: true + color: Theme.popupBackground() + radius: Theme.cornerRadius + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, + Theme.outline.b, 0.08) + border.width: 1 + antialiasing: true + smooth: true + focus: true - Component.onCompleted: { - if (root.shouldBeVisible) - forceActiveFocus() - } - - Keys.onPressed: function (event) { - if (event.key === Qt.Key_Escape) { - root.close() - event.accepted = true - } else { - event.accepted = false - } - } - - Connections { - function onShouldBeVisibleChanged() { + Component.onCompleted: { if (root.shouldBeVisible) - Qt.callLater(function () { - controlContent.forceActiveFocus() - }) + forceActiveFocus() + } + + Keys.onPressed: function (event) { + if (event.key === Qt.Key_Escape) { + root.close() + event.accepted = true + } else { + event.accepted = false + } + } + + Connections { + function onShouldBeVisibleChanged() { + if (root.shouldBeVisible) + Qt.callLater(function () { + controlContent.forceActiveFocus() + }) + } + target: root } - target: root - } Column { id: mainColumn @@ -808,7 +814,13 @@ DankPopout { } } } - + } + + Details.BluetoothCodecSelector { + id: bluetoothCodecSelector + anchors.fill: parent + z: 10000 + } } } @@ -819,7 +831,17 @@ DankPopout { Component { id: bluetoothDetailComponent - BluetoothDetail {} + BluetoothDetail { + id: bluetoothDetail + onShowCodecSelector: function(device) { + if (contentLoader.item && contentLoader.item.bluetoothCodecSelector) { + contentLoader.item.bluetoothCodecSelector.show(device) + contentLoader.item.bluetoothCodecSelector.codecSelected.connect(function(deviceAddress, codecName) { + bluetoothDetail.updateDeviceCodecDisplay(deviceAddress, codecName) + }) + } + } + } } Component { diff --git a/Modules/ControlCenter/Details/BluetoothCodecSelector.qml b/Modules/ControlCenter/Details/BluetoothCodecSelector.qml index 22080948..f67427f3 100644 --- a/Modules/ControlCenter/Details/BluetoothCodecSelector.qml +++ b/Modules/ControlCenter/Details/BluetoothCodecSelector.qml @@ -6,7 +6,7 @@ import qs.Common import qs.Services import qs.Widgets -Rectangle { +Item { id: root property var device: null @@ -15,7 +15,8 @@ Rectangle { property var availableCodecs: [] property string currentCodec: "" property bool isLoading: false - property bool parsingTargetCard: false + + signal codecSelected(string deviceAddress, string codecName) function show(bluetoothDevice) { device = bluetoothDevice; @@ -39,75 +40,68 @@ Rectangle { function queryCodecs() { if (!device) - return ; + return; - codecQueryProcess.cardName = BluetoothService.getCardName(device); - codecQueryProcess.running = true; + BluetoothService.getAvailableCodecs(device, function(codecs, current) { + availableCodecs = codecs; + currentCodec = current; + isLoading = false; + }); } function selectCodec(profileName) { if (!device || isLoading) - return ; + return; + + let selectedCodec = availableCodecs.find(c => c.profile === profileName); + if (selectedCodec && device) { + BluetoothService.updateDeviceCodec(device.address, selectedCodec.name); + codecSelected(device.address, selectedCodec.name); + } 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 ; + BluetoothService.switchCodec(device, profileName, function(success, message) { + isLoading = false; + if (success) { + ToastService.showToast(message, ToastService.levelInfo); + Qt.callLater(root.hide); + } else { + ToastService.showToast(message, ToastService.levelError); } - 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 + + MouseArea { + id: modalBlocker + anchors.fill: parent + visible: modalVisible + enabled: modalVisible + hoverEnabled: true + preventStealing: true + propagateComposedEvents: false + + onClicked: root.hide() + onWheel: (wheel) => { wheel.accepted = true } + onPositionChanged: (mouse) => { mouse.accepted = true } + } + + Rectangle { + id: modalBackground + anchors.fill: parent + color: Qt.rgba(0, 0, 0, 0.5) + opacity: modalVisible ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.emphasizedEasing + } + } + } FocusScope { id: focusScope @@ -116,17 +110,14 @@ Rectangle { focus: root.visible enabled: root.visible - MouseArea { - anchors.fill: parent - onClicked: root.hide() - onWheel: (wheel) => { - return wheel.accepted = true; - } + Keys.onEscapePressed: { + root.hide() + event.accepted = true } - } Rectangle { + id: modalContent anchors.centerIn: parent width: 320 height: Math.min(contentColumn.implicitHeight + Theme.spacingL * 2, 400) @@ -139,8 +130,12 @@ Rectangle { MouseArea { anchors.fill: parent - onClicked: { - } + hoverEnabled: true + preventStealing: true + propagateComposedEvents: false + onClicked: (mouse) => { mouse.accepted = true } + onWheel: (wheel) => { wheel.accepted = true } + onPositionChanged: (mouse) => { mouse.accepted = true } } Column { @@ -309,55 +304,4 @@ Rectangle { } } - - 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 - } - - } - } diff --git a/Modules/ControlCenter/Details/BluetoothDetail.qml b/Modules/ControlCenter/Details/BluetoothDetail.qml index 759363ed..7de1f6c4 100644 --- a/Modules/ControlCenter/Details/BluetoothDetail.qml +++ b/Modules/ControlCenter/Details/BluetoothDetail.qml @@ -13,7 +13,19 @@ Rectangle { border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08) border.width: 1 - property var bluetoothCodecModalRef: bluetoothCodecModal + property var bluetoothCodecModalRef: null + + signal showCodecSelector(var device) + + function updateDeviceCodecDisplay(deviceAddress, codecName) { + for (let i = 0; i < pairedRepeater.count; i++) { + let item = pairedRepeater.itemAt(i) + if (item && item.modelData && item.modelData.address === deviceAddress) { + item.currentCodec = codecName + break + } + } + } Row { id: headerRow @@ -131,9 +143,17 @@ Rectangle { required property var modelData required property int index + property string currentCodec: BluetoothService.deviceCodecs[modelData.address] || "" + width: parent.width height: 50 radius: Theme.cornerRadius + + Component.onCompleted: { + if (modelData.connected && BluetoothService.isAudioDevice(modelData)) { + BluetoothService.refreshDeviceCodec(modelData) + } + } color: { if (modelData.state === BluetoothDeviceState.Connecting) return Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.12) @@ -189,8 +209,13 @@ Rectangle { text: { if (modelData.state === BluetoothDeviceState.Connecting) return "Connecting..." - if (modelData.connected) - return "Connected" + if (modelData.connected) { + let status = "Connected" + if (currentCodec) { + status += " • " + currentCodec + } + return status + } return "Paired" } font.pixelSize: Theme.fontSizeSmall @@ -484,8 +509,8 @@ Rectangle { } onTriggered: { - if (bluetoothCodecModalRef && bluetoothContextMenu.currentDevice) { - bluetoothCodecModalRef.show(bluetoothContextMenu.currentDevice) + if (bluetoothContextMenu.currentDevice) { + showCodecSelector(bluetoothContextMenu.currentDevice) } } } @@ -515,9 +540,4 @@ Rectangle { } } - BluetoothCodecSelector { - id: bluetoothCodecModal - anchors.fill: parent - z: 3000 - } } \ No newline at end of file diff --git a/Services/BluetoothService.qml b/Services/BluetoothService.qml index a0cb69a7..9d378434 100644 --- a/Services/BluetoothService.qml +++ b/Services/BluetoothService.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell +import Quickshell.Io import Quickshell.Bluetooth Singleton { @@ -221,4 +222,255 @@ Singleton { qualityColor: "#9E9E9E" } } + + property var deviceCodecs: ({}) + + function updateDeviceCodec(deviceAddress, codec) { + deviceCodecs[deviceAddress] = codec + deviceCodecsChanged() + } + + function refreshDeviceCodec(device) { + if (!device || !device.connected || !isAudioDevice(device)) { + return + } + + let cardName = getCardName(device) + codecQueryProcess.cardName = cardName + codecQueryProcess.deviceAddress = device.address + codecQueryProcess.availableCodecs = [] + codecQueryProcess.parsingTargetCard = false + codecQueryProcess.detectedCodec = "" + codecQueryProcess.running = true + } + + function getCurrentCodec(device, callback) { + if (!device || !device.connected || !isAudioDevice(device)) { + callback("") + return + } + + let cardName = getCardName(device) + codecQueryProcess.cardName = cardName + codecQueryProcess.callback = callback + codecQueryProcess.availableCodecs = [] + codecQueryProcess.parsingTargetCard = false + codecQueryProcess.detectedCodec = "" + codecQueryProcess.running = true + } + + function getAvailableCodecs(device, callback) { + if (!device || !device.connected || !isAudioDevice(device)) { + callback([], "") + return + } + + let cardName = getCardName(device) + codecFullQueryProcess.cardName = cardName + codecFullQueryProcess.callback = callback + codecFullQueryProcess.availableCodecs = [] + codecFullQueryProcess.parsingTargetCard = false + codecFullQueryProcess.detectedCodec = "" + codecFullQueryProcess.running = true + } + + function switchCodec(device, profileName, callback) { + if (!device || !isAudioDevice(device)) { + callback(false, "Invalid device") + return + } + + let cardName = getCardName(device) + codecSwitchProcess.cardName = cardName + codecSwitchProcess.profile = profileName + codecSwitchProcess.callback = callback + codecSwitchProcess.running = true + } + + Process { + id: codecQueryProcess + + property string cardName: "" + property string deviceAddress: "" + property var callback: null + property bool parsingTargetCard: false + property string detectedCodec: "" + property var availableCodecs: [] + + command: ["pactl", "list", "cards"] + + onExited: function(exitCode, exitStatus) { + if (exitCode === 0 && detectedCodec) { + if (deviceAddress) { + root.updateDeviceCodec(deviceAddress, detectedCodec) + } + if (callback) { + callback(detectedCodec) + } + } else if (callback) { + callback("") + } + + parsingTargetCard = false + detectedCodec = "" + availableCodecs = [] + deviceAddress = "" + callback = null + } + + stdout: SplitParser { + splitMarker: "\n" + onRead: (data) => { + let line = data.trim() + + if (line.includes(`Name: ${codecQueryProcess.cardName}`)) { + codecQueryProcess.parsingTargetCard = true + return + } + + if (codecQueryProcess.parsingTargetCard && line.startsWith("Name: ") && !line.includes(codecQueryProcess.cardName)) { + codecQueryProcess.parsingTargetCard = false + return + } + + if (codecQueryProcess.parsingTargetCard) { + if (line.startsWith("Active Profile:")) { + let profile = line.split(": ")[1] || "" + let activeCodec = codecQueryProcess.availableCodecs.find((c) => { + return c.profile === profile + }) + if (activeCodec) { + codecQueryProcess.detectedCodec = 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 = root.getCodecInfo(codecName) + if (codecInfo && !codecQueryProcess.availableCodecs.some((c) => { + return c.profile === profile + })) { + let newCodecs = codecQueryProcess.availableCodecs.slice() + newCodecs.push({ + "name": codecInfo.name, + "profile": profile, + "description": codecInfo.description, + "qualityColor": codecInfo.qualityColor + }) + codecQueryProcess.availableCodecs = newCodecs + } + } + } + } + } + } + } + + Process { + id: codecFullQueryProcess + + property string cardName: "" + property var callback: null + property bool parsingTargetCard: false + property string detectedCodec: "" + property var availableCodecs: [] + + command: ["pactl", "list", "cards"] + + onExited: function(exitCode, exitStatus) { + if (callback) { + callback(exitCode === 0 ? availableCodecs : [], exitCode === 0 ? detectedCodec : "") + } + parsingTargetCard = false + detectedCodec = "" + availableCodecs = [] + callback = null + } + + stdout: SplitParser { + splitMarker: "\n" + onRead: (data) => { + let line = data.trim() + + if (line.includes(`Name: ${codecFullQueryProcess.cardName}`)) { + codecFullQueryProcess.parsingTargetCard = true + return + } + + if (codecFullQueryProcess.parsingTargetCard && line.startsWith("Name: ") && !line.includes(codecFullQueryProcess.cardName)) { + codecFullQueryProcess.parsingTargetCard = false + return + } + + if (codecFullQueryProcess.parsingTargetCard) { + if (line.startsWith("Active Profile:")) { + let profile = line.split(": ")[1] || "" + let activeCodec = codecFullQueryProcess.availableCodecs.find((c) => { + return c.profile === profile + }) + if (activeCodec) { + codecFullQueryProcess.detectedCodec = 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 = root.getCodecInfo(codecName) + if (codecInfo && !codecFullQueryProcess.availableCodecs.some((c) => { + return c.profile === profile + })) { + let newCodecs = codecFullQueryProcess.availableCodecs.slice() + newCodecs.push({ + "name": codecInfo.name, + "profile": profile, + "description": codecInfo.description, + "qualityColor": codecInfo.qualityColor + }) + codecFullQueryProcess.availableCodecs = newCodecs + } + } + } + } + } + } + } + + Process { + id: codecSwitchProcess + + property string cardName: "" + property string profile: "" + property var callback: null + + command: ["pactl", "set-card-profile", cardName, profile] + + onExited: function(exitCode, exitStatus) { + if (callback) { + callback(exitCode === 0, exitCode === 0 ? "Codec switched successfully" : "Failed to switch codec") + } + + // If successful, refresh the codec for this device + if (exitCode === 0) { + if (root.adapter && root.adapter.devices) { + root.adapter.devices.values.forEach(device => { + if (device && root.getCardName(device) === cardName) { + Qt.callLater(() => root.refreshDeviceCodec(device)) + } + }) + } + } + + callback = null + } + } } diff --git a/Services/CavaService.qml b/Services/CavaService.qml index 532a3b79..506e751d 100644 --- a/Services/CavaService.qml +++ b/Services/CavaService.qml @@ -11,7 +11,6 @@ Singleton { property list values: Array(6) property int refCount: 0 property bool cavaAvailable: false - property string monitorSource: AudioService.sink && AudioService.sink.name ? AudioService.sink.name + ".monitor" : "" Process { id: cavaCheck @@ -31,9 +30,7 @@ Singleton { id: cavaProcess running: root.cavaAvailable && root.refCount > 0 - command: ["sh", "-c", root.monitorSource - ? `printf '[general]\\nmode=normal\\nframerate=25\\nautosens=0\\nsensitivity=30\\nbars=6\\nlower_cutoff_freq=50\\nhigher_cutoff_freq=12000\\n[input]\\nmethod=pulse\\nsource=${root.monitorSource}\\n[output]\\nmethod=raw\\nraw_target=/dev/stdout\\ndata_format=ascii\\nchannels=mono\\nmono_option=average\\n[smoothing]\\nnoise_reduction=35\\nintegral=90\\ngravity=95\\nignore=2\\nmonstercat=1.5' | cava -p /dev/stdin` - : `printf '[general]\\nmode=normal\\nframerate=25\\nautosens=0\\nsensitivity=30\\nbars=6\\nlower_cutoff_freq=50\\nhigher_cutoff_freq=12000\\n[output]\\nmethod=raw\\nraw_target=/dev/stdout\\ndata_format=ascii\\nchannels=mono\\nmono_option=average\\n[smoothing]\\nnoise_reduction=35\\nintegral=90\\ngravity=95\\nignore=2\\nmonstercat=1.5' | cava -p /dev/stdin`] + command: ["sh", "-c", `printf '[general]\\nmode=normal\\nframerate=25\\nautosens=0\\nsensitivity=30\\nbars=6\\nlower_cutoff_freq=50\\nhigher_cutoff_freq=12000\\n[output]\\nmethod=raw\\nraw_target=/dev/stdout\\ndata_format=ascii\\nchannels=mono\\nmono_option=average\\n[smoothing]\\nnoise_reduction=35\\nintegral=90\\ngravity=95\\nignore=2\\nmonstercat=1.5' | cava -p /dev/stdin`] onRunningChanged: { if (!running) {