Skip to content
Open
1 change: 1 addition & 0 deletions Assets/Translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
52 changes: 37 additions & 15 deletions Modules/Panels/Audio/AudioPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
Expand Down Expand Up @@ -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
}
}
}
}
Expand Down
107 changes: 107 additions & 0 deletions Modules/Panels/Audio/BluetoothProfileSelector.qml
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
}
151 changes: 151 additions & 0 deletions Services/Media/AudioService.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Loading