fix(audio): Linux output device selection and watcher (#176)

* fix(audio): stabilize Linux output device picker and watcher

Keep pinned ALSA/cpal device ids stable when enumeration omits the active
sink or returns an equivalent name. Stop Linux device-watcher from clearing
the pin based on missing list entries; macOS and Windows still treat repeated
absence as unplugged. Settings refresh flow calls canonicalize and refetches
the list; add i18n for the out-of-list device label.

* fix(settings): sort audio output devices by label

cpal enumeration order is arbitrary; order the dropdown by readable label
and place the current OS default device first among concrete outputs.
This commit is contained in:
cucadmuh
2026-04-13 22:48:21 +03:00
committed by GitHub
parent 9983d13122
commit 4207455440
11 changed files with 167 additions and 25 deletions
+44 -4
View File
@@ -300,17 +300,44 @@ function disambiguatedAudioDeviceLabel(raw: string, baseLabel: string, duplicate
return `${baseLabel} · ${audioDeviceDuplicateHint(raw)}`;
}
/** cpal order is arbitrary; sort by readable label, current OS default first. */
function sortAudioDeviceIds(devices: string[], osDefaultDeviceId: string | null): string[] {
return [...devices].sort((a, b) => {
const aDef = osDefaultDeviceId && a === osDefaultDeviceId;
const bDef = osDefaultDeviceId && b === osDefaultDeviceId;
if (aDef !== bDef) return aDef ? -1 : 1;
const la = formatAudioDeviceLabel(a);
const lb = formatAudioDeviceLabel(b);
const byLabel = la.localeCompare(lb, undefined, { sensitivity: 'base' });
if (byLabel !== 0) return byLabel;
return a.localeCompare(b);
});
}
function buildAudioDeviceSelectOptions(
devices: string[],
defaultLabel: string,
osDefaultDeviceId: string | null,
osDefaultMark: string,
pinnedDevice: string | null,
notInListSuffix: string,
): { value: string; label: string }[] {
const baseLabels = devices.map(formatAudioDeviceLabel);
const countByBase = new Map<string, number>();
for (const b of baseLabels) countByBase.set(b, (countByBase.get(b) ?? 0) + 1);
const pinned = pinnedDevice?.trim() || null;
const pinnedNotListed = !!(pinned && !devices.includes(pinned));
const ghost: { value: string; label: string }[] = pinnedNotListed
? (() => {
const base = formatAudioDeviceLabel(pinned);
let label = `${base} · ${notInListSuffix}`;
if (osDefaultDeviceId && pinned === osDefaultDeviceId) label = `${label} · ${osDefaultMark}`;
return [{ value: pinned, label }];
})()
: [];
return [
{ value: '', label: defaultLabel },
...ghost,
...devices.map((d, i) => {
const base = baseLabels[i];
const dup = (countByBase.get(base) ?? 0) > 1;
@@ -390,9 +417,20 @@ export default function Settings() {
});
const defP = invoke<string | null>('audio_default_output_device_name').catch(() => null);
Promise.all([listP, defP])
.then(([devices, osDefault]) => {
setAudioDevices(devices);
setOsDefaultAudioDeviceId(osDefault ?? null);
.then(async ([devices, osDefault]) => {
let canon: string | null = null;
try {
canon = await invoke<string | null>('audio_canonicalize_selected_device');
if (canon) useAuthStore.getState().setAudioOutputDevice(canon);
} catch {
/* ignore */
}
const finalList = canon
? await invoke<string[]>('audio_list_devices').catch(() => devices)
: devices;
const defId = osDefault ?? null;
setAudioDevices(sortAudioDeviceIds(finalList, defId));
setOsDefaultAudioDeviceId(defId);
})
.finally(() => {
if (!silent) setDevicesLoading(false);
@@ -671,11 +709,13 @@ export default function Settings() {
t('settings.audioOutputDeviceDefault'),
osDefaultAudioDeviceId,
t('settings.audioOutputDeviceOsDefaultNow'),
auth.audioOutputDevice,
t('settings.audioOutputDeviceNotInCurrentList'),
)}
/>
<button
className="icon-btn"
onClick={refreshAudioDevices}
onClick={() => refreshAudioDevices()}
disabled={devicesLoading || deviceSwitching}
data-tooltip={t('settings.audioOutputDeviceRefresh')}
>