diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index e1297f1aa7..69dc4bc236 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -796,6 +796,7 @@ "media-title": "Media players", "on-middle-clicked-description": "Command to execute when the button is middle-clicked.", "panel-applications-empty": "No applications are currently playing audio", + "bluetooth-profile-tooltip": "Bluetooth profile: {profile}", "spectrum-mirrored-description": "Mirror the spectrum so low frequencies are in the center. When off, low frequencies are on the left and high on the right.", "spectrum-mirrored-label": "Mirror spectrum", "title": "Audio", diff --git a/Modules/Panels/Audio/AudioPanel.qml b/Modules/Panels/Audio/AudioPanel.qml index 31e22809f2..571cd79ee4 100644 --- a/Modules/Panels/Audio/AudioPanel.qml +++ b/Modules/Panels/Audio/AudioPanel.qml @@ -728,17 +728,28 @@ SmartPanel { Repeater { model: AudioService.sinks - NRadioButton { - ButtonGroup.group: sinks + + RowLayout { required property PwNode modelData - pointSize: Style.fontSizeS - text: modelData.description - checked: AudioService.sink?.id === modelData.id - onClicked: { - AudioService.setAudioSink(modelData); - localOutputVolume = AudioService.volume; - } Layout.fillWidth: true + spacing: Style.marginS + + NRadioButton { + ButtonGroup.group: sinks + pointSize: Style.fontSizeS + text: parent.modelData.description + checked: AudioService.sink?.id === parent.modelData.id + onClicked: { + AudioService.setAudioSink(parent.modelData); + localOutputVolume = AudioService.volume; + } + Layout.fillWidth: true + } + + BluetoothProfileSelector { + device: parent.modelData + screen: root.screen + } } } } @@ -771,14 +782,25 @@ SmartPanel { Repeater { model: AudioService.sources - NRadioButton { - ButtonGroup.group: sources + + RowLayout { required property PwNode modelData - pointSize: Style.fontSizeS - text: modelData.description - checked: AudioService.source?.id === modelData.id - onClicked: AudioService.setAudioSource(modelData) Layout.fillWidth: true + spacing: Style.marginS + + NRadioButton { + ButtonGroup.group: sources + pointSize: Style.fontSizeS + text: parent.modelData.description + checked: AudioService.source?.id === parent.modelData.id + onClicked: AudioService.setAudioSource(parent.modelData) + Layout.fillWidth: true + } + + BluetoothProfileSelector { + device: parent.modelData + screen: root.screen + } } } } diff --git a/Modules/Panels/Audio/BluetoothProfileSelector.qml b/Modules/Panels/Audio/BluetoothProfileSelector.qml new file mode 100644 index 0000000000..8411f8b7b0 --- /dev/null +++ b/Modules/Panels/Audio/BluetoothProfileSelector.qml @@ -0,0 +1,107 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire +import qs.Commons +import qs.Services.Media +import qs.Services.UI +import qs.Widgets + +// Button that shows a dropdown menu for selecting Bluetooth audio profiles (A2DP, HSP/HFP, etc.) +// Located in: Audio Panel → Devices tab → next to Bluetooth audio devices +Item { + id: root + + property PwNode device: null + property ShellScreen screen: null + + // Reactivity trigger for profile cache updates + property int _cacheVersion: 0 + + readonly property string cardName: device ? AudioService.getBluetoothCardName(device) : "" + readonly property bool isBluetooth: cardName !== "" + readonly property var profileData: { + var _ = _cacheVersion; + return cardName ? AudioService.getCachedBluetoothProfiles(cardName) : null; + } + readonly property bool hasProfiles: profileData && profileData.profiles && profileData.profiles.length > 1 + + visible: isBluetooth && hasProfiles + implicitWidth: visible ? Style.toOdd(Style.baseWidgetSize * 0.7 * Style.uiScaleRatio) : 0 + implicitHeight: implicitWidth + + // Query profiles on load + Component.onCompleted: { + if (isBluetooth) { + AudioService.queryBluetoothProfiles(cardName); + } + } + + onCardNameChanged: { + if (cardName) { + AudioService.queryBluetoothProfiles(cardName); + } + } + + Connections { + target: AudioService + function onBluetoothProfilesChanged(changedCard) { + if (changedCard === root.cardName) { + root._cacheVersion++; + } + } + } + + // Context menu for profile selection - positioned to the right of the button + NPopupContextMenu { + id: profileMenu + screen: root.screen + positionHint: "left" // Position menu to the right of anchor (like left-bar behavior) + + model: { + if (!root.profileData || !root.profileData.profiles) + return []; + var active = root.profileData.activeProfile || ""; + return root.profileData.profiles.map(function (p) { + return { + label: p.displayName, + action: p.name, + icon: p.name === active ? "check" : undefined + }; + }); + } + + onTriggered: function (action, item) { + profileMenu.close(); + PanelService.closeContextMenu(root.screen); + if (action && root.cardName) { + AudioService.setBluetoothProfile(root.cardName, action); + } + } + } + + NIconButton { + anchors.fill: parent + icon: "bluetooth" + baseSize: parent.width + applyUiScale: false + tooltipText: { + if (!root.profileData) + return ""; + var active = root.profileData.activeProfile || ""; + var profiles = root.profileData.profiles || []; + var entry = profiles.find(function (p) { + return p.name === active; + }); + return I18n.tr("panels.audio.bluetooth-profile-tooltip", { + profile: entry ? entry.displayName : active + }); + } + + onClicked: { + if (root.hasProfiles) { + PanelService.showContextMenu(profileMenu, root, root.screen); + } + } + } +} diff --git a/Services/Media/AudioService.qml b/Services/Media/AudioService.qml index 8cc23e8bf7..db092a3631 100644 --- a/Services/Media/AudioService.qml +++ b/Services/Media/AudioService.qml @@ -994,4 +994,155 @@ Singleton { } Pipewire.preferredDefaultAudioSource = newSource; } + + // ===== Bluetooth Audio Profile Support ===== + // Provides profile switching for Bluetooth audio devices (A2DP, HSP/HFP, etc.) + // Location: Audio Panel → Devices tab → Bluetooth icon button next to BT devices + + property var _btProfileCache: ({}) + + signal bluetoothProfilesChanged(string cardName) + + // Extract Bluetooth card name from a PipeWire node (e.g., "bluez_card.XX_XX_XX_XX_XX_XX") + function getBluetoothCardName(node: PwNode): string { + if (!node?.properties) + return ""; + + const props = node.properties; + + // Direct card name (unlikely on sink/source nodes but check anyway) + const deviceName = props["device.name"] || ""; + if (deviceName.startsWith("bluez_card.")) + return deviceName; + + // Construct from Bluetooth MAC address (most reliable) + const address = props["api.bluez5.address"] || ""; + if (address) + return "bluez_card." + address.replace(/:/g, "_"); + + // Extract from node name (e.g., "bluez_output.40_58_99_49_42_0C.1") + const nodeName = node.name || ""; + const match = nodeName.match(/bluez_(?:output|input)\.([0-9A-Fa-f_]+)/); + if (match) + return "bluez_card." + match[1]; + + return ""; + } + + // Get cached profile data for a card (returns null if not cached) + function getCachedBluetoothProfiles(cardName: string): var { + return _btProfileCache[cardName] || null; + } + + // Query available profiles for a Bluetooth card + function queryBluetoothProfiles(cardName: string): void { + if (!cardName) + return; + btProfileQueryProcess.command = ["pactl", "list", "cards"]; + btProfileQueryProcess.running = true; + } + + // Set the active profile for a Bluetooth card + function setBluetoothProfile(cardName: string, profileName: string): void { + if (!cardName || !profileName) + return; + + btProfileSetProcess.command = ["pactl", "set-card-profile", cardName, profileName]; + btProfileSetProcess.running = true; + + // Optimistic cache update + if (_btProfileCache[cardName]) { + _btProfileCache[cardName].activeProfile = profileName; + bluetoothProfilesChanged(cardName); + } + } + + // Parse pactl output and update cache + function _parsePactlCards(output: string): void { + for (const block of output.split(/(?=Card #\d+)/)) { + if (!block.includes("bluez")) + continue; + + const nameMatch = block.match(/Name:\s*(\S+)/); + if (!nameMatch) + continue; + const cardName = nameMatch[1]; + + const profilesMatch = block.match(/Profiles:\n([\s\S]*?)(?=\n\tActive Profile:|\n\tPorts:|\nCard #|$)/); + if (!profilesMatch) + continue; + + const profiles = []; + for (const line of profilesMatch[1].split("\n")) { + const m = line.match(/^\s+([a-zA-Z0-9_-]+):\s+(.+?)\s+\(sinks:/); + if (!m || m[1] === "off") + continue; + + const name = m[1]; + const desc = m[2]; + + // Format display name: "A2DP (AAC)" or "HSP/HFP (MSBC)" + let displayName = desc; + const codecMatch = desc.match(/codec\s+(\S+)\)?/i); + const codec = codecMatch ? ` (${codecMatch[1].replace(/\)$/, "")})` : ""; + + if (desc.includes("A2DP")) + displayName = "A2DP" + codec; + else if (desc.includes("HSP") || desc.includes("HFP")) + displayName = "HSP/HFP" + codec; + + profiles.push({ + name, + description: desc, + displayName + }); + } + + const activeMatch = block.match(/Active Profile:\s*(\S+)/); + if (profiles.length > 0) { + _btProfileCache[cardName] = { + profiles, + activeProfile: activeMatch?.[1] || "" + }; + bluetoothProfilesChanged(cardName); + } + } + } + + Process { + id: btProfileQueryProcess + running: false + onExited: code => { + if (code === 0) + root._parsePactlCards(stdout.text); + } + stdout: StdioCollector {} + } + + Process { + id: btProfileSetProcess + running: false + onExited: code => { + if (code !== 0) { + // Refresh to get actual state on failure + btProfileQueryProcess.command = ["pactl", "list", "cards"]; + btProfileQueryProcess.running = true; + } + } + } + + // Auto-query profiles when a Bluetooth device becomes active + Connections { + target: root + function onSinkChanged() { + const card = root.sink ? root.getBluetoothCardName(root.sink) : ""; + if (card) + root.queryBluetoothProfiles(card); + } + function onSourceChanged() { + const card = root.source ? root.getBluetoothCardName(root.source) : ""; + if (card) + root.queryBluetoothProfiles(card); + } + } } diff --git a/Widgets/NPopupContextMenu.qml b/Widgets/NPopupContextMenu.qml index cb1cde8107..89c2800999 100644 --- a/Widgets/NPopupContextMenu.qml +++ b/Widgets/NPopupContextMenu.qml @@ -27,8 +27,13 @@ PopupWindow { property real targetWidth: 0 property real targetHeight: 0 + // Optional position hint override: "left", "right", "top", "bottom", or "" (auto) + // When set, overrides barPosition for positioning logic + property string positionHint: "" + readonly property string barPosition: Settings.getBarPositionForScreen(screen?.name) readonly property real barHeight: Style.getBarHeightForScreen(screen?.name) + readonly property string effectivePosition: positionHint !== "" ? positionHint : barPosition signal triggered(string action, var item) @@ -96,13 +101,13 @@ PopupWindow { const targetGlobalX = anchorGlobalPos.x + targetOffsetX; // For right bar: position menu to the left of target - if (root.barPosition === "right") { + if (root.effectivePosition === "right") { let baseX = targetOffsetX - implicitWidth - Style.marginM; return baseX; } // For left bar: position menu to the right of target - if (root.barPosition === "left") { + if (root.effectivePosition === "left") { let baseX = targetOffsetX + effectiveWidth + Style.marginM; return baseX; } @@ -153,10 +158,10 @@ PopupWindow { // Calculate base Y position based on bar orientation let baseY; - if (root.barPosition === "bottom") { + if (root.effectivePosition === "bottom") { // For bottom bar: position menu above the bar baseY = -(implicitHeight + Style.marginS); - } else if (root.barPosition === "top") { + } else if (root.effectivePosition === "top") { // For top bar: position menu below bar at consistent height // Compensate for anchor's Y position to ensure menu top is always at (barHeight + margin) baseY = barHeight + Style.marginS - anchorGlobalPos.y; @@ -171,10 +176,10 @@ PopupWindow { // Define clipping boundaries based on bar position const topLimit = Style.marginM; - const bottomLimit = root.barPosition === "bottom" ? screen.height - barHeight - Style.marginS : screen.height - Style.marginM; + const bottomLimit = root.effectivePosition === "bottom" ? screen.height - barHeight - Style.marginS : screen.height - Style.marginM; // Adjust if menu would clip at top (skip for bottom bar - don't push menu down over bar) - if (menuScreenY < topLimit && root.barPosition !== "bottom") { + if (menuScreenY < topLimit && root.effectivePosition !== "bottom") { const adjustment = topLimit - menuScreenY; return baseY + adjustment; } @@ -189,7 +194,7 @@ PopupWindow { } // Fallback if no screen - if (root.barPosition === "bottom") { + if (root.effectivePosition === "bottom") { return -implicitHeight - Style.marginS; } return barHeight; diff --git a/flake.lock b/flake.lock index 07e33b07fa..1dd85ba3a7 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1774709303, - "narHash": "sha256-D3Q07BbIA2KnTcSXIqqu9P586uWxN74zNoCH3h2ESHg=", + "lastModified": 1776548001, + "narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=", "owner": "nixos", "repo": "nixpkgs", - "rev": "8110df5ad7abf5d4c0f6fb0f8f978390e77f9685", + "rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc", "type": "github" }, "original": { @@ -25,11 +25,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1774902752, - "narHash": "sha256-WC3SgVJX+N78KnRf1v9Z2VowkJBc9SBKpaZsWxWm/Rs=", + "lastModified": 1776585574, + "narHash": "sha256-j35EWhKoGhKrfcXcAOpoRVgXEPQt41Eukji/h59cnjk=", "owner": "noctalia-dev", "repo": "noctalia-qs", - "rev": "4f0ceff244748ec55cfccc4f674759a7a2941b18", + "rev": "75d180c28a9ab4470e980f3d6f706ad6c5213add", "type": "github" }, "original": { @@ -67,11 +67,11 @@ ] }, "locked": { - "lastModified": 1773297127, - "narHash": "sha256-6E/yhXP7Oy/NbXtf1ktzmU8SdVqJQ09HC/48ebEGBpk=", + "lastModified": 1775636079, + "narHash": "sha256-pc20NRoMdiar8oPQceQT47UUZMBTiMdUuWrYu2obUP0=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "71b125cd05fbfd78cab3e070b73544abe24c5016", + "rev": "790751ff7fd3801feeaf96d7dc416a8d581265ba", "type": "github" }, "original": {