//! Audio settings persistence //! //! Stores user preferences for audio output device, exclusive mode, and DAC passthrough. use crate::audio::{AlsaPlugin, AudioBackendType}; use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; use std::sync::{Arc, Mutex}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AudioSettings { pub output_device: Option, // None = system default pub exclusive_mode: bool, pub dac_passthrough: bool, pub preferred_sample_rate: Option, // None = auto pub backend_type: Option, // None = auto-detect pub alsa_plugin: Option, // Only used when backend is ALSA pub alsa_hardware_volume: bool, // Use ALSA mixer for volume (only with hw: devices) /// When true, uncached tracks start playing via streaming instead of waiting for full download pub stream_first_track: bool, /// Initial buffer size in seconds before starting streaming playback (1-10, default 3) pub stream_buffer_seconds: u8, } impl Default for AudioSettings { fn default() -> Self { Self { output_device: None, exclusive_mode: false, dac_passthrough: false, preferred_sample_rate: None, backend_type: None, // Auto-detect (PipeWire if available, else ALSA) alsa_plugin: Some(AlsaPlugin::Hw), // Default to hw (bit-perfect) alsa_hardware_volume: false, // Disabled by default (maximum compatibility) stream_first_track: false, // Disabled by default (current behavior) stream_buffer_seconds: 3, // 3 seconds initial buffer } } } pub struct AudioSettingsStore { conn: Connection, } impl AudioSettingsStore { pub fn new() -> Result { let data_dir = dirs::data_dir() .ok_or("Could not determine data directory")? .join("qbz"); std::fs::create_dir_all(&data_dir) .map_err(|e| format!("Failed to create data directory: {}", e))?; let db_path = data_dir.join("audio_settings.db"); let conn = Connection::open(&db_path) .map_err(|e| format!("Failed to open audio settings database: {}", e))?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS audio_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), output_device TEXT, exclusive_mode INTEGER NOT NULL DEFAULT 0, dac_passthrough INTEGER NOT NULL DEFAULT 0, preferred_sample_rate INTEGER, backend_type TEXT, alsa_plugin TEXT, alsa_hardware_volume INTEGER NOT NULL DEFAULT 0, stream_first_track INTEGER NOT NULL DEFAULT 0, stream_buffer_seconds INTEGER NOT NULL DEFAULT 3 ); INSERT OR IGNORE INTO audio_settings (id, exclusive_mode, dac_passthrough) VALUES (1, 0, 0);" ).map_err(|e| format!("Failed to create audio settings table: {}", e))?; // Migration: Add new columns if they don't exist (for existing databases) let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN backend_type TEXT", []); let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN alsa_plugin TEXT", []); let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN alsa_hardware_volume INTEGER DEFAULT 0", []); let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN stream_first_track INTEGER DEFAULT 0", []); let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN stream_buffer_seconds INTEGER DEFAULT 3", []); Ok(Self { conn }) } pub fn get_settings(&self) -> Result { self.conn .query_row( "SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds FROM audio_settings WHERE id = 1", [], |row| { // Parse backend_type from JSON string let backend_type: Option = row .get::<_, Option>(4)? .and_then(|s| serde_json::from_str(&s).ok()); // Parse alsa_plugin from JSON string let alsa_plugin: Option = row .get::<_, Option>(5)? .and_then(|s| serde_json::from_str(&s).ok()); Ok(AudioSettings { output_device: row.get(0)?, exclusive_mode: row.get::<_, i64>(1)? != 0, dac_passthrough: row.get::<_, i64>(2)? != 0, preferred_sample_rate: row.get(3)?, backend_type, alsa_plugin, alsa_hardware_volume: row.get::<_, Option>(6)?.unwrap_or(0) != 0, stream_first_track: row.get::<_, Option>(7)?.unwrap_or(0) != 0, stream_buffer_seconds: row.get::<_, Option>(8)?.unwrap_or(3) as u8, }) }, ) .map_err(|e| format!("Failed to get audio settings: {}", e)) } pub fn set_output_device(&self, device: Option<&str>) -> Result<(), String> { self.conn .execute( "UPDATE audio_settings SET output_device = ?1 WHERE id = 1", params![device], ) .map_err(|e| format!("Failed to set output device: {}", e))?; Ok(()) } pub fn set_exclusive_mode(&self, enabled: bool) -> Result<(), String> { self.conn .execute( "UPDATE audio_settings SET exclusive_mode = ?1 WHERE id = 1", params![enabled as i64], ) .map_err(|e| format!("Failed to set exclusive mode: {}", e))?; Ok(()) } pub fn set_dac_passthrough(&self, enabled: bool) -> Result<(), String> { self.conn .execute( "UPDATE audio_settings SET dac_passthrough = ?1 WHERE id = 1", params![enabled as i64], ) .map_err(|e| format!("Failed to set DAC passthrough: {}", e))?; Ok(()) } pub fn set_sample_rate(&self, rate: Option) -> Result<(), String> { self.conn .execute( "UPDATE audio_settings SET preferred_sample_rate = ?1 WHERE id = 1", params![rate.map(|r| r as i64)], ) .map_err(|e| format!("Failed to set sample rate: {}", e))?; Ok(()) } pub fn set_backend_type(&self, backend: Option) -> Result<(), String> { let backend_json = backend .map(|b| serde_json::to_string(&b)) .transpose() .map_err(|e| format!("Failed to serialize backend type: {}", e))?; self.conn .execute( "UPDATE audio_settings SET backend_type = ?1 WHERE id = 1", params![backend_json], ) .map_err(|e| format!("Failed to set backend type: {}", e))?; Ok(()) } pub fn set_alsa_plugin(&self, plugin: Option) -> Result<(), String> { let plugin_json = plugin .map(|p| serde_json::to_string(&p)) .transpose() .map_err(|e| format!("Failed to serialize ALSA plugin: {}", e))?; self.conn .execute( "UPDATE audio_settings SET alsa_plugin = ?1 WHERE id = 1", params![plugin_json], ) .map_err(|e| format!("Failed to set ALSA plugin: {}", e))?; Ok(()) } pub fn set_alsa_hardware_volume(&self, enabled: bool) -> Result<(), String> { self.conn .execute( "UPDATE audio_settings SET alsa_hardware_volume = ?1 WHERE id = 1", params![enabled as i64], ) .map_err(|e| format!("Failed to set ALSA hardware volume: {}", e))?; Ok(()) } pub fn set_stream_first_track(&self, enabled: bool) -> Result<(), String> { self.conn .execute( "UPDATE audio_settings SET stream_first_track = ?1 WHERE id = 1", params![enabled as i64], ) .map_err(|e| format!("Failed to set stream first track: {}", e))?; Ok(()) } pub fn set_stream_buffer_seconds(&self, seconds: u8) -> Result<(), String> { // Clamp to valid range 1-10 let clamped = seconds.clamp(1, 10); self.conn .execute( "UPDATE audio_settings SET stream_buffer_seconds = ?1 WHERE id = 1", params![clamped as i64], ) .map_err(|e| format!("Failed to set stream buffer seconds: {}", e))?; Ok(()) } } /// Thread-safe wrapper pub struct AudioSettingsState { pub store: Arc>, } impl AudioSettingsState { pub fn new() -> Result { Ok(Self { store: Arc::new(Mutex::new(AudioSettingsStore::new()?)), }) } } // Tauri commands #[tauri::command] pub fn get_audio_settings( state: tauri::State<'_, AudioSettingsState>, ) -> Result { let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; store.get_settings() } #[tauri::command] pub fn set_audio_output_device( state: tauri::State<'_, AudioSettingsState>, device: Option, ) -> Result<(), String> { let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; store.set_output_device(device.as_deref()) } #[tauri::command] pub fn set_audio_exclusive_mode( state: tauri::State<'_, AudioSettingsState>, enabled: bool, ) -> Result<(), String> { let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; store.set_exclusive_mode(enabled) } #[tauri::command] pub fn set_audio_dac_passthrough( state: tauri::State<'_, AudioSettingsState>, enabled: bool, ) -> Result<(), String> { let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; store.set_dac_passthrough(enabled) } #[tauri::command] pub fn set_audio_sample_rate( state: tauri::State<'_, AudioSettingsState>, rate: Option, ) -> Result<(), String> { let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; store.set_sample_rate(rate) } #[tauri::command] pub fn set_audio_backend_type( state: tauri::State<'_, AudioSettingsState>, backend_type: Option, ) -> Result<(), String> { let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; store.set_backend_type(backend_type) } #[tauri::command] pub fn set_audio_alsa_plugin( state: tauri::State<'_, AudioSettingsState>, plugin: Option, ) -> Result<(), String> { let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; store.set_alsa_plugin(plugin) } #[tauri::command] pub fn set_audio_alsa_hardware_volume( state: tauri::State<'_, AudioSettingsState>, enabled: bool, ) -> Result<(), String> { let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; store.set_alsa_hardware_volume(enabled) } #[tauri::command] pub fn set_audio_stream_first_track( state: tauri::State<'_, AudioSettingsState>, enabled: bool, ) -> Result<(), String> { let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; store.set_stream_first_track(enabled) } #[tauri::command] pub fn set_audio_stream_buffer_seconds( state: tauri::State<'_, AudioSettingsState>, seconds: u8, ) -> Result<(), String> { let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; store.set_stream_buffer_seconds(seconds) }