fix(windows): startup hang, stable device IDs, boot barrel guards (#1277)

This commit is contained in:
cucadmuh
2026-07-12 21:28:58 +03:00
committed by GitHub
parent 097438f9da
commit 54bc9814f1
37 changed files with 721 additions and 3088 deletions
File diff suppressed because it is too large Load Diff
+8
View File
@@ -269,6 +269,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Covers that hit the gate during the brief window before headers were registered got a `403`, which was treated as "cover missing" and written a 30-minute do-not-retry marker — so on a gated server most covers stayed blank long after the gate started answering. A gate-style `403`/`401` on cover art is now treated as a recoverable hiccup (retried) rather than a permanent miss, and reconnecting a gated server clears those stale markers and re-runs the cover fill so the artwork loads.
* For a server with both a LAN and a public address, whichever answered first after launch stuck for the whole session: if the app started off the LAN it pinned to the public address and never switched back to LAN once you got home (the public address kept answering, so the LAN address was never re-checked). The reachability tick now re-checks the LAN address first with a single attempt, so returning to the LAN upgrades the connection back to local automatically, while staying remote costs just that one probe rather than the full retry wait. The LAN/public badge also refreshes immediately when you switch the active server, instead of staying on the previous server's classification until the next poll.
### Windows — startup hang and audio output after per-device EQ (#1274)
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1277](https://github.com/Psychotoxical/psysonic/pull/1277)**
* Windows release builds no longer freeze on the loading splash after the per-device EQ changes in #1274 — boot-time imports no longer pull lucide into the auth-store chunk (circular init left `createLucideIcon` undefined).
* Restores the lightweight cpal default-output path on Windows so startup no longer blocks on full device enumeration.
* Audio output devices on Windows and macOS now use stable backend IDs (`Wasapi:…`) with clearer labels; duplicate friendly names are disambiguated, device-change detection works again, and legacy pinned device / per-device EQ keys stored as plain names are matched after upgrade.
## [1.49.0] - 2026-06-29
+4 -1
View File
@@ -14,7 +14,10 @@
"lint": "eslint -c eslint.config.mjs src",
"lint:gradual": "eslint -c eslint.config.gradual.mjs src",
"dep:check": "depcruise src --config .dependency-cruiser.cjs --ignore-known",
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs && npm run check:css-imports",
"dep:check:barrels": "node scripts/check-feature-barrel-ui.mjs",
"check:boot-chunks": "node scripts/check-boot-chunk-lucide.mjs",
"build:verify": "npm run build && npm run check:boot-chunks",
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs && npm run check:css-imports && npm run dep:check:barrels",
"test:watch": "vitest",
"test:coverage": "npm run prebuild:release-notes && vitest run --coverage && npm run check:css-imports"
},
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
/**
* Post-build guard: boot-adjacent Vite chunks must not bundle lucide-react.
*
* When a store/utils barrel accidentally re-exports UI, Rollup may pull
* createLucideIcon into authStore/offline chunks and hit TDZ init-order bugs
* in production (Windows WebView2: "X is not a function" on splash).
*
* Run after `npm run build` — no Tauri compile needed.
*/
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const DIST = join(new URL('..', import.meta.url).pathname, 'dist/assets');
/** Chunk filename prefixes that must stay lucide-free. */
const BOOT_CHUNK_PREFIXES = ['authStore-', 'offline-'];
/** Minified bundles still embed lucide module paths or icon factory calls. */
const LUCIDE_SIGNALS = [
'lucide-react',
'createLucideIcon',
// Common preset/offline icons pulled through bad barrels (minified arg strings):
'("globe"',
'("settings"',
'("wifi-off"',
'("download"',
];
let files;
try {
files = readdirSync(DIST).filter(f => f.endsWith('.js'));
} catch {
console.error('check-boot-chunk-lucide: dist/assets not found — run `npm run build` first.');
process.exit(1);
}
const violations = [];
for (const file of files) {
if (!BOOT_CHUNK_PREFIXES.some(p => file.startsWith(p))) continue;
const text = readFileSync(join(DIST, file), 'utf8');
for (const signal of LUCIDE_SIGNALS) {
if (text.includes(signal)) {
violations.push({ file, signal });
}
}
}
if (violations.length > 0) {
console.error('Lucide leaked into boot-critical chunks:\n');
for (const { file, signal } of violations) {
console.error(` • dist/assets/${file} — matched "${signal}"`);
}
console.error(
'\nFix: split UI from the feature root barrel; import UI from @/features/<x>/ui only in components.',
);
process.exit(1);
}
console.log('check-boot-chunk-lucide: ok');
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* Guard: boot-critical feature barrels must not re-export UI modules.
*
* Re-exporting components/hooks that pull lucide-react through a barrel while
* the same barrel (or its stores) is imported from boot paths creates production
* init-order failures (`createLucideIcon is not a function` in minified chunks).
*
* Not every feature barrel is checked — album/artist/etc. export UI for lazy
* routes and are safe. Only barrels that stores/utils on the boot path import
* from are listed in BOOT_CRITICAL_BARRELS.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
const ROOT = new URL('..', import.meta.url).pathname;
/** Barrels whose non-UI surface is imported before or during first paint. */
const BOOT_CRITICAL_BARRELS = [
{ file: join(ROOT, 'src/features/offline/index.ts'), label: 'src/features/offline/index.ts' },
{ file: join(ROOT, 'src/music-network/index.ts'), label: 'src/music-network/index.ts' },
];
const FORBIDDEN_EXPORT_PATTERNS = [
/from\s+['"]\.\/components\//,
/from\s+['"]\.\/ui\//,
/export\s+\*\s+from\s+['"]\.\/components\//,
/export\s+\*\s+from\s+['"]\.\/ui\//,
/export\s+\{[^}]*\}\s+from\s+['"]\.\/components\//,
/export\s+\{[^}]*\}\s+from\s+['"]\.\/ui\//,
];
/** @param {string} file @param {string} label */
function checkBarrel(file, label) {
const text = readFileSync(file, 'utf8');
const hits = [];
for (const re of FORBIDDEN_EXPORT_PATTERNS) {
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
if (re.test(line)) hits.push(trimmed);
}
}
if (hits.length === 0) return [];
return hits.map(h => `${label}: ${h}`);
}
const errors = [];
for (const { file, label } of BOOT_CRITICAL_BARRELS) {
try {
statSync(file);
errors.push(...checkBarrel(file, label));
} catch {
errors.push(`${label}: missing barrel file`);
}
}
if (errors.length > 0) {
console.error('Boot-critical barrel UI re-export violations:\n');
for (const e of errors) console.error(`${e}`);
console.error(
'\nFix: remove UI exports from the root barrel; import from @/features/<x>/ui or deep paths.',
);
process.exit(1);
}
console.log('check-feature-barrel-ui: ok');
+237 -21
View File
@@ -27,19 +27,169 @@ pub(crate) fn with_suppressed_alsa_stderr<R>(f: impl FnOnce() -> R) -> R {
}
pub(crate) fn enumerate_output_device_names() -> Vec<String> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
with_suppressed_alsa_stderr(|| {
enumerate_output_device_entries()
.into_iter()
.map(|e| e.key)
.collect()
}
/// Stable key + human label for the settings dropdown.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OutputDeviceEntry {
pub key: String,
pub label: String,
}
pub(crate) fn enumerate_output_device_entries() -> Vec<OutputDeviceEntry> {
use rodio::cpal::traits::HostTrait;
let mut out = with_suppressed_alsa_stderr(|| {
let host = rodio::cpal::default_host();
host.output_devices()
.map(|iter| {
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
.collect()
iter.filter_map(|d| {
let key = output_device_stable_key(&d);
if key.is_empty() {
return None;
}
Some(OutputDeviceEntry {
label: output_device_display_label(&d),
key,
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default()
});
dedupe_output_device_entries(&mut out);
out
}
fn dedupe_output_device_entries(entries: &mut Vec<OutputDeviceEntry>) {
let mut seen = std::collections::HashSet::new();
entries.retain(|e| seen.insert(e.key.clone()));
}
/// Stable per-device key for Settings / EQ maps. Linux keeps ALSA-style description
/// names; Windows/macOS use cpal [`DeviceId`] so same-named endpoints stay distinct
/// and default-device changes are observable by the watcher.
pub(crate) fn output_device_stable_key(device: &impl rodio::cpal::traits::DeviceTrait) -> String {
#[cfg(not(target_os = "linux"))]
{
if let Ok(id) = device.id() {
return id.to_string();
}
}
device
.description()
.ok()
.map(|d| d.name().to_string())
.unwrap_or_else(|| device.id().map(|i| i.to_string()).unwrap_or_default())
}
/// Human-readable label for the settings dropdown (not the stored key).
pub(crate) fn output_device_display_label(
device: &impl rodio::cpal::traits::DeviceTrait,
) -> String {
match device.description() {
Ok(desc) => format_output_device_label(&desc),
Err(_) => output_device_stable_key(device),
}
}
pub(crate) fn format_output_device_label(desc: &rodio::cpal::DeviceDescription) -> String {
use rodio::cpal::{DeviceType, InterfaceType};
let name = desc.name();
let mut parts: Vec<String> = vec![name.to_string()];
if let Some(mfr) = desc.manufacturer() {
if mfr != name && !name.contains(mfr) {
parts.push(mfr.to_string());
}
}
if let Some(driver) = desc.driver() {
if driver != name && !parts.iter().any(|p| p.contains(driver)) {
parts.push(driver.to_string());
}
}
if parts.len() == 1 {
let iface = desc.interface_type();
if iface != InterfaceType::Unknown && iface != InterfaceType::BuiltIn {
parts.push(iface.to_string());
} else {
let dtype = desc.device_type();
if dtype != DeviceType::Unknown && dtype != DeviceType::Speaker {
parts.push(dtype.to_string());
}
}
}
parts.join(" · ")
}
/// Best-effort label when a legacy plain-name pin is kept off the current list.
pub(crate) fn legacy_output_device_display_label(key: &str) -> String {
#[cfg(not(target_os = "linux"))]
{
use rodio::cpal::traits::HostTrait;
if let Ok(id) = key.parse::<rodio::cpal::DeviceId>() {
if let Some(device) = rodio::cpal::default_host().device_by_id(&id) {
return output_device_display_label(&device);
}
}
}
key.to_string()
}
/// Upgrade a preDeviceId persisted pin to the current stable key when unambiguous.
pub(crate) fn resolve_legacy_pinned_key(
pinned: &str,
entries: &[OutputDeviceEntry],
) -> Option<String> {
if entries.iter().any(|e| e.key == pinned) {
return Some(pinned.to_string());
}
let logic_matches: Vec<_> = entries
.iter()
.filter(|e| output_devices_logically_same(&e.key, pinned))
.collect();
if logic_matches.len() == 1 {
return Some(logic_matches[0].key.clone());
}
#[cfg(not(target_os = "linux"))]
{
let label_matches: Vec<_> = entries
.iter()
.filter(|e| e.label == pinned || e.label.starts_with(&format!("{pinned} · ")))
.collect();
if label_matches.len() == 1 {
return Some(label_matches[0].key.clone());
}
}
None
}
/// Resolve a stored device key to a cpal device (DeviceId on Windows/macOS, name on Linux).
pub(crate) fn resolve_output_device(
device_key: &str,
) -> Option<rodio::cpal::Device> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
use std::str::FromStr;
let host = rodio::cpal::default_host();
if let Ok(id) = rodio::cpal::DeviceId::from_str(device_key) {
if let Some(device) = host.device_by_id(&id) {
return Some(device);
}
}
host.output_devices().ok()?.find(|d| {
output_device_stable_key(d) == device_key
|| d.description()
.ok()
.map(|desc| desc.name().to_string())
.as_deref()
== Some(device_key)
})
}
/// cpal/rodio aliases for "follow the OS default" — not a stable per-device key.
#[cfg(target_os = "linux")]
pub(crate) fn is_generic_default_output_alias(name: &str) -> bool {
matches!(
name,
@@ -50,21 +200,23 @@ pub(crate) fn is_generic_default_output_alias(name: &str) -> bool {
)
}
fn raw_cpal_default_output_device_name() -> Option<String> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
fn raw_cpal_default_output_device_key() -> Option<String> {
use rodio::cpal::traits::HostTrait;
with_suppressed_alsa_stderr(|| {
let host = rodio::cpal::default_host();
host.default_output_device()
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
rodio::cpal::default_host()
.default_output_device()
.map(|d| output_device_stable_key(&d))
})
}
#[cfg(target_os = "linux")]
fn pick_listed_device_name(candidate: &str, list: &[String]) -> Option<String> {
list.iter()
.find(|d| d.as_str() == candidate || output_devices_logically_same(d, candidate))
.cloned()
}
#[cfg(target_os = "linux")]
fn equivalent_list_entries(name: &str, list: &[String]) -> Vec<String> {
let mut out: Vec<String> = list
.iter()
@@ -83,6 +235,7 @@ fn equivalent_list_entries(name: &str, list: &[String]) -> Vec<String> {
}
/// True when two device keys refer to the same sink (exact, ALSA logical, or via list canon).
#[cfg(target_os = "linux")]
pub(crate) fn output_device_keys_equivalent(a: &str, b: &str, list: &[String]) -> bool {
if a == b || output_devices_logically_same(a, b) {
return true;
@@ -97,6 +250,7 @@ pub(crate) fn output_device_keys_equivalent(a: &str, b: &str, list: &[String]) -
}
/// Match wpctl/cpal `"CARD, PCM"` labels to ALSA `iface:CARD=…` picker ids.
#[cfg(target_os = "linux")]
fn comma_and_alsa_device_equivalent(a: &str, b: &str) -> bool {
let (comma, alsa) = if linux_alsa_sink_fingerprint(a).is_some() {
(b, a)
@@ -138,11 +292,13 @@ fn comma_and_alsa_device_equivalent(a: &str, b: &str) -> bool {
}
/// Build the cpal-style `"CARD, PCM name"` label PipeWire exposes for ALSA sinks.
#[cfg(target_os = "linux")]
pub(crate) fn cpal_name_from_pipewire_alsa(card: &str, alsa_name: &str) -> String {
format!("{card}, {alsa_name}")
}
/// Read `node.driver-id` from `wpctl inspect` output (PipeWire stream → sink link).
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_inspect_driver_id(inspect: &str) -> Option<u32> {
for line in inspect.lines() {
let line = line.trim().trim_start_matches('*').trim();
@@ -155,6 +311,7 @@ pub(crate) fn parse_wpctl_inspect_driver_id(inspect: &str) -> Option<u32> {
/// Collect PipeWire ALSA `[psysonic]` stream node ids that have at least one
/// active playback link in `wpctl status` (ignores stale / idle nodes).
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_status_psysonic_stream_ids(status: &str) -> Vec<u32> {
let mut in_audio_streams = false;
let mut ids = Vec::new();
@@ -230,12 +387,8 @@ pub(crate) fn linux_psysonic_stream_routes_to_default_sink() -> bool {
stream_ids.iter().any(|&id| linux_wpctl_inspect_driver_id(id) == Some(default_id))
}
#[cfg(not(target_os = "linux"))]
pub(crate) fn linux_psysonic_stream_routes_to_default_sink() -> bool {
false
}
/// Parse `wpctl list audio sinks` and return the id of the default sink (trailing `*`).
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_list_default_sink_id(listing: &str) -> Option<u32> {
for line in listing.lines() {
let line = line.trim_end();
@@ -249,6 +402,7 @@ pub(crate) fn parse_wpctl_list_default_sink_id(listing: &str) -> Option<u32> {
}
/// Parse `wpctl status` and return the id of the default sink (line marked with `*`).
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_default_sink_id(status: &str) -> Option<u32> {
let mut in_sinks = false;
for line in status.lines() {
@@ -273,6 +427,7 @@ pub(crate) fn parse_wpctl_default_sink_id(status: &str) -> Option<u32> {
}
/// Read `api.alsa.card.name` + `alsa.name` from `wpctl inspect` output.
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_inspect_alsa_names(inspect: &str) -> Option<(String, String)> {
let mut card: Option<String> = None;
let mut pcm: Option<String> = None;
@@ -319,6 +474,7 @@ fn linux_wpctl_default_sink_id() -> Option<u32> {
}
/// Read `node.description` from `wpctl inspect` (Bluetooth and other non-ALSA sinks).
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_inspect_node_description(inspect: &str) -> Option<String> {
for line in inspect.lines() {
let line = line.trim().trim_start_matches('*').trim();
@@ -365,20 +521,29 @@ pub(crate) fn effective_default_output_device_name_for_poll() -> Option<String>
}
fn resolve_effective_default_output_device_name(enumerate_devices: bool) -> Option<String> {
// Windows/macOS: single cpal default query (pre-#1274). Full `output_devices()`
// enumeration contends with WASAPI/CoreAudio and is only needed for Linux/PipeWire
// default resolution + ALSA logical key matching.
#[cfg(not(target_os = "linux"))]
{
let _ = enumerate_devices;
return raw_cpal_default_output_device_key();
}
#[cfg(target_os = "linux")]
{
let list = if enumerate_devices {
enumerate_output_device_names()
} else {
Vec::new()
};
#[cfg(target_os = "linux")]
if let Some(resolved) = linux_resolve_default_via_pipewire(&list) {
return Some(resolved);
}
#[cfg(target_os = "linux")]
if !enumerate_devices {
// wpctl unavailable — last-resort cpal (skip generic/stale placeholder names).
if linux_wpctl_default_sink_id().is_none() {
if let Some(raw) = raw_cpal_default_output_device_name() {
if let Some(raw) = raw_cpal_default_output_device_key() {
if !is_generic_default_output_alias(&raw) {
return Some(raw);
}
@@ -386,17 +551,15 @@ fn resolve_effective_default_output_device_name(enumerate_devices: bool) -> Opti
}
return None;
}
let raw = raw_cpal_default_output_device_name();
let raw = raw_cpal_default_output_device_key();
if let Some(ref name) = raw {
if !is_generic_default_output_alias(name) {
if enumerate_devices {
return pick_listed_device_name(name, &list).or_else(|| Some(name.clone()));
}
return Some(name.clone());
}
}
raw
}
}
/// Linux ALSA-style cpal names: same physical sink can appear with different suffixes;
/// busy devices are sometimes omitted from `output_devices()` while playback works.
@@ -431,6 +594,20 @@ pub(crate) fn output_devices_logically_same(a: &str, b: &str) -> bool {
if a == b {
return true;
}
#[cfg(not(target_os = "linux"))]
{
if let (Ok(ida), Ok(idb)) = (
a.parse::<rodio::cpal::DeviceId>(),
b.parse::<rodio::cpal::DeviceId>(),
) {
return ida.1 == idb.1;
}
if legacy_description_key_matches_device_id(a, b)
|| legacy_description_key_matches_device_id(b, a)
{
return true;
}
}
match (
linux_alsa_sink_fingerprint(a),
linux_alsa_sink_fingerprint(b),
@@ -440,7 +617,32 @@ pub(crate) fn output_devices_logically_same(a: &str, b: &str) -> bool {
}
}
/// PreDeviceId persisted pins (description names) vs cpal `DeviceId` enumeration keys.
#[cfg(not(target_os = "linux"))]
fn legacy_description_key_matches_device_id(legacy: &str, device_id_key: &str) -> bool {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
use std::str::FromStr;
if legacy.parse::<rodio::cpal::DeviceId>().is_ok() {
return false;
}
let Ok(id) = rodio::cpal::DeviceId::from_str(device_id_key) else {
return legacy == device_id_key;
};
let Some(device) = rodio::cpal::default_host().device_by_id(&id) else {
return false;
};
let Ok(desc) = device.description() else {
return false;
};
if desc.name() == legacy {
return true;
}
let label = output_device_display_label(&device);
label == legacy || label.starts_with(&format!("{legacy} · "))
}
/// True if `pinned` is the same sink as some entry (exact or Linux ALSA logical match).
#[cfg(not(target_os = "linux"))]
pub(crate) fn output_enumeration_includes_pinned(available: &[String], pinned: &str) -> bool {
available
.iter()
@@ -469,18 +671,21 @@ mod tests {
// ── output_enumeration_includes_pinned ────────────────────────────────────
#[test]
#[cfg(not(target_os = "linux"))]
fn includes_pinned_finds_exact_match() {
let avail = vec!["A".to_string(), "B".to_string(), "C".to_string()];
assert!(output_enumeration_includes_pinned(&avail, "B"));
}
#[test]
#[cfg(not(target_os = "linux"))]
fn includes_pinned_returns_false_when_absent() {
let avail = vec!["A".to_string(), "B".to_string()];
assert!(!output_enumeration_includes_pinned(&avail, "Z"));
}
#[test]
#[cfg(not(target_os = "linux"))]
fn includes_pinned_returns_false_for_empty_list() {
let avail: Vec<String> = vec![];
assert!(!output_enumeration_includes_pinned(&avail, "anything"));
@@ -551,6 +756,7 @@ mod tests {
// ── generic default alias / PipeWire wpctl parsing ────────────────────────
#[test]
#[cfg(target_os = "linux")]
fn generic_default_alias_detects_cpal_pipewire_placeholders() {
assert!(is_generic_default_output_alias("Default Audio Device"));
assert!(is_generic_default_output_alias("PipeWire Sound Server"));
@@ -558,6 +764,7 @@ mod tests {
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_status_psysonic_stream_ids_accepts_init_links_when_paused() {
let status = r#"
Audio
@@ -569,6 +776,7 @@ Audio
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_status_psysonic_stream_ids_ignores_streams_without_links() {
let status = r#"
Audio
@@ -581,6 +789,7 @@ Audio
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_status_psysonic_stream_ids_finds_active_streams() {
let status = r#"
Audio
@@ -597,6 +806,7 @@ Video
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_inspect_driver_id_reads_node_driver() {
let inspect = r#"
* node.driver-id = "58"
@@ -606,12 +816,14 @@ Video
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_list_default_sink_id_finds_starred_sink() {
let listing = "56\talsa_output.pci-hdmi\taudio/sink\t\n58\talsa_output.pci-analog\taudio/sink\t*";
assert_eq!(parse_wpctl_list_default_sink_id(listing), Some(58));
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_default_sink_id_finds_starred_sink() {
let status = r#"
Audio
@@ -625,6 +837,7 @@ Audio
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_inspect_alsa_names_reads_card_and_pcm() {
let inspect = r#"
api.alsa.card.name = "HD-Audio Generic"
@@ -641,6 +854,7 @@ Audio
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_inspect_node_description_reads_bluetooth_sink() {
let inspect = r#"
* node.description = "BlueZ Audio Device"
@@ -673,6 +887,7 @@ Audio
}
#[test]
#[cfg(target_os = "linux")]
fn pick_listed_device_name_prefers_enumerated_entry() {
let list = vec![
"Default Audio Device".to_string(),
@@ -685,6 +900,7 @@ Audio
}
#[test]
#[cfg(target_os = "linux")]
fn pick_listed_device_name_matches_linux_alsa_logical_alias() {
let list = vec!["hdmi:CARD=NVidia,DEV=3".to_string()];
assert_eq!(
@@ -7,11 +7,49 @@ use std::sync::atomic::Ordering;
use tauri::{Emitter, State};
use super::dev_io::{
enumerate_output_device_names, output_device_keys_equivalent,
output_devices_logically_same, output_enumeration_includes_pinned,
enumerate_output_device_entries, legacy_output_device_display_label,
output_devices_logically_same, resolve_legacy_pinned_key, OutputDeviceEntry,
};
#[cfg(target_os = "linux")]
use super::dev_io::output_device_keys_equivalent;
use super::engine::AudioEngine;
/// One row in the audio output device picker (`key` is persisted; `label` is display-only).
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
pub struct AudioOutputDeviceEntry {
pub key: String,
pub label: String,
}
impl From<OutputDeviceEntry> for AudioOutputDeviceEntry {
fn from(e: OutputDeviceEntry) -> Self {
Self {
key: e.key,
label: e.label,
}
}
}
fn list_output_device_entries(engine: &AudioEngine) -> Vec<AudioOutputDeviceEntry> {
let mut list: Vec<AudioOutputDeviceEntry> = enumerate_output_device_entries()
.into_iter()
.map(Into::into)
.collect();
if let Some(ref name) = *engine.selected_device.lock().unwrap() {
if !name.is_empty()
&& !list
.iter()
.any(|e| output_devices_logically_same(&e.key, name))
{
list.push(AudioOutputDeviceEntry {
key: name.clone(),
label: legacy_output_device_display_label(name),
});
}
}
list
}
/// When the saved `selected_device` no longer literally matches any listed
/// physical sink (e.g. suffix drift), rewrite `selected_device` to the listed form.
#[tauri::command]
@@ -21,38 +59,39 @@ pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Opti
if pinned.is_empty() {
return None;
}
let list = enumerate_output_device_names();
if list.iter().any(|d| d == &pinned) {
let entries = list_output_device_entries(&state);
if entries.iter().any(|e| e.key == pinned) {
return None;
}
let canon = list
if let Some(upgraded) = resolve_legacy_pinned_key(&pinned, &enumerate_output_device_entries()) {
if upgraded != pinned {
*state.selected_device.lock().unwrap() = Some(upgraded.clone());
return Some(upgraded);
}
}
let canon = entries
.iter()
.find(|d| output_devices_logically_same(d, &pinned))?
.find(|e| output_devices_logically_same(&e.key, &pinned))?
.key
.clone();
*state.selected_device.lock().unwrap() = Some(canon.clone());
Some(canon)
}
/// Same device list as [`audio_list_devices`] without the Tauri `State` wrapper (CLI / single-instance).
pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec<String> {
let mut list = enumerate_output_device_names();
if let Some(ref name) = *engine.selected_device.lock().unwrap() {
if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) {
list.push(name.clone());
}
}
list
pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec<AudioOutputDeviceEntry> {
list_output_device_entries(engine)
}
/// Returns the names of all available audio output devices on the current host.
/// Returns the keys of all available audio output devices on the current host.
/// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to
/// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean.
///
/// The user-pinned device name is appended when cpal omits it (e.g. HDMI busy while
/// The user-pinned device is appended when cpal omits it (e.g. HDMI busy while
/// streaming) so the Settings dropdown still matches `audioOutputDevice`.
#[tauri::command]
#[specta::specta]
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<AudioOutputDeviceEntry> {
audio_list_devices_for_engine(&state)
}
@@ -78,11 +117,20 @@ pub fn audio_match_stored_output_device_key(
candidate: String,
stored_keys: Vec<String>,
) -> Option<String> {
let list = enumerate_output_device_names();
#[cfg(not(target_os = "linux"))]
{
return stored_keys
.into_iter()
.find(|k| output_devices_logically_same(k, &candidate));
}
#[cfg(target_os = "linux")]
{
let list = super::dev_io::enumerate_output_device_names();
stored_keys
.into_iter()
.find(|k| output_device_keys_equivalent(k, &candidate, &list))
}
}
/// Switch the audio output device. `device_name = null` → follow system default.
/// Reopens the stream immediately; frontend must restart playback via audio:device-changed.
@@ -324,6 +324,9 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
continue;
}
// Linux/PipeWire: cpal default labels can drift while the physical sink
// is unchanged — compare via ALSA logical keys before reopening.
#[cfg(target_os = "linux")]
if let Some(ref prev) = last_default {
let prev_name = prev.clone();
let new_name_for_eq = new_name.clone();
@@ -206,21 +206,23 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
// On systems where neither alias exists (pure ALSA, macOS, Windows),
// `find_by_name` returns None and we drop through to `default_output_device`
// unchanged — no regression.
let find_by_name = |name: &str| -> Option<_> {
let find_by_key = |key: &str| -> Option<_> {
super::dev_io::resolve_output_device(key).or_else(|| {
host.output_devices().ok()?.find(|d| {
d.description()
.ok()
.map(|desc| desc.name().to_string())
.as_deref()
== Some(name)
== Some(key)
})
})
};
let device = device_name
.and_then(find_by_name)
.and_then(find_by_key)
.or_else(|| {
#[cfg(target_os = "linux")]
{ find_by_name("pipewire").or_else(|| find_by_name("pulse")) }
{ find_by_key("pipewire").or_else(|| find_by_key("pulse")) }
#[cfg(not(target_os = "linux"))]
{ None }
})
+2
View File
@@ -84,6 +84,7 @@ fn set_app_user_model_id() {
/// tests, so a specta RC break can never block a release `cargo build` — the
/// committed `bindings.ts` is plain TypeScript for `tsc`. Grow `collect_commands!`
/// crate-by-crate (see the specta-contract plan).
#[cfg(any(debug_assertions, test))]
fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
tauri_specta::Builder::<tauri::Wry>::new()
// Map Rust `i64`/`u64`/`usize`/… to TS `number` globally. This is
@@ -325,6 +326,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
/// TS exporter config. Kept as a single seam so the exporter options (header /
/// per-type BigInt-style handling for i64 DTOs) are configured in one place as
/// commands are added crate-by-crate.
#[cfg(any(debug_assertions, test))]
fn bindings_exporter() -> specta_typescript::Typescript {
specta_typescript::Typescript::default()
}
+2 -3
View File
@@ -17,7 +17,7 @@ import AppRoutes from './AppRoutes';
import FullscreenPlayer, { FullscreenPlayerImmersive, FullscreenPlayerPrism } from '@/features/fullscreenPlayer';
import ContextMenu from '@/features/contextMenu/components/ContextMenu';
import SongInfoModal from '@/features/playback/components/SongInfoModal';
import { DownloadFolderModal } from '@/features/offline';
import { DownloadFolderModal, OfflineBanner } from '@/features/offline/ui';
import GlobalConfirmModal from '@/ui/GlobalConfirmModal';
import ThemeMigrationNotice from '@/ui/ThemeMigrationNotice';
import { OrbitAccountPicker, OrbitHelpModal } from '@/features/orbit';
@@ -28,8 +28,7 @@ import {
mainRouteInpageScrollViewportId,
} from '../constants/appScroll';
import ConnectionIndicator from '@/app/ConnectionIndicator';
import { MusicNetworkIndicator } from '@/music-network';
import { OfflineBanner } from '@/features/offline';
import { MusicNetworkIndicator } from '@/music-network/ui';
import AppUpdater from '@/features/updater/components/AppUpdater';
import TitleBar from '@/app/TitleBar';
import { OrbitSessionBar, OrbitStartTrigger } from '@/features/orbit';
+9 -6
View File
@@ -8,19 +8,22 @@ import { WindowVisibilityProvider } from '@/lib/hooks/useWindowVisibility';
import { DragDropProvider } from '@/lib/dnd/DragDropContext';
import PasteClipboardHandler from '@/features/share/components/PasteClipboardHandler';
import ExportPickerModal from '@/ui/ExportPickerModal';
import { ZipDownloadOverlay } from '@/features/offline';
import { ZipDownloadOverlay } from '@/features/offline/ui';
import FpsOverlay from '@/app/FpsOverlay';
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
import { initHotCachePrefetch } from '../hotCachePrefetch';
import { initLocalPlaybackInvalidation } from '../localPlaybackInvalidation';
import { initFavoritesOfflineSync } from '@/features/offline';
import { initPinnedOfflineSync } from '@/features/offline';
import { initFavoritesOfflineSync } from '@/features/offline/utils/favoritesOfflineSync';
import { initPinnedOfflineSync } from '@/features/offline/utils/pinnedOfflineSync';
import { initClusterRebuildOnSync } from '@/lib/library/clusterRebuildOnSync';
import { initResumeIncompleteOfflinePins, scheduleResumeIncompleteOfflinePins } from '@/features/offline';
import { runLegacyOfflineFileMigration } from '@/features/offline';
import { reconcileLibraryTierForServer } from '@/features/offline';
import {
initResumeIncompleteOfflinePins,
scheduleResumeIncompleteOfflinePins,
} from '@/features/offline/utils/resumeIncompleteOfflinePins';
import { runLegacyOfflineFileMigration } from '@/features/offline/utils/legacyOfflineFileMigration';
import { reconcileLibraryTierForServer } from '@/features/offline/utils/libraryTierReconcile';
import { initMiniPlayerBridgeOnMain } from '@/features/miniPlayer';
import { runAdvancedModeMigration } from '@/app/migrations/advancedModeMigration';
import { bootstrapAllIndexedServers } from '@/lib/library/librarySession';
+2 -5
View File
@@ -3,11 +3,8 @@
// does not matter), and the Tauri shell backs the host (browser auth + uuid).
import { open } from '@tauri-apps/plugin-shell';
import {
initMusicNetworkRuntime,
type MusicNetworkStore,
type RuntimeHost,
} from '../music-network';
import { initMusicNetworkRuntime } from '../music-network/runtime/getMusicNetworkRuntime';
import type { MusicNetworkStore, RuntimeHost } from '../music-network/runtime/store';
import { useAuthStore } from '../store/authStore';
const store: MusicNetworkStore = {
+1
View File
@@ -197,6 +197,7 @@ const CONTRIBUTOR_ENTRIES = [
'Servers — connect and run fully behind a custom-header gate (Cloudflare Access / Pangolin): native connect probe + REST proxy so browse/playback/covers pass, add-form failure reasons, live LAN/public badge on server switch, and LAN reclaim from a sticky public endpoint (PR #1273)',
'Per-device EQ — when System Default is selected, profiles follow the active OS default output and switch on external output changes (PR #1274)',
'Navidrome public share links — paste/search preview modal, anonymous queue playback, idle server queue-restore guard (PR #1275)',
'Windows startup hang after #1274 — boot barrel split, stable Wasapi device IDs, legacy EQ key match (PR #1277)',
],
},
{
@@ -1,10 +1,10 @@
import { useTranslation } from 'react-i18next';
import { Play, Radio, Heart, ChevronRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, Share2 } from 'lucide-react';
import { queueSongStar } from '@/features/playback/store/pendingStarSync';
import { getMusicNetworkRuntime, useEnrichmentPrimary } from '@/music-network';
import { getMusicNetworkRuntime } from '@/music-network';
import type { Track } from '@/lib/media/trackTypes';
import { useAuthStore } from '@/store/authStore';
import { renderPresetIcon } from '@/music-network';
import { renderPresetIcon, useEnrichmentPrimary } from '@/music-network/ui';
import StarRating from '@/ui/StarRating';
import { AddToPlaylistSubmenu } from '@/features/contextMenu/components/AddToPlaylistSubmenu';
import type { ContextMenuItemsProps } from '@/features/contextMenu/components/contextMenuItemTypes';
@@ -4,7 +4,8 @@ import { useNavigateToAlbum } from '@/features/album';
import { useNavigateToArtist } from '@/features/artist';
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
import { queueSongStar } from '@/features/playback/store/pendingStarSync';
import { getMusicNetworkRuntime, useEnrichmentPrimary } from '@/music-network';
import { getMusicNetworkRuntime } from '@/music-network';
import { renderPresetIcon, useEnrichmentPrimary } from '@/music-network/ui';
import type { Track } from '@/lib/media/trackTypes';
import { useAuthStore } from '@/store/authStore';
import { usePlaylistStore } from '@/features/playlist';
@@ -12,7 +13,6 @@ import { usePlaylistMembershipStore } from '@/store/playlistMembershipStore';
import { songToTrack } from '@/lib/media/songToTrack';
import { showToast } from '@/lib/dom/toast';
import { suggestOrbitTrack, hostEnqueueToOrbit, evaluateOrbitSuggestGate, OrbitSuggestBlockedError } from '@/features/orbit';
import { renderPresetIcon } from '@/music-network';
import StarRating from '@/ui/StarRating';
import { AddToPlaylistSubmenu } from '@/features/contextMenu/components/AddToPlaylistSubmenu';
import type { ContextMenuItemsProps } from '@/features/contextMenu/components/contextMenuItemTypes';
+1 -1
View File
@@ -7,7 +7,7 @@ import type { ArtistStats, TrackStats } from '@/music-network';
import type { SubsonicOpenArtistRef } from '@/lib/api/subsonicTypes';
import { OpenArtistRefInline } from '@/ui/OpenArtistRefInline';
import { formatTrackTime } from '@/lib/format/formatDuration';
import { renderPresetIcon, useEnrichmentPrimaryIcon, useEnrichmentPrimaryLabel } from '@/music-network';
import { renderPresetIcon, useEnrichmentPrimaryIcon, useEnrichmentPrimaryLabel } from '@/music-network/ui';
interface HeroProps {
track: { title: string; artist: string; album: string; year?: number;
-4
View File
@@ -49,7 +49,3 @@ export * from './utils/resumeIncompleteOfflinePins';
// `OfflinePinKind` (= PinSource['kind']) is declared in both pin modules; the
// explicit re-export resolves the `export *` ambiguity (TS2308).
export type { OfflinePinKind } from './utils/offlinePinQueue';
export { OfflineLibraryDiskStat } from './components/OfflineLibraryDiskStat';
export { default as DownloadFolderModal } from './components/DownloadFolderModal';
export { default as OfflineBanner } from './components/OfflineBanner';
export { default as ZipDownloadOverlay } from './components/ZipDownloadOverlay';
+7
View File
@@ -0,0 +1,7 @@
// Offline feature UI — import from here, not `@/features/offline`, so utils/stores
// loaded at boot never pull lucide-react through the root barrel.
export { OfflineLibraryDiskStat } from '../components/OfflineLibraryDiskStat';
export { default as DownloadFolderModal } from '../components/DownloadFolderModal';
export { default as OfflineBanner } from '../components/OfflineBanner';
export { default as ZipDownloadOverlay } from '../components/ZipDownloadOverlay';
@@ -15,7 +15,7 @@ import { OpenArtistRefInline } from '@/ui/OpenArtistRefInline';
import StarRating from '@/ui/StarRating';
import { PlaybackBufferingOverlay } from '@/features/playback/components/PlaybackBufferingOverlay';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { renderPresetIcon, useEnrichmentPrimaryIcon, useEnrichmentPrimaryLabel } from '@/music-network';
import { renderPresetIcon, useEnrichmentPrimaryIcon, useEnrichmentPrimaryLabel } from '@/music-network/ui';
import {
usePlayerBarLayoutStore,
type PlayerBarLayoutItemId,
@@ -4,15 +4,16 @@ import {
audioCanonicalizeSelectedDevice,
audioDefaultOutputDeviceName,
audioListDevices,
type AudioOutputDeviceEntry,
} from '@/lib/api/audio';
import type { TFunction } from 'i18next';
import { useAuthStore } from '@/store/authStore';
import { IS_MACOS } from '@/lib/util/platform';
import { sortAudioDeviceIds } from '@/features/playback/utils/audio/audioDeviceLabels';
import { sortAudioDeviceEntries } from '@/features/playback/utils/audio/audioDeviceLabels';
import { showToast } from '@/lib/dom/toast';
interface UseAudioDevicesProbeResult {
audioDevices: string[];
audioDevices: AudioOutputDeviceEntry[];
osDefaultAudioDeviceId: string | null;
deviceSwitching: boolean;
devicesLoading: boolean;
@@ -31,7 +32,7 @@ interface UseAudioDevicesProbeResult {
* there, and the whole output-device category is gated out in settings.
*/
export function useAudioDevicesProbe(t: TFunction): UseAudioDevicesProbeResult {
const [audioDevices, setAudioDevices] = useState<string[]>([]);
const [audioDevices, setAudioDevices] = useState<AudioOutputDeviceEntry[]>([]);
const [osDefaultAudioDeviceId, setOsDefaultAudioDeviceId] = useState<string | null>(null);
const [deviceSwitching, setDeviceSwitching] = useState(false);
const [devicesLoading, setDevicesLoading] = useState(false);
@@ -42,7 +43,7 @@ export function useAudioDevicesProbe(t: TFunction): UseAudioDevicesProbeResult {
const listP = audioListDevices().catch((e) => {
console.error(e);
showToast(t('settings.audioOutputDeviceListError'), 5000, 'error');
return [] as string[];
return [] as AudioOutputDeviceEntry[];
});
const defP = audioDefaultOutputDeviceName().catch(() => null);
Promise.all([listP, defP])
@@ -58,7 +59,7 @@ export function useAudioDevicesProbe(t: TFunction): UseAudioDevicesProbeResult {
? await audioListDevices().catch(() => devices)
: devices;
const defId = osDefault ?? null;
setAudioDevices(sortAudioDeviceIds(finalList, defId));
setAudioDevices(sortAudioDeviceEntries(finalList, defId));
setOsDefaultAudioDeviceId(defId);
})
.finally(() => {
@@ -268,6 +268,75 @@ describe('eqDeviceSync', () => {
expect(useEqStore.getState().enabled).toBe(false);
});
it('restores pinned device EQ when the stored key is a legacy description name', async () => {
onInvoke('audio_match_stored_output_device_key', (args) => {
const { candidate, storedKeys } = args as { candidate: string; storedKeys: string[] };
if (candidate === 'Wasapi:{speakers-guid}' && storedKeys.includes('Speakers')) {
return 'Speakers';
}
return null;
});
useAuthStore.getState().setAudioOutputDevice('Wasapi:{speakers-guid}');
resetEq({
rememberPerDevice: true,
byDevice: { Speakers: snap(8, { enabled: true }) },
});
cleanup = setupEqDeviceSync();
await flushAsync();
expect(useEqStore.getState().gains[0]).toBe(8);
expect(useEqStore.getState().enabled).toBe(true);
});
it('matches legacy stored keys when switching pinned devices after DeviceId upgrade', async () => {
onInvoke('audio_match_stored_output_device_key', (args) => {
const { candidate, storedKeys } = args as { candidate: string; storedKeys: string[] };
if (candidate === 'Wasapi:{headphones-guid}' && storedKeys.includes('Headphones')) {
return 'Headphones';
}
return null;
});
useAuthStore.getState().setAudioOutputDevice('Wasapi:{speakers-guid}');
resetEq({
rememberPerDevice: true,
byDevice: { Headphones: snap(7, { enabled: true }) },
});
cleanup = setupEqDeviceSync();
await flushAsync();
useAuthStore.getState().setAudioOutputDevice('Wasapi:{headphones-guid}');
await flushAsync();
expect(useEqStore.getState().gains[0]).toBe(7);
expect(useEqStore.getState().enabled).toBe(true);
});
it('does not apply a queued pinned switch after the user unpins', async () => {
let releaseMatch: () => void = () => {};
const matchBlocked = new Promise<string | null>((resolve) => {
releaseMatch = () => resolve(null);
});
onInvoke('audio_match_stored_output_device_key', () => matchBlocked);
onInvoke('audio_default_output_device_name', () => 'Speakers');
useAuthStore.getState().setAudioOutputDevice('A');
resetEq({
rememberPerDevice: true,
byDevice: {
A: snap(9, { enabled: true }),
Speakers: snap(3),
},
});
cleanup = setupEqDeviceSync();
await flushAsync();
useAuthStore.getState().setAudioOutputDevice('Wasapi:{slow-device}');
useAuthStore.getState().setAudioOutputDevice(null);
releaseMatch();
await waitForOsDefaultInit(3);
expect(useEqStore.getState().gains[0]).toBe(3);
});
it('saves the outgoing device snapshot when switching pinned devices', async () => {
useAuthStore.getState().setAudioOutputDevice('A');
resetEq({ rememberPerDevice: true });
@@ -68,7 +68,7 @@ async function lookupSnapshotAsync(
followingSystemDefault: boolean,
): Promise<EqSnapshot | undefined> {
const direct = lookupSnapshot(byDevice, key, followingSystemDefault);
if (direct || !followingSystemDefault) return direct;
if (direct) return direct;
const storedKeys = Object.keys(byDevice);
if (storedKeys.length === 0) return undefined;
try {
@@ -80,6 +80,15 @@ async function lookupSnapshotAsync(
return undefined;
}
/** Serializes async pinned-device EQ switches so overlapping updates cannot apply stale snapshots. */
const pinnedSwitchQueue: { tail: Promise<void> } = { tail: Promise.resolve() };
function enqueuePinnedSwitch(task: () => Promise<void>): Promise<void> {
const next = pinnedSwitchQueue.tail.then(task, task);
pinnedSwitchQueue.tail = next.catch(() => {});
return next;
}
function applySnapshot(snap: EqSnapshot): void {
applying = true;
try {
@@ -89,39 +98,31 @@ function applySnapshot(snap: EqSnapshot): void {
}
}
function switchEqToKey(newKey: string, followingSystemDefault: boolean): void {
const prevKey = currentKey;
if (newKey === prevKey) return;
const eq = useEqStore.getState();
if (eq.rememberPerDevice) {
eq.saveSnapshotFor(prevKey);
}
currentKey = newKey;
if (!eq.rememberPerDevice) return;
const snap = lookupSnapshot(eq.byDevice, newKey, followingSystemDefault);
if (snap) {
applySnapshot(snap);
return;
}
// Key resolved from a generic placeholder → keep live EQ; next edit mirrors here.
if (followingSystemDefault && isLegacyDefaultDeviceKey(prevKey)) {
useEqStore.getState().saveSnapshotFor(newKey);
}
}
async function switchEqToKeyAsync(
newKey: string,
followingSystemDefault: boolean,
): Promise<void> {
if (!followingSystemDefault && useAuthStore.getState().audioOutputDevice !== newKey) {
return;
}
const prevKey = currentKey;
if (newKey === prevKey) return;
const eq = useEqStore.getState();
if (eq.rememberPerDevice) {
eq.saveSnapshotFor(prevKey);
}
if (!eq.rememberPerDevice) {
currentKey = newKey;
if (!eq.rememberPerDevice) return;
return;
}
const snap = await lookupSnapshotAsync(eq.byDevice, newKey, followingSystemDefault);
if (!followingSystemDefault && useAuthStore.getState().audioOutputDevice !== newKey) {
return;
}
if (currentKey !== prevKey) {
return;
}
currentKey = newKey;
if (snap) {
applySnapshot(snap);
return;
@@ -210,7 +211,7 @@ export function setupEqDeviceSync(): () => void {
if (_state.audioOutputDevice === prev.audioOutputDevice) return;
const latestPinned = _state.audioOutputDevice;
if (latestPinned !== null) {
switchEqToKey(latestPinned, false);
void enqueuePinnedSwitch(() => switchEqToKeyAsync(latestPinned, false));
return;
}
void enqueueOsDefaultRefresh(async () => {
@@ -1,10 +1,66 @@
/** Makes raw ALSA device names more readable on Linux.
* Values are kept as-is (rodio needs the ALSA name); only the displayed label is cleaned.
* e.g. "sysdefault:CARD=U192k" "U192k"
* "hw:CARD=U192k,DEV=0" "U192k (hw · PCM 0)"
* "hdmi:CARD=NVidia,DEV=1" "NVidia (HDMI · DEV 1)" (same DEV as in ALSA string)
* "iec958:CARD=PCH,DEV=0" "PCH (S/PDIF)"
* Names without ALSA prefix (pipewire, pulse, default) are returned unchanged. */
import type { AudioOutputDeviceEntry } from '@/lib/api/audio';
function shortDeviceKey(key: string): string {
const colon = key.indexOf(':');
if (colon >= 0) {
const tail = key.slice(colon + 1).replace(/[{}]/g, '');
if (tail.length > 12) return `${tail.slice(-8)}`;
return tail;
}
return key.length > 48 ? `${key.slice(-20)}` : key;
}
/** Sort by readable label; current OS default first. */
export function sortAudioDeviceEntries(
devices: AudioOutputDeviceEntry[],
osDefaultDeviceKey: string | null,
): AudioOutputDeviceEntry[] {
return [...devices].sort((a, b) => {
const aDef = osDefaultDeviceKey && a.key === osDefaultDeviceKey;
const bDef = osDefaultDeviceKey && b.key === osDefaultDeviceKey;
if (aDef !== bDef) return aDef ? -1 : 1;
const byLabel = a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
if (byLabel !== 0) return byLabel;
return a.key.localeCompare(b.key);
});
}
export function buildAudioDeviceSelectOptions(
devices: AudioOutputDeviceEntry[],
defaultLabel: string,
osDefaultDeviceKey: string | null,
osDefaultMark: string,
pinnedDevice: string | null,
notInListSuffix: string,
): { value: string; label: string }[] {
const labelCounts = new Map<string, number>();
for (const d of devices) {
labelCounts.set(d.label, (labelCounts.get(d.label) ?? 0) + 1);
}
const pinned = pinnedDevice?.trim() || null;
const pinnedNotListed = !!(pinned && !devices.some(d => d.key === pinned));
const ghost: { value: string; label: string }[] = pinnedNotListed
? (() => {
const entry = devices.find(d => d.key === pinned);
const base = entry?.label ?? pinned;
let label = `${base} · ${notInListSuffix}`;
if (osDefaultDeviceKey && pinned === osDefaultDeviceKey) label = `${label} · ${osDefaultMark}`;
return [{ value: pinned, label }];
})()
: [];
return [
{ value: '', label: defaultLabel },
...ghost,
...devices.map((d) => {
const dup = (labelCounts.get(d.label) ?? 0) > 1;
let label = dup ? `${d.label} · ${shortDeviceKey(d.key)}` : d.label;
if (osDefaultDeviceKey && d.key === osDefaultDeviceKey) label = `${label} · ${osDefaultMark}`;
return { value: d.key, label };
}),
];
}
/** Makes raw ALSA device names more readable on Linux (legacy keys without Rust labels). */
export function formatAudioDeviceLabel(name: string): string {
const cardMatch = name.match(/CARD=([^,]+)/);
if (!cardMatch) return name;
@@ -39,7 +95,6 @@ export function formatAudioDeviceLabel(name: string): string {
}
if (name.startsWith('front:')) return `${card} (Front)`;
if (name.startsWith('surround')) return `${card} (${name.split(':')[0]})`;
// Other ALSA iface:card,dev — show plugin + PCM so identical cards differ
const iface = name.split(':')[0];
if (iface && !['default', 'pulse', 'pipewire'].includes(iface)) {
if (devNum !== null) return `${card} (${iface} · PCM ${devNum})`;
@@ -48,28 +103,7 @@ export function formatAudioDeviceLabel(name: string): string {
return card;
}
/** Readable tail when two devices still share the same label (rare after formatAudioDeviceLabel). */
export function audioDeviceDuplicateHint(raw: string): string {
const cardM = raw.match(/CARD=([^,]+)/);
const devM = raw.match(/DEV=(\d+)/);
const subM = raw.match(/SUBDEV=(\d+)/);
const iface = raw.split(':')[0] || '';
const parts: string[] = [];
if (iface) parts.push(iface);
if (cardM) parts.push(cardM[1]);
if (devM) parts.push(`PCM ${devM[1]}`);
if (subM) parts.push(`sub ${subM[1]}`);
if (parts.length > 1) return parts.join(' · ');
return raw.length > 56 ? `${raw.slice(-53)}` : raw;
}
/** When several devices share the same display label, append a disambiguator. */
export function disambiguatedAudioDeviceLabel(raw: string, baseLabel: string, duplicateBase: boolean): string {
if (!duplicateBase) return baseLabel;
return `${baseLabel} · ${audioDeviceDuplicateHint(raw)}`;
}
/** cpal order is arbitrary; sort by readable label, current OS default first. */
/** @deprecated Use `sortAudioDeviceEntries` with Rust-provided labels. */
export function sortAudioDeviceIds(devices: string[], osDefaultDeviceId: string | null): string[] {
return [...devices].sort((a, b) => {
const aDef = osDefaultDeviceId && a === osDefaultDeviceId;
@@ -82,37 +116,3 @@ export function sortAudioDeviceIds(devices: string[], osDefaultDeviceId: string
return a.localeCompare(b);
});
}
export 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;
let label = disambiguatedAudioDeviceLabel(d, base, dup);
if (osDefaultDeviceId && d === osDefaultDeviceId) label = `${label} · ${osDefaultMark}`;
return { value: d, label };
}),
];
}
@@ -9,9 +9,10 @@ import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
import { useAuthStore } from '@/store/authStore';
import { useEqStore } from '@/store/eqStore';
import { buildAudioDeviceSelectOptions } from '@/features/playback/utils/audio/audioDeviceLabels';
import type { AudioOutputDeviceEntry } from '@/lib/api/audio';
interface Props {
audioDevices: string[];
audioDevices: AudioOutputDeviceEntry[];
osDefaultAudioDeviceId: string | null;
deviceSwitching: boolean;
devicesLoading: boolean;
@@ -7,7 +7,7 @@ import {
type BuiltinPreset,
type PresetId,
} from '@/music-network';
import { renderPresetIcon } from '@/music-network';
import { renderPresetIcon } from '@/music-network/ui';
/**
* "Add a service" list, driven entirely by the preset registry. Token-poll
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { getPreset, type Account, type UserProfile } from '@/music-network';
import { renderPresetIcon } from '@/music-network';
import { renderPresetIcon } from '@/music-network/ui';
/**
* One connected account as a single self-contained block: header (icon, label,
+2 -1
View File
@@ -12,7 +12,8 @@ import StatisticsTabBar from '@/features/stats/components/StatisticsTabBar';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/authStore';
import { useLocation } from 'react-router-dom';
import { getMusicNetworkRuntime, useEnrichmentPrimaryLabel, type RecentTrack, type StatsPeriod, type TopItem } from '@/music-network';
import { getMusicNetworkRuntime, type RecentTrack, type StatsPeriod, type TopItem } from '@/music-network';
import { useEnrichmentPrimaryLabel } from '@/music-network/ui';
import { useOfflineBrowseContext } from '@/features/offline';
import { usePlayerStatsRecordingEnabled } from '@/features/stats/hooks/usePlayerStatsRecordingEnabled';
+9 -3
View File
@@ -137,14 +137,14 @@ export const commands = {
*/
audioPreviewSetVolume: (volume: number | null) => __TAURI_INVOKE<void>("audio_preview_set_volume", { volume }),
/**
* Returns the names of all available audio output devices on the current host.
* Returns the keys of all available audio output devices on the current host.
* On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to
* stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean.
*
* The user-pinned device name is appended when cpal omits it (e.g. HDMI busy while
* The user-pinned device is appended when cpal omits it (e.g. HDMI busy while
* streaming) so the Settings dropdown still matches `audioOutputDevice`.
*/
audioListDevices: () => __TAURI_INVOKE<string[]>("audio_list_devices"),
audioListDevices: () => __TAURI_INVOKE<AudioOutputDeviceEntry[]>("audio_list_devices"),
/**
* When the saved `selected_device` no longer literally matches any listed
* physical sink (e.g. suffix drift), rewrite `selected_device` to the listed form.
@@ -803,6 +803,12 @@ export type ArtifactInputDto = {
expiresAt?: number | null,
};
/** One row in the audio output device picker (`key` is persisted; `label` is display-only). */
export type AudioOutputDeviceEntry = {
key: string,
label: string,
};
export type BandsintownEvent = {
datetime: string,
venue_name: string,
+3 -1
View File
@@ -105,7 +105,9 @@ export function audioPreviewSetVolume(args: { volume: number | null }): Promise<
return commands.audioPreviewSetVolume(args.volume);
}
export function audioListDevices(): Promise<string[]> {
export type AudioOutputDeviceEntry = { key: string; label: string };
export function audioListDevices(): Promise<AudioOutputDeviceEntry[]> {
return commands.audioListDevices();
}
+2 -2
View File
@@ -5,8 +5,8 @@ import { useLocalPlaybackStore } from './store/localPlaybackStore';
import { layoutFingerprintFromLibraryTrack } from '@/lib/media/mediaLayout';
import { getMediaDir } from '@/lib/media/mediaDir';
import { deleteMediaFile } from '@/lib/api/syncfs';
import { runLegacyOfflineFileMigration } from '@/features/offline';
import { reconcileLibraryTierForServer } from '@/features/offline';
import { runLegacyOfflineFileMigration } from '@/features/offline/utils/legacyOfflineFileMigration';
import { reconcileLibraryTierForServer } from '@/features/offline/utils/libraryTierReconcile';
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
import { serverIndexKeyFromUrl } from '@/lib/server/serverIndexKey';
+5 -1
View File
@@ -11,10 +11,14 @@ import './styles/tracks/index.css';
runPreReactBootstrap();
try {
ReactDOM.createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);
} finally {
// Always dismiss the inline splash once the bundle has executed — even when
// React mount throws, so Windows users are not stuck on "Loading" forever.
scheduleStartupSplashDismiss();
}
-5
View File
@@ -11,11 +11,6 @@ export {
} from './runtime/getMusicNetworkRuntime';
export type { MusicNetworkStore, RuntimeHost } from './runtime/store';
export { listPresets, getPreset } from './registry/presetRegistry';
export { useEnrichmentPrimary, type EnrichmentPrimary } from './ui/useEnrichmentPrimary';
export { useEnrichmentPrimaryIcon } from './ui/useEnrichmentPrimaryIcon';
export { useEnrichmentPrimaryLabel } from './ui/useEnrichmentPrimaryLabel';
export { renderPresetIcon } from './ui/presetIcon';
export { default as MusicNetworkIndicator } from './ui/MusicNetworkIndicator';
export {
migrateLegacyLastfm,
sanitizeAccounts,
@@ -1,7 +1,7 @@
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useEnrichmentPrimary } from '@/music-network';
import { renderPresetIcon } from '@/music-network/ui/presetIcon';
import { useEnrichmentPrimary } from './useEnrichmentPrimary';
import { renderPresetIcon } from './presetIcon';
/**
* Sidebar status indicator for the enrichment primary (the account that drives
+8
View File
@@ -0,0 +1,8 @@
// Music Network UI — hooks and icons. Import from here, not the root barrel,
// so runtime/store boot paths never pull lucide-react through a side-effect cycle.
export { useEnrichmentPrimary, type EnrichmentPrimary } from './useEnrichmentPrimary';
export { useEnrichmentPrimaryIcon } from './useEnrichmentPrimaryIcon';
export { useEnrichmentPrimaryLabel } from './useEnrichmentPrimaryLabel';
export { renderPresetIcon } from './presetIcon';
export { default as MusicNetworkIndicator } from './MusicNetworkIndicator';
+1 -1
View File
@@ -1,6 +1,6 @@
import { Globe, Radio, Server, Music2, Headphones } from 'lucide-react';
import LastfmIcon from '@/ui/LastfmIcon';
import type { PresetIcon } from '@/music-network';
import type { PresetIcon } from '../contracts/PresetManifest';
/**
* Maps a preset manifest icon id to a rendered icon. Feature code references the
@@ -1,4 +1,4 @@
import type { PresetIcon } from '@/music-network';
import type { PresetIcon } from '../contracts/PresetManifest';
import { useEnrichmentPrimary } from './useEnrichmentPrimary';
/**