Files
lingdianshiren 300544bc8c bluetooth: identify codecs from the profile description (#3643)
* bluetooth: identify codecs from the profile description

PipeWire describes profiles in the daemon's locale and only names the codec in
that description for the bare profile (LHDC settles on "a2dp-sink"), so the old
English "codec %s" match dropped LHDC profiles on non-English systems. Parsing
the translated shape instead only moves the coupling to word order and
punctuation.

Emit the profile index and raw description from the lua script and let the UI
name the codec by matching the untranslated literal from one codec table.
Switch by index, falling back to the profile name or codec literal, so no
translated text is parsed. Also teach the BlueZ fallback the Savitech ids
(LHDC v5 and v3).

Test: quickshell/tests/bluez-codec.test.mjs

* bluetooth: show unknown codec without masking bluez

* bluetooth: ignore stale codec query results
2026-10-02 19:07:28 -04:00

153 lines
5.1 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Modules.ControlCenter.Widgets
import qs.Services
CcSheetDialog {
id: root
property var device: null
property var availableCodecs: []
property string currentCodec: ""
property int currentIndex: -1
property bool isLoading: false
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)
readonly property bool splitSections: mediaCodecs.length > 0 && callCodecs.length > 0
signal codecSelected(string deviceAddress, string codecName)
iconName: device ? BluetoothService.getDeviceIcon(device) : "headset"
title: device ? (device.name || device.deviceName) : ""
subtitle: I18n.tr("Audio Codec Selection")
statusText: {
if (isLoading)
return I18n.tr("Loading codecs...");
if (statusMessage.length > 0)
return statusMessage;
return I18n.tr("Current: %1").arg(currentCodec);
}
statusColor: statusIsError ? Theme.error : (isLoading ? Theme.primary : Theme.surfaceVariantText)
function show(bluetoothDevice) {
if (!bluetoothDevice?.connected || !BluetoothService.isAudioDevice(bluetoothDevice))
return;
device = bluetoothDevice;
isLoading = true;
availableCodecs = [];
currentCodec = "";
currentIndex = -1;
statusMessage = "";
statusIsError = false;
queryCodecs();
present();
}
function queryCodecs() {
if (!deviceValid) {
dismiss();
return;
}
const capturedDevice = device;
const capturedAddress = device.address;
BluetoothService.getAvailableCodecs(capturedDevice, (codecs, current, index) => {
if (!root.deviceValid || root.device?.address !== capturedAddress)
return;
availableCodecs = codecs;
currentCodec = current;
currentIndex = index ?? -1;
isLoading = false;
if (BluetoothService.wpexecChecked && !BluetoothService.wpexecAvailable && !BluetoothService.dbusBridgeAvailable) {
statusMessage = I18n.tr("Codec switching is unavailable. WirePlumber wpexec was not found.", "bluetooth codec selector error, wpexec is a program name");
statusIsError = true;
return;
}
statusMessage = codecs.length === 0 ? I18n.tr("No codecs found") : "";
statusIsError = false;
});
}
function selectCodec(selectedCodec) {
if (!deviceValid || isLoading || !selectedCodec)
return;
const capturedDevice = device;
const capturedAddress = device.address;
BluetoothService.invalidateCodecQuery(capturedAddress);
BluetoothService.updateDeviceCodec(capturedAddress, selectedCodec.name);
codecSelected(capturedAddress, selectedCodec.name);
isLoading = true;
BluetoothService.switchCodec(capturedDevice, selectedCodec.profile, (success, message) => {
if (!root.device || root.device.address !== capturedAddress)
return;
isLoading = false;
if (!success) {
BluetoothService.refreshDeviceCodec(capturedDevice);
ToastService.showToast(message, ToastService.levelError);
return;
}
BluetoothService.updateDeviceCodec(capturedAddress, selectedCodec.name);
codecSelected(capturedAddress, selectedCodec.name);
ToastService.showToast(message, ToastService.levelInfo);
root.dismiss();
}, selectedCodec.name, selectedCodec.index, selectedCodec.codec);
}
onDeviceValidChanged: {
if (shown && !deviceValid)
dismiss();
}
onDismissed: device = null
component CodecRow: CcListRow {
required property var modelData
title: modelData.name
subtitle: modelData.description
active: root.currentIndex >= 0 ? modelData.index === root.currentIndex : modelData.name === root.currentCodec
enabled: !root.isLoading
clickable: !active
onClicked: root.selectCodec(modelData)
leading: CcStatusDot {
color: modelData.qualityColor
}
}
CcSectionLabel {
text: I18n.tr("Media")
visible: root.splitSections && !root.isLoading
}
CcGroup {
visible: !root.isLoading && root.mediaCodecs.length > 0
Repeater {
model: root.mediaCodecs
CodecRow {}
}
}
CcSectionLabel {
text: I18n.tr("Calls / Headset")
visible: root.callCodecs.length > 0 && !root.isLoading
}
CcGroup {
visible: !root.isLoading && root.callCodecs.length > 0
Repeater {
model: root.callCodecs
CodecRow {}
}
}
}