diff --git a/src-tauri/src/config/audio_settings.rs b/src-tauri/src/config/audio_settings.rs new file mode 100644 index 000000000..0c1bb15ed --- /dev/null +++ b/src-tauri/src/config/audio_settings.rs @@ -0,0 +1,163 @@ +//! Audio settings persistence +//! +//! Stores user preferences for audio output device, exclusive mode, and DAC passthrough. + +use rusqlite::{Connection, params}; +use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +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 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-nix"); + + 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 + ); + 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))?; + + Ok(Self { conn }) + } + + pub fn get_settings(&self) -> Result { + self.conn + .query_row( + "SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate FROM audio_settings WHERE id = 1", + [], + |row| { + 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)?, + }) + }, + ) + .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(()) + } +} + +/// 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) +} diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index c4ff43845..6b697028e 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -7,5 +7,14 @@ //! - Local playlists //! - Cached favorites -// TODO: Implement configuration storage -// pub mod store; +pub mod audio_settings; + +pub use audio_settings::{ + AudioSettings, + AudioSettingsState, + get_audio_settings, + set_audio_output_device, + set_audio_exclusive_mode, + set_audio_dac_passthrough, + set_audio_sample_rate, +}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 76588a1a3..e023da1a2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -124,6 +124,9 @@ pub fn run() { // Initialize session store state let session_store_state = session_store::SessionStoreState::new() .expect("Failed to initialize session store"); + // Initialize audio settings state + let audio_settings_state = config::audio_settings::AudioSettingsState::new() + .expect("Failed to initialize audio settings"); tauri::Builder::default() .plugin(tauri_plugin_opener::init()) @@ -184,6 +187,7 @@ pub fn run() { .manage(reco_state) .manage(api_cache_state) .manage(session_store_state) + .manage(audio_settings_state) .invoke_handler(tauri::generate_handler![ // Auth commands commands::init_client, @@ -359,6 +363,12 @@ pub fn run() { session_store::save_session_position, session_store::save_session_playback_mode, session_store::clear_session, + // Audio settings commands + config::audio_settings::get_audio_settings, + config::audio_settings::set_audio_output_device, + config::audio_settings::set_audio_exclusive_mode, + config::audio_settings::set_audio_dac_passthrough, + config::audio_settings::set_audio_sample_rate, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/static/apple-music-logo.svg b/static/apple-music-logo.svg new file mode 100644 index 000000000..f4f9aceea --- /dev/null +++ b/static/apple-music-logo.svg @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/static/deezer-logo.svg b/static/deezer-logo.svg new file mode 100644 index 000000000..d6293c390 --- /dev/null +++ b/static/deezer-logo.svg @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/static/qobuz-logo.svg b/static/qobuz-logo.svg new file mode 100644 index 000000000..01494e689 --- /dev/null +++ b/static/qobuz-logo.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/static/spotify-logo.svg b/static/spotify-logo.svg new file mode 100644 index 000000000..cf4b003f0 --- /dev/null +++ b/static/spotify-logo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/static/tidal-tidal.svg b/static/tidal-tidal.svg new file mode 100644 index 000000000..7487d81a3 --- /dev/null +++ b/static/tidal-tidal.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file