From 2114ece0df9b794b62291e79340b18a0b791a984 Mon Sep 17 00:00:00 2001 From: purian23 Date: Sat, 18 Jul 2026 14:16:49 -0400 Subject: [PATCH] feat(bluetooth): built in audio codec support - another dep bites the dust - Updated the codec logic to utilize WirePlumber, bye pactl - Improved UI to display media and call codec sections --- .../Details/BluetoothCodecSelector.qml | 182 +++-- .../ControlCenter/Details/BluetoothDetail.qml | 15 +- quickshell/Services/BluetoothService.qml | 670 ++++++++++++------ quickshell/scripts/bluez-card-profile.lua | 142 ++++ 4 files changed, 699 insertions(+), 310 deletions(-) create mode 100755 quickshell/scripts/bluez-card-profile.lua diff --git a/quickshell/Modules/ControlCenter/Details/BluetoothCodecSelector.qml b/quickshell/Modules/ControlCenter/Details/BluetoothCodecSelector.qml index beb9f00a0..43cba78a6 100644 --- a/quickshell/Modules/ControlCenter/Details/BluetoothCodecSelector.qml +++ b/quickshell/Modules/ControlCenter/Details/BluetoothCodecSelector.qml @@ -18,6 +18,8 @@ Item { property string statusMessage: "" property bool statusIsError: false + readonly property var mediaCodecs: availableCodecs.filter(c => (c.category || "media") !== "call") + readonly property var callCodecs: availableCodecs.filter(c => c.category === "call") readonly property bool deviceValid: device !== null && device.connected && BluetoothService.isAudioDevice(device) signal codecSelected(string deviceAddress, string codecName) @@ -64,8 +66,8 @@ Item { availableCodecs = codecs; currentCodec = current; isLoading = false; - if (BluetoothService.pactlChecked && !BluetoothService.pactlAvailable) { - statusMessage = I18n.tr("Codec switching is unavailable because pactl was not found"); + if (BluetoothService.wpexecChecked && !BluetoothService.wpexecAvailable && !BluetoothService.dbusBridgeAvailable) { + statusMessage = I18n.tr("Codec switching is unavailable because WirePlumber was not found"); statusIsError = true; } else if (codecs.length === 0) { statusMessage = I18n.tr("No codecs found"); @@ -98,12 +100,14 @@ Item { isLoading = false; if (success) { + BluetoothService.updateDeviceCodec(capturedAddress, selectedCodec.name); + codecSelected(capturedAddress, selectedCodec.name); ToastService.showToast(message, ToastService.levelInfo); Qt.callLater(root.hide); return; } ToastService.showToast(message, ToastService.levelError); - }); + }, selectedCodec.name); } onDeviceValidChanged: { @@ -254,86 +258,118 @@ Item { spacing: Theme.spacingXS visible: !isLoading && availableCodecs.length > 0 + StyledText { + text: I18n.tr("Media") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceTextMedium + visible: root.mediaCodecs.length > 0 && root.callCodecs.length > 0 + width: parent.width + } + Repeater { - model: availableCodecs + model: root.mediaCodecs + delegate: codecRow + } + + Item { + width: 1 + height: Theme.spacingS + visible: root.mediaCodecs.length > 0 && root.callCodecs.length > 0 + } + + StyledText { + text: I18n.tr("Calls / Headset") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceTextMedium + visible: root.callCodecs.length > 0 + width: parent.width + } + + Repeater { + model: root.callCodecs + delegate: codecRow + } + } + } + + Component { + id: codecRow + + Rectangle { + required property var modelData + width: parent ? parent.width : 280 + height: 48 + radius: Theme.cornerRadius + color: { + if (modelData.name === root.currentCodec) + return Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency); + if (codecMouseArea.containsMouse) + return Theme.surfaceHover; + return "transparent"; + } + border.color: "transparent" + border.width: 0 + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS Rectangle { - width: parent.width - height: 48 - radius: Theme.cornerRadius - color: { - if (modelData.name === currentCodec) - return Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency); - else if (codecMouseArea.containsMouse) - return Theme.surfaceHover; - else - return "transparent"; - } - border.color: "transparent" - border.width: 0 + width: 6 + height: 6 + radius: 3 + color: modelData.qualityColor + anchors.verticalCenter: parent.verticalCenter + } - Row { - anchors.left: parent.left - anchors.leftMargin: Theme.spacingM - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingS + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXXS - Rectangle { - width: 6 - height: 6 - radius: 3 - color: modelData.qualityColor - anchors.verticalCenter: parent.verticalCenter - } - - Column { - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXXS - - 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 - } - } + StyledText { + text: modelData.name + font.pixelSize: Theme.fontSizeMedium + color: modelData.name === root.currentCodec ? Theme.primary : Theme.surfaceText + font.weight: modelData.name === root.currentCodec ? Font.Medium : Font.Normal } - 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 - } - - DankRipple { - id: codecRipple - cornerRadius: parent.radius - } - - MouseArea { - id: codecMouseArea - - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - enabled: modelData.name !== currentCodec && !isLoading - onPressed: mouse => codecRipple.trigger(mouse.x, mouse.y) - onClicked: { - selectCodec(modelData.profile); - } + 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 === root.currentCodec + } + + DankRipple { + id: codecRipple + cornerRadius: parent.radius + } + + MouseArea { + id: codecMouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: modelData.name !== root.currentCodec && !root.isLoading + onPressed: mouse => codecRipple.trigger(mouse.x, mouse.y) + onClicked: root.selectCodec(modelData.profile) + } } } diff --git a/quickshell/Modules/ControlCenter/Details/BluetoothDetail.qml b/quickshell/Modules/ControlCenter/Details/BluetoothDetail.qml index 03e14e1fc..97dcaee85 100644 --- a/quickshell/Modules/ControlCenter/Details/BluetoothDetail.qml +++ b/quickshell/Modules/ControlCenter/Details/BluetoothDetail.qml @@ -92,15 +92,7 @@ Rectangle { } 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; - } + BluetoothService.updateDeviceCodec(deviceAddress, codecName); } function normalizePinList(value) { @@ -255,7 +247,10 @@ Rectangle { required property var modelData required property int index - readonly property string currentCodec: BluetoothService.deviceCodecs[modelData.address] || "" + readonly property string currentCodec: { + const codecs = BluetoothService.deviceCodecs; + return codecs[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) diff --git a/quickshell/Services/BluetoothService.qml b/quickshell/Services/BluetoothService.qml index 4db401849..f18ed3c1d 100644 --- a/quickshell/Services/BluetoothService.qml +++ b/quickshell/Services/BluetoothService.qml @@ -5,6 +5,7 @@ import QtQuick import Quickshell import Quickshell.Io import Quickshell.Bluetooth +import qs.Common import qs.Services Singleton { @@ -14,9 +15,18 @@ Singleton { readonly property bool available: adapter !== null readonly property bool enabled: (adapter && adapter.enabled) ?? false readonly property bool discovering: (adapter && adapter.discovering) ?? false - property bool pactlAvailable: false - property bool pactlChecked: false - property var pendingPactlActions: [] + readonly property bool dbusBridgeAvailable: DMSService.isConnected && DMSService.capabilities.includes("dbus") + property bool wpexecAvailable: false + property bool wpexecChecked: false + property var pendingCodecActions: [] + property bool _codecSignalsSubscribed: false + readonly property string bluezService: "org.bluez" + readonly property string mediaTransportIface: "org.bluez.MediaTransport1" + readonly property string mediaEndpointIface: "org.bluez.MediaEndpoint1" + readonly property string objectManagerIface: "org.freedesktop.DBus.ObjectManager" + readonly property string propertiesIface: "org.freedesktop.DBus.Properties" + readonly property string cardProfileScript: Quickshell.shellDir + "/scripts/bluez-card-profile.lua" + readonly property bool codecControlAvailable: wpexecChecked && wpexecAvailable readonly property var devices: adapter ? adapter.devices : null readonly property bool enhancedPairingAvailable: DMSService.dmsAvailable && DMSService.apiVersion >= 9 && DMSService.capabilities.includes("bluetooth") readonly property bool connected: { @@ -65,20 +75,189 @@ Singleton { } Component.onCompleted: { - detectPactlProcess.running = true; + detectWpexecProcess.running = true; + maybeSubscribeCodecSignals(); } - function whenPactlChecked(action) { - if (pactlChecked) { + Connections { + target: DMSService + + function onConnectionStateChanged() { + root._codecSignalsSubscribed = false; + root.maybeSubscribeCodecSignals(); + } + + function onCapabilitiesReceived() { + root.maybeSubscribeCodecSignals(); + } + + function onDbusSignalReceived(subscriptionId, data) { + root.handleCodecDbusSignal(data); + } + } + + function whenCodecBackendReady(action) { + if (wpexecChecked) { action(); return; } - const actions = pendingPactlActions.slice(); + const actions = pendingCodecActions.slice(); actions.push(action); - pendingPactlActions = actions; - if (!detectPactlProcess.running) - detectPactlProcess.running = true; + pendingCodecActions = actions; + if (!detectWpexecProcess.running) + detectWpexecProcess.running = true; + } + + function maybeSubscribeCodecSignals() { + if (!dbusBridgeAvailable || _codecSignalsSubscribed) + return; + _codecSignalsSubscribed = true; + DMSService.dbusSubscribe("system", bluezService, "", objectManagerIface, "InterfacesAdded", null); + DMSService.dbusSubscribe("system", bluezService, "", objectManagerIface, "InterfacesRemoved", null); + DMSService.dbusSubscribe("system", bluezService, "", propertiesIface, "PropertiesChanged", null); + } + + function handleCodecDbusSignal(data) { + if (!data?.path || !data.path.includes("/dev_")) + return; + + const member = data.member || ""; + if (member !== "InterfacesAdded" && member !== "InterfacesRemoved" && member !== "PropertiesChanged") + return; + + if (member === "PropertiesChanged" && data.body?.[0] !== mediaTransportIface && data.body?.[0] !== mediaEndpointIface) + return; + + const device = deviceForDbusPath(data.path); + if (device && device.connected && isAudioDevice(device)) + Qt.callLater(() => root.refreshDeviceCodec(device)); + } + + function deviceForDbusPath(path) { + if (!path || !adapter?.devices) + return null; + const match = path.match(/\/dev_([0-9A-Fa-f_]+)/); + if (!match) + return null; + const address = match[1].replace(/_/g, ":").toUpperCase(); + return adapter.devices.values.find(d => (d.address || "").toUpperCase() === address) ?? null; + } + + function bytesFromDbusValue(value) { + if (!value) + return []; + if (Array.isArray(value)) + return value.map(v => Number(v) & 0xff); + if (typeof value === "string") { + try { + const decoded = Qt.atob(value); + if (decoded instanceof ArrayBuffer) { + return Array.from(new Uint8Array(decoded)); + } + const bytes = []; + for (let i = 0; i < decoded.length; i++) + bytes.push(decoded.charCodeAt(i) & 0xff); + return bytes; + } catch (e) { + return []; + } + } + return []; + } + + function codecNameFromBluez(codecId, configValue) { + const id = Number(codecId); + if (id === 0x00) + return "SBC"; + if (id === 0x01) + return "MPEG12"; + if (id === 0x02) + return "AAC"; + if (id === 0x04) + return "ATRAC"; + if (id === 0xFF) { + const bytes = bytesFromDbusValue(configValue); + if (bytes.length >= 6) { + const vendor = bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24); + const vendorCodec = bytes[4] | (bytes[5] << 8); + if (vendor === 0x0000004f && vendorCodec === 0x0001) + return "APTX"; + if (vendor === 0x000000d7 && vendorCodec === 0x0024) + return "APTX_HD"; + if (vendor === 0x0000000a && vendorCodec === 0x0002) + return "APTX"; + if (vendor === 0x0000012d && vendorCodec === 0x00aa) + return "LDAC"; + } + return "VENDOR"; + } + return ""; + } + + function queryBluezCodecState(device, callback) { + if (!dbusBridgeAvailable || !device) { + callback([], ""); + return; + } + + const devicePath = getDevicePath(device); + if (!devicePath) { + callback([], ""); + return; + } + + DMSService.dbusCall("system", bluezService, "/", objectManagerIface, "GetManagedObjects", [], response => { + if (response.error) { + callback([], ""); + return; + } + + const objects = response.result?.values?.[0] || {}; + const codecs = []; + let current = ""; + + for (const path in objects) { + if (!path.startsWith(devicePath + "/")) + continue; + const ifaces = objects[path] || {}; + const transport = ifaces[mediaTransportIface]; + if (transport) { + const name = codecNameFromBluez(transport.Codec, transport.Configuration); + if (name) { + current = root.getCodecInfo(name).name; + if (!codecs.some(c => c.name === current)) { + const info = root.getCodecInfo(name); + codecs.push({ + "name": info.name, + "profile": info.name, + "description": info.description, + "qualityColor": info.qualityColor, + "category": root.codecCategory(info.name, info.name) + }); + } + } + } + const endpoint = ifaces[mediaEndpointIface]; + if (endpoint) { + const name = codecNameFromBluez(endpoint.Codec, endpoint.Capabilities || endpoint.Configuration); + if (name) { + const info = root.getCodecInfo(name); + if (!codecs.some(c => c.name === info.name)) { + codecs.push({ + "name": info.name, + "profile": info.name, + "description": info.description, + "qualityColor": info.qualityColor, + "category": root.codecCategory(info.name, info.name) + }); + } + } + } + } + + callback(codecs, current); + }); } function sortDevices(devices) { @@ -256,8 +435,8 @@ Singleton { function getDevicePath(device) { if (!device || !device.address) { return ""; - } - return device.dbusPath ?? ""; + } + return device.dbusPath ?? ""; } function isAudioDevice(device) { @@ -282,16 +461,61 @@ Singleton { "description": "High quality • Balanced battery", "qualityColor": "#FF9800" }, + "APTX_LL": { + "name": "aptX LL", + "description": "Low latency • Gaming and video", + "qualityColor": "#FF9800" + }, + "APTX_ADAPTIVE": { + "name": "aptX Adaptive", + "description": "Adaptive quality and latency", + "qualityColor": "#FF9800" + }, "APTX": { "name": "aptX", "description": "Good quality • Low latency", "qualityColor": "#FF9800" }, + "AAC_ELD": { + "name": "AAC-ELD", + "description": "Low-delay AAC • Voice and video", + "qualityColor": "#2196F3" + }, "AAC": { "name": "AAC", "description": "Balanced quality and battery", "qualityColor": "#2196F3" }, + "OPUS_05": { + "name": "Opus", + "description": "High quality • Modern Bluetooth LE audio", + "qualityColor": "#4CAF50" + }, + "OPUS_G": { + "name": "Opus", + "description": "High quality • Modern Bluetooth LE audio", + "qualityColor": "#4CAF50" + }, + "OPUS": { + "name": "Opus", + "description": "High quality • Efficient streaming", + "qualityColor": "#4CAF50" + }, + "LC3": { + "name": "LC3", + "description": "LE Audio • Efficient high quality", + "qualityColor": "#4CAF50" + }, + "LC3_SWB": { + "name": "LC3-SWB", + "description": "Wideband speech • Hands-free calls", + "qualityColor": "#9E9E9E" + }, + "LC3_A127": { + "name": "LC3", + "description": "LE Audio speech • Hands-free calls", + "qualityColor": "#9E9E9E" + }, "SBC_XQ": { "name": "SBC-XQ", "description": "Enhanced SBC • Better compatibility", @@ -311,6 +535,11 @@ Singleton { "name": "CVSD", "description": "Basic speech codec • Legacy compatibility", "qualityColor": "#9E9E9E" + }, + "FASTSTREAM": { + "name": "FastStream", + "description": "Low latency SBC variant", + "qualityColor": "#2196F3" } }; @@ -324,29 +553,73 @@ Singleton { property var deviceCodecs: ({}) function updateDeviceCodec(deviceAddress, codec) { - deviceCodecs[deviceAddress] = codec; - deviceCodecsChanged(); + if (!deviceAddress || !codec) + return; + const next = Object.assign({}, deviceCodecs); + next[deviceAddress] = codec; + deviceCodecs = next; + } + + function codecCategory(codecName, profileName) { + const profile = String(profileName || "").toLowerCase(); + const codec = String(codecName || "").replace(/[-\s]+/g, "_").toUpperCase(); + if (profile.includes("headset") || profile.includes("hfp") || profile.includes("hsp") || profile.includes("handsfree") || profile.includes("head-unit") || profile.includes("audio-gateway")) + return "call"; + if (codec === "CVSD" || codec === "MSBC" || codec === "LC3_SWB" || codec === "LC3_A127") + return "call"; + return "media"; + } + + function codecNameFromProfile(profileName) { + if (!profileName) + return ""; + const match = String(profileName).match(/codec\s+([^\)]+)/i); + if (match) + return getCodecInfo(match[1].trim()).name; + const parts = String(profileName).split(/[-_]/); + if (parts.length > 1) { + const tail = parts.slice(1).join("_"); + const info = getCodecInfo(tail); + if (info.description !== "Unknown codec") + return info.name; + const last = parts[parts.length - 1]; + const lastInfo = getCodecInfo(last); + if (lastInfo.description !== "Unknown codec") + return lastInfo.name; + } + const info = getCodecInfo(profileName); + return info.description === "Unknown codec" ? "" : info.name; } function refreshDeviceCodec(device) { if (!device || !device.connected || !isAudioDevice(device)) { return; } - if (!pactlChecked) { - whenPactlChecked(() => root.refreshDeviceCodec(device)); - return; - } - if (!pactlAvailable) { - return; - } - const cardName = getCardName(device); - codecQueryProcess.cardName = cardName; - codecQueryProcess.deviceAddress = device.address; - codecQueryProcess.availableCodecs = []; - codecQueryProcess.parsingTargetCard = false; - codecQueryProcess.detectedCodec = ""; - codecQueryProcess.running = true; + whenCodecBackendReady(() => { + if (root.wpexecAvailable) { + root.queryCardProfiles(device, (codecs, current) => { + if (current) { + root.updateDeviceCodec(device.address, current); + return; + } + if (!root.dbusBridgeAvailable) + return; + root.queryBluezCodecState(device, (bluezCodecs, bluezCurrent) => { + if (bluezCurrent) + root.updateDeviceCodec(device.address, bluezCurrent); + }); + }); + return; + } + + if (!root.dbusBridgeAvailable) + return; + root.queryBluezCodecState(device, (codecs, current) => { + if (current) + root.updateDeviceCodec(device.address, current); + }); + }); } function getCurrentCodec(device, callback) { @@ -354,22 +627,33 @@ Singleton { callback(""); return; } - if (!pactlChecked) { - whenPactlChecked(() => root.getCurrentCodec(device, callback)); - return; - } - if (!pactlAvailable) { - callback(""); - return; - } - const cardName = getCardName(device); - codecQueryProcess.cardName = cardName; - codecQueryProcess.callback = callback; - codecQueryProcess.availableCodecs = []; - codecQueryProcess.parsingTargetCard = false; - codecQueryProcess.detectedCodec = ""; - codecQueryProcess.running = true; + whenCodecBackendReady(() => { + if (root.wpexecAvailable) { + root.queryCardProfiles(device, (codecs, current) => { + if (current) { + callback(current); + return; + } + if (!root.dbusBridgeAvailable) { + callback(""); + return; + } + root.queryBluezCodecState(device, (bluezCodecs, bluezCurrent) => { + callback(bluezCurrent || ""); + }); + }); + return; + } + + if (!root.dbusBridgeAvailable) { + callback(""); + return; + } + root.queryBluezCodecState(device, (codecs, current) => { + callback(current || ""); + }); + }); } function getAvailableCodecs(device, callback) { @@ -377,215 +661,136 @@ Singleton { callback([], ""); return; } - if (!pactlChecked) { - whenPactlChecked(() => root.getAvailableCodecs(device, callback)); + + whenCodecBackendReady(() => { + if (!root.wpexecAvailable) { + if (root.dbusBridgeAvailable) { + root.queryBluezCodecState(device, callback); + return; + } + callback([], ""); + return; + } + + root.queryCardProfiles(device, (codecs, current) => { + if (codecs.length > 0 || !root.dbusBridgeAvailable) { + callback(codecs, current); + return; + } + root.queryBluezCodecState(device, (bluezCodecs, bluezCurrent) => { + callback(bluezCodecs, bluezCurrent || current); + }); + }); + }); + } + + function switchCodec(device, profileName, callback, codecDisplayName) { + if (!device || !isAudioDevice(device)) { + callback(false, "Invalid device"); return; } - if (!pactlAvailable) { + + whenCodecBackendReady(() => { + if (!root.wpexecAvailable) { + callback(false, I18n.tr("Codec switching is unavailable. WirePlumber wpexec was not found.")); + return; + } + + const cardName = root.getCardName(device); + codecSwitchProcess.cardName = cardName; + codecSwitchProcess.profile = profileName; + codecSwitchProcess.deviceAddress = device.address || ""; + codecSwitchProcess.expectedCodec = codecDisplayName || root.codecNameFromProfile(profileName); + codecSwitchProcess.callback = callback; + codecSwitchProcess.command = ["wpexec", root.cardProfileScript, JSON.stringify({ + "mode": "set", + "device": cardName, + "target": profileName + })]; + codecSwitchProcess.running = true; + }); + } + + function queryCardProfiles(device, callback) { + if (!device || !wpexecAvailable) { callback([], ""); return; } const 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; - } - if (!pactlChecked) { - whenPactlChecked(() => root.switchCodec(device, profileName, callback)); - return; - } - if (!pactlAvailable) { - callback(false, I18n.tr("Codec switching is unavailable because pactl was not found")); - return; - } - - const cardName = getCardName(device); - codecSwitchProcess.cardName = cardName; - codecSwitchProcess.profile = profileName; - codecSwitchProcess.callback = callback; - codecSwitchProcess.running = true; + codecListProcess.cardName = cardName; + codecListProcess.callback = callback; + codecListProcess.availableCodecs = []; + codecListProcess.detectedCodec = ""; + codecListProcess.command = ["wpexec", cardProfileScript, JSON.stringify({ + "mode": "list", + "device": cardName + })]; + codecListProcess.running = true; } Process { - id: detectPactlProcess + id: detectWpexecProcess running: false - command: ["sh", "-c", "command -v pactl"] + command: ["sh", "-c", "command -v wpexec"] onExited: function (exitCode) { - root.pactlAvailable = (exitCode === 0); - root.pactlChecked = true; - const actions = root.pendingPactlActions.slice(); - root.pendingPactlActions = []; + root.wpexecAvailable = (exitCode === 0); + root.wpexecChecked = true; + const actions = root.pendingCodecActions.slice(); + root.pendingCodecActions = []; actions.forEach(action => action()); } } Process { - id: codecQueryProcess + id: codecListProcess 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: (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; - } + command: ["wpexec", root.cardProfileScript, "{}"] stdout: SplitParser { splitMarker: "\n" onRead: data => { - let line = data.trim(); - - if (line.includes(`Name: ${codecQueryProcess.cardName}`)) { - codecQueryProcess.parsingTargetCard = true; + const line = data.trim(); + if (!line.startsWith("CODEC\t")) return; - } - - if (codecQueryProcess.parsingTargetCard && line.startsWith("Name: ") && !line.includes(codecQueryProcess.cardName)) { - codecQueryProcess.parsingTargetCard = false; + const parts = line.split("\t"); + if (parts.length < 5) 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 ([^\)]+)\)/i); - let codecName = codecMatch ? codecMatch[1].trim().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; - } - } - } + const codecName = parts[1]; + const profile = parts[2]; + const isCurrent = parts[4] === "1"; + const codecInfo = root.getCodecInfo(codecName); + if (!codecInfo) + return; + if (isCurrent) + codecListProcess.detectedCodec = codecInfo.name; + if (!codecListProcess.availableCodecs.some(c => c.profile === profile)) { + const next = codecListProcess.availableCodecs.slice(); + next.push({ + "name": codecInfo.name, + "profile": profile, + "description": codecInfo.description, + "qualityColor": codecInfo.qualityColor, + "category": root.codecCategory(codecInfo.name, profile) + }); + codecListProcess.availableCodecs = next; } } } - } - 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) { + onExited: function (exitCode) { + 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 ([^\)]+)\)/i); - let codecName = codecMatch ? codecMatch[1].trim().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 { @@ -593,26 +798,37 @@ Singleton { property string cardName: "" property string profile: "" + property string deviceAddress: "" + property string expectedCodec: "" property var callback: null + property bool sawOk: false - command: ["pactl", "set-card-profile", cardName, profile] + command: ["wpexec", root.cardProfileScript, "{}"] - onExited: function (exitCode, exitStatus) { - if (callback) { - callback(exitCode === 0, exitCode === 0 ? "Codec switched successfully" : "Failed to switch codec"); + stdout: SplitParser { + splitMarker: "\n" + onRead: data => { + const line = data.trim(); + if (!line.startsWith("OK\t")) + return; + codecSwitchProcess.sawOk = true; + const appliedProfile = line.split("\t")[1] || ""; + if (appliedProfile) + codecSwitchProcess.expectedCodec = root.codecNameFromProfile(appliedProfile) || codecSwitchProcess.expectedCodec; } + } - // 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)); - } - }); - } - } + onExited: function (exitCode) { + const success = exitCode === 0 && sawOk; + if (success && deviceAddress && expectedCodec) + root.updateDeviceCodec(deviceAddress, expectedCodec); + if (callback) + callback(success, success ? I18n.tr("Codec switched successfully") : I18n.tr("Failed to switch codec")); + + sawOk = false; + expectedCodec = ""; + deviceAddress = ""; callback = null; } } diff --git a/quickshell/scripts/bluez-card-profile.lua b/quickshell/scripts/bluez-card-profile.lua new file mode 100755 index 000000000..28a09460c --- /dev/null +++ b/quickshell/scripts/bluez-card-profile.lua @@ -0,0 +1,142 @@ +#!/usr/bin/wpexec +cutils = require("common-utils") + +local args = (...):parse() +local mode = args.mode or "list" +local dev_name = args.device or "" +local target = args.target or "" + +local function fail(msg) + print("ERROR: " .. msg) + Core.quit() +end + +if dev_name == "" then + fail("missing device") + return +end + +local om = ObjectManager { + Interest { + type = "device", + Constraint { "media.class", "=", "Audio/Device", type = "pw-global" }, + } +} + +local handled = false + +local function profile_available(profile) + return profile and profile.available ~= "no" +end + +local function codec_from_profile(profile) + if not profile then + return "" + end + local desc = profile.description or "" + local codec = desc:match("[Cc]odec%s+([%w%+%-%._]+)") + if not codec then + return "" + end + return codec:gsub("%-", "_"):upper() +end + +local function handle_device(device) + if handled then + return + end + local name = device.properties["device.name"] or "" + if name ~= dev_name then + return + end + handled = true + + if mode == "list" then + local active_index = nil + for p in device:iterate_params("Profile") do + local active = cutils.parseParam(p, "Profile") + if active then + active_index = active.index + end + end + + for p in device:iterate_params("EnumProfile") do + local profile = cutils.parseParam(p, "EnumProfile") + if profile_available(profile) then + local codec = codec_from_profile(profile) + if codec ~= "" then + local current = (active_index ~= nil and profile.index == active_index) and "1" or "0" + print(string.format("CODEC\t%s\t%s\t%s\t%s", codec, profile.name, profile.description or "", current)) + end + end + end + Core.quit() + return + end + + if mode == "set" then + if target == "" then + fail("missing target") + return + end + + local target_lower = tostring(target):lower() + local match = nil + for p in device:iterate_params("EnumProfile") do + local profile = cutils.parseParam(p, "EnumProfile") + if profile_available(profile) then + local pname = profile.name or "" + local codec = codec_from_profile(profile):lower() + if pname == target or pname:lower() == target_lower or codec == target_lower + or codec == target_lower:gsub("%-", "_") then + match = profile + break + end + end + end + + if not match then + fail("profile not found: " .. target) + return + end + + local param = Pod.Object { + "Spa:Pod:Object:Param:Profile", "Profile", + index = tonumber(match.index), + save = true, + } + device:set_param("Profile", param) + Core.sync(function() + print("OK\t" .. (match.name or target)) + Core.quit() + end) + return + end + + fail("unknown mode " .. mode) +end + +om:connect("object-added", function(_, device) + handle_device(device) +end) + +om:connect("installed", function() + if handled then + return + end + for device in om:iterate() do + handle_device(device) + if handled then + return + end + end + fail("device not found: " .. dev_name) +end) + +om:activate() +Core.timeout_add(3000, function() + if not handled then + fail("timed out waiting for device " .. dev_name) + end + return false +end)