diff --git a/HANDOFF_AUDIO.md b/HANDOFF_AUDIO.md index 45f2e2368..fb7b27058 100644 --- a/HANDOFF_AUDIO.md +++ b/HANDOFF_AUDIO.md @@ -1,8 +1,8 @@ # HANDOFF: Audio Output Device Selection Implementation -## Status: Backend Settings Complete, Player Modification Pending +## Status: COMPLETE - Backend and Frontend Wired -### What's Done ✅ +### What's Done 1. **Audio Settings Storage** (`src-tauri/src/config/audio_settings.rs`) - SQLite persistence for: `output_device`, `exclusive_mode`, `dac_passthrough`, `preferred_sample_rate` @@ -11,120 +11,44 @@ 2. **Device Listing** (`src-tauri/src/commands/playback.rs:295-328`) - `get_audio_devices()` - Lists available audio devices using rodio/cpal -### What's Pending ❌ +3. **Player Device Selection** (`src-tauri/src/player/mod.rs`) + - `Player::new(device_name: Option)` - Accepts optional device name + - Uses `OutputStream::try_from_device()` for specific device + - Falls back to default device if specified device not found + - Falls back to `try_default()` if device-specific stream fails -#### 1. Modify Player to Use Selected Device +4. **AppState Integration** (`src-tauri/src/lib.rs`) + - `AppState::with_device(device_name)` - Creates state with specific audio device + - On app startup, reads saved device from audio settings + - Passes saved device to Player initialization -**File:** `src-tauri/src/player/mod.rs` - -**Current code (line 175):** -```rust -let (_stream, stream_handle) = OutputStream::try_default(); -``` - -**Needs to change to:** -```rust -use rodio::cpal::traits::{DeviceTrait, HostTrait}; - -// Get saved device preference -let settings = audio_settings_state.store.lock().unwrap().get_settings().unwrap_or_default(); - -let host = rodio::cpal::default_host(); -let device = if let Some(device_name) = &settings.output_device { - host.output_devices() - .ok() - .and_then(|mut devices| devices.find(|d| d.name().ok().as_ref() == Some(device_name))) - .unwrap_or_else(|| host.default_output_device().expect("No output device")) -} else { - host.default_output_device().expect("No output device") -}; - -let (_stream, stream_handle) = OutputStream::try_from_device(&device) - .map_err(|e| format!("Failed to create output stream: {}", e))?; -``` - -#### 2. Pass AudioSettingsState to Player - -**In `lib.rs`**, the Player is created in `AppState::new()` which doesn't have access to `audio_settings_state`. Options: - -A. Create Player after audio_settings_state and pass it: -```rust -let audio_settings_state = config::audio_settings::AudioSettingsState::new()?; -let player = Player::new_with_settings(audio_settings_state.store.clone()); -``` - -B. Or use a channel to send device change commands to the audio thread. - -#### 3. Runtime Device Switching (Optional but Nice) - -Add to `AudioCommand` enum: -```rust -enum AudioCommand { - // ... existing - SelectDevice(String), -} -``` - -Handle in audio thread to switch device without restart. - -#### 4. Frontend Integration - -**File:** `src/lib/components/views/SettingsView.svelte` - -Currently the settings UI exists but doesn't save. Add: -```typescript -import { invoke } from '@tauri-apps/api/core'; - -// When outputDevice changes: -async function handleOutputDeviceChange(device: string) { - outputDevice = device; - await invoke('set_audio_output_device', { - device: device === 'System Default' ? null : device - }); -} - -// When exclusiveMode changes: -async function handleExclusiveModeChange(enabled: boolean) { - exclusiveMode = enabled; - await invoke('set_audio_exclusive_mode', { enabled }); -} - -// Similar for dacPassthrough -``` - -Also load settings on mount: -```typescript -async function loadAudioSettings() { - const settings = await invoke('get_audio_settings'); - outputDevice = settings.output_device ?? 'System Default'; - exclusiveMode = settings.exclusive_mode; - dacPassthrough = settings.dac_passthrough; -} -``` +5. **Frontend Integration** (`src/lib/components/views/SettingsView.svelte`) + - `loadAudioSettings()` - Loads saved settings on mount + - `handleOutputDeviceChange()` - Saves device to backend + - `handleExclusiveModeChange()` - Saves exclusive mode to backend + - `handleDacPassthroughChange()` - Saves DAC passthrough to backend + - UI shows tooltip explaining changes take effect on next playback ### Testing Checklist -1. [ ] Select specific device in Settings +1. [ ] Select specific device in Settings - should save to database 2. [ ] Close and reopen app - device should persist 3. [ ] Play audio - should go to selected device -4. [ ] Toggle exclusive mode - verify behavior -5. [ ] Test with external DAC (when available Monday) +4. [ ] Toggle exclusive mode - verify behavior (Linux/ALSA specific) +5. [ ] Test with external DAC (user has DAC on Monday) -### Files to Modify +### Important Notes -| File | Changes | -|------|---------| -| `src-tauri/src/player/mod.rs` | Use device from settings, modify Player::new() | -| `src-tauri/src/lib.rs` | Pass audio_settings to Player | -| `src/lib/components/views/SettingsView.svelte` | Wire up UI to backend commands | +- **Device changes take effect on app restart** - The Player is created once at startup with the saved device. Changing the device in settings saves it for the next session. +- **Exclusive mode on Linux** requires ALSA configuration, not just app-side changes +- **DAC passthrough** may need specific sample rate matching +- The app logs the audio device being used on startup (check terminal output) -### Dependencies +### Future Enhancement: Runtime Device Switching -- rodio 0.19 supports `OutputStream::try_from_device()` ✅ -- cpal traits already imported for device listing ✅ +To switch devices without restart, would need to add an `AudioCommand::SelectDevice` variant and recreate the output stream in the audio thread. This is more complex because: +- Need to recreate OutputStream +- Need to handle any currently playing audio +- Need to preserve playback position if switching mid-track -### Notes - -- Exclusive mode on Linux requires ALSA configuration, not just app-side changes -- DAC passthrough may need specific sample rate matching -- User doesn't have DAC until Monday for testing +This is optional and can be added later if needed. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e023da1a2..b00b2080f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -47,9 +47,13 @@ pub struct AppState { impl AppState { pub fn new() -> Self { + Self::with_device(None) + } + + pub fn with_device(device_name: Option) -> Self { Self { client: Arc::new(Mutex::new(QobuzClient::default())), - player: Player::new(), + player: Player::new(device_name), queue: QueueManager::new(), media_controls: MediaControlsManager::new(), audio_cache: Arc::new(AudioCache::default()), @@ -128,11 +132,23 @@ pub fn run() { let audio_settings_state = config::audio_settings::AudioSettingsState::new() .expect("Failed to initialize audio settings"); + // Read saved audio device setting for player initialization + let saved_device = audio_settings_state + .store + .lock() + .ok() + .and_then(|store| store.get_settings().ok()) + .and_then(|settings| settings.output_device); + + if let Some(ref device) = saved_device { + log::info!("Initializing player with saved device: {}", device); + } + tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_clipboard_manager::init()) - .manage(AppState::new()) + .manage(AppState::with_device(saved_device)) .setup(|app| { // Start background task to emit playback events let app_handle = app.handle().clone(); diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index dba20c401..2961989cc 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -16,6 +16,7 @@ use std::sync::Arc; use std::thread; use std::time::Duration; use rodio::{Decoder, OutputStream, Sink, Source}; +use rodio::cpal::traits::{DeviceTrait, HostTrait}; use crate::api::{client::QobuzClient, models::Quality}; @@ -157,12 +158,14 @@ pub struct Player { impl Default for Player { fn default() -> Self { - Self::new() + Self::new(None) } } impl Player { - pub fn new() -> Self { + /// Create a new player with an optional specific output device + /// If device_name is None, uses the system default device + pub fn new(device_name: Option) -> Self { let (tx, rx) = mpsc::channel::(); let state = SharedState::new(); let thread_state = state.clone(); @@ -171,28 +174,71 @@ impl Player { thread::spawn(move || { log::info!("Audio thread starting..."); - // Create output stream on this thread (it must stay here) - let (_stream, stream_handle) = match OutputStream::try_default() { + // Get the audio host + let host = rodio::cpal::default_host(); + + // Find the requested device or use default + let device = if let Some(ref name) = device_name { + log::info!("Looking for audio device: {}", name); + host.output_devices() + .ok() + .and_then(|mut devices| { + devices.find(|d| d.name().ok().as_ref() == Some(name)) + }) + .or_else(|| { + log::warn!("Device '{}' not found, using default", name); + host.default_output_device() + }) + } else { + log::info!("Using default audio device"); + host.default_output_device() + }; + + let device = match device { + Some(d) => { + if let Ok(name) = d.name() { + log::info!("Using audio device: {}", name); + } + d + } + None => { + log::error!("No audio output device available"); + // Keep thread alive to receive commands + loop { + match rx.recv() { + Ok(_) => log::warn!("Audio command received but no device available"), + Err(_) => break, + } + } + return; + } + }; + + // Create output stream on the selected device + let (_stream, stream_handle) = match OutputStream::try_from_device(&device) { Ok(s) => { log::info!("Audio output initialized successfully"); s } Err(e) => { - log::error!("Failed to create audio output: {}. Audio playback will not work.", e); - // Keep the thread alive to receive commands (even if we can't play) - // This prevents the channel from closing immediately - loop { - match rx.recv() { - Ok(_) => { - log::warn!("Audio command received but audio output is unavailable"); - } - Err(_) => { - log::info!("Audio thread: channel closed, exiting"); - break; + log::error!("Failed to create audio output on device: {}. Trying default...", e); + // Fallback to default + match OutputStream::try_default() { + Ok(s) => { + log::info!("Fallback to default audio output succeeded"); + s + } + Err(e2) => { + log::error!("Failed to create default audio output: {}", e2); + loop { + match rx.recv() { + Ok(_) => log::warn!("Audio command received but audio output is unavailable"), + Err(_) => break, + } } + return; } } - return; } }; diff --git a/src/lib/components/views/SettingsView.svelte b/src/lib/components/views/SettingsView.svelte index ec25443d2..2bd99841e 100644 --- a/src/lib/components/views/SettingsView.svelte +++ b/src/lib/components/views/SettingsView.svelte @@ -127,8 +127,9 @@ // Load download cache stats loadDownloadStats(); - // Load audio devices + // Load audio devices and settings loadAudioDevices(); + loadAudioSettings(); // Load Last.fm state loadLastfmState(); @@ -275,21 +276,65 @@ localStorage.setItem('qbz-language', lang); } + interface AudioSettings { + output_device: string | null; + exclusive_mode: boolean; + dac_passthrough: boolean; + preferred_sample_rate: number | null; + } + async function loadAudioDevices() { try { const devices = await invoke('get_audio_devices'); audioDevices = devices; - - // Set current device to the default one - const defaultDevice = devices.find(d => d.is_default); - if (defaultDevice) { - outputDevice = defaultDevice.name; - } } catch (err) { console.error('Failed to load audio devices:', err); } } + async function loadAudioSettings() { + try { + const settings = await invoke('get_audio_settings'); + outputDevice = settings.output_device ?? 'System Default'; + exclusiveMode = settings.exclusive_mode; + dacPassthrough = settings.dac_passthrough; + } catch (err) { + console.error('Failed to load audio settings:', err); + } + } + + async function handleOutputDeviceChange(device: string) { + outputDevice = device; + try { + await invoke('set_audio_output_device', { + device: device === 'System Default' ? null : device + }); + console.log('Audio output device saved:', device); + } catch (err) { + console.error('Failed to save audio output device:', err); + } + } + + async function handleExclusiveModeChange(enabled: boolean) { + exclusiveMode = enabled; + try { + await invoke('set_audio_exclusive_mode', { enabled }); + console.log('Exclusive mode saved:', enabled); + } catch (err) { + console.error('Failed to save exclusive mode:', err); + } + } + + async function handleDacPassthroughChange(enabled: boolean) { + dacPassthrough = enabled; + try { + await invoke('set_audio_dac_passthrough', { enabled }); + console.log('DAC passthrough saved:', enabled); + } catch (err) { + console.error('Failed to save DAC passthrough:', err); + } + } + async function loadCacheStats() { try { cacheStats = await invoke('get_cache_stats'); @@ -454,11 +499,14 @@
- Output Device +
+ Output Device + +
1 ? audioDeviceOptions : ['System Default']} - onchange={(v) => (outputDevice = v)} + onchange={handleOutputDeviceChange} />
@@ -466,14 +514,14 @@ Exclusive Mode
- (exclusiveMode = v)} /> +
DAC Passthrough
- (dacPassthrough = v)} /> +
Sample Rate