Wire audio device selection from settings to player

- Player::new() now accepts optional device name
- Uses OutputStream::try_from_device() for specific device
- AppState reads saved device from audio settings on startup
- SettingsView loads/saves audio settings to backend
- Changes take effect on app restart
This commit is contained in:
vicrodh committed 2026-01-11 03:54:56 -06:00
1 parent f2d3b0e65b
commit c80f2d9583
4 files changed
+170 -136

No files matched your search

+31 -107
View File
@@ -1,8 +1,8 @@
# HANDOFF: Audio Output Device Selection Implementation # 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`) 1. **Audio Settings Storage** (`src-tauri/src/config/audio_settings.rs`)
- SQLite persistence for: `output_device`, `exclusive_mode`, `dac_passthrough`, `preferred_sample_rate` - 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`) 2. **Device Listing** (`src-tauri/src/commands/playback.rs:295-328`)
- `get_audio_devices()` - Lists available audio devices using rodio/cpal - `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<String>)` - 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` 5. **Frontend Integration** (`src/lib/components/views/SettingsView.svelte`)
- `loadAudioSettings()` - Loads saved settings on mount
**Current code (line 175):** - `handleOutputDeviceChange()` - Saves device to backend
```rust - `handleExclusiveModeChange()` - Saves exclusive mode to backend
let (_stream, stream_handle) = OutputStream::try_default(); - `handleDacPassthroughChange()` - Saves DAC passthrough to backend
``` - UI shows tooltip explaining changes take effect on next playback
**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<AudioSettings>('get_audio_settings');
outputDevice = settings.output_device ?? 'System Default';
exclusiveMode = settings.exclusive_mode;
dacPassthrough = settings.dac_passthrough;
}
```
### Testing Checklist ### 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 2. [ ] Close and reopen app - device should persist
3. [ ] Play audio - should go to selected device 3. [ ] Play audio - should go to selected device
4. [ ] Toggle exclusive mode - verify behavior 4. [ ] Toggle exclusive mode - verify behavior (Linux/ALSA specific)
5. [ ] Test with external DAC (when available Monday) 5. [ ] Test with external DAC (user has DAC on Monday)
### Files to Modify ### Important Notes
| File | Changes | - **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
| `src-tauri/src/player/mod.rs` | Use device from settings, modify Player::new() | - **DAC passthrough** may need specific sample rate matching
| `src-tauri/src/lib.rs` | Pass audio_settings to Player | - The app logs the audio device being used on startup (check terminal output)
| `src/lib/components/views/SettingsView.svelte` | Wire up UI to backend commands |
### Dependencies ### Future Enhancement: Runtime Device Switching
- rodio 0.19 supports `OutputStream::try_from_device()` ✅ 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:
- cpal traits already imported for device listing ✅ - Need to recreate OutputStream
- Need to handle any currently playing audio
- Need to preserve playback position if switching mid-track
### Notes This is optional and can be added later if needed.
- 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
+18 -2
View File
@@ -47,9 +47,13 @@ pub struct AppState {
impl AppState { impl AppState {
pub fn new() -> Self { pub fn new() -> Self {
Self::with_device(None)
}
pub fn with_device(device_name: Option<String>) -> Self {
Self { Self {
client: Arc::new(Mutex::new(QobuzClient::default())), client: Arc::new(Mutex::new(QobuzClient::default())),
player: Player::new(), player: Player::new(device_name),
queue: QueueManager::new(), queue: QueueManager::new(),
media_controls: MediaControlsManager::new(), media_controls: MediaControlsManager::new(),
audio_cache: Arc::new(AudioCache::default()), audio_cache: Arc::new(AudioCache::default()),
@@ -128,11 +132,23 @@ pub fn run() {
let audio_settings_state = config::audio_settings::AudioSettingsState::new() let audio_settings_state = config::audio_settings::AudioSettingsState::new()
.expect("Failed to initialize audio settings"); .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() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_clipboard_manager::init())
.manage(AppState::new()) .manage(AppState::with_device(saved_device))
.setup(|app| { .setup(|app| {
// Start background task to emit playback events // Start background task to emit playback events
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
+62 -16
View File
@@ -16,6 +16,7 @@ use std::sync::Arc;
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
use rodio::{Decoder, OutputStream, Sink, Source}; use rodio::{Decoder, OutputStream, Sink, Source};
use rodio::cpal::traits::{DeviceTrait, HostTrait};
use crate::api::{client::QobuzClient, models::Quality}; use crate::api::{client::QobuzClient, models::Quality};
@@ -157,12 +158,14 @@ pub struct Player {
impl Default for Player { impl Default for Player {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new(None)
} }
} }
impl Player { 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<String>) -> Self {
let (tx, rx) = mpsc::channel::<AudioCommand>(); let (tx, rx) = mpsc::channel::<AudioCommand>();
let state = SharedState::new(); let state = SharedState::new();
let thread_state = state.clone(); let thread_state = state.clone();
@@ -171,28 +174,71 @@ impl Player {
thread::spawn(move || { thread::spawn(move || {
log::info!("Audio thread starting..."); log::info!("Audio thread starting...");
// Create output stream on this thread (it must stay here) // Get the audio host
let (_stream, stream_handle) = match OutputStream::try_default() { 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) => { Ok(s) => {
log::info!("Audio output initialized successfully"); log::info!("Audio output initialized successfully");
s s
} }
Err(e) => { Err(e) => {
log::error!("Failed to create audio output: {}. Audio playback will not work.", e); log::error!("Failed to create audio output on device: {}. Trying default...", e);
// Keep the thread alive to receive commands (even if we can't play) // Fallback to default
// This prevents the channel from closing immediately match OutputStream::try_default() {
loop { Ok(s) => {
match rx.recv() { log::info!("Fallback to default audio output succeeded");
Ok(_) => { s
log::warn!("Audio command received but audio output is unavailable"); }
} Err(e2) => {
Err(_) => { log::error!("Failed to create default audio output: {}", e2);
log::info!("Audio thread: channel closed, exiting"); loop {
break; match rx.recv() {
Ok(_) => log::warn!("Audio command received but audio output is unavailable"),
Err(_) => break,
}
} }
return;
} }
} }
return;
} }
}; };
+59 -11
View File
@@ -127,8 +127,9 @@
// Load download cache stats // Load download cache stats
loadDownloadStats(); loadDownloadStats();
// Load audio devices // Load audio devices and settings
loadAudioDevices(); loadAudioDevices();
loadAudioSettings();
// Load Last.fm state // Load Last.fm state
loadLastfmState(); loadLastfmState();
@@ -275,21 +276,65 @@
localStorage.setItem('qbz-language', lang); 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() { async function loadAudioDevices() {
try { try {
const devices = await invoke<AudioDevice[]>('get_audio_devices'); const devices = await invoke<AudioDevice[]>('get_audio_devices');
audioDevices = 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) { } catch (err) {
console.error('Failed to load audio devices:', err); console.error('Failed to load audio devices:', err);
} }
} }
async function loadAudioSettings() {
try {
const settings = await invoke<AudioSettings>('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() { async function loadCacheStats() {
try { try {
cacheStats = await invoke<CacheStats>('get_cache_stats'); cacheStats = await invoke<CacheStats>('get_cache_stats');
@@ -454,11 +499,14 @@
<Toggle enabled={preferHighest} onchange={handlePreferHighestChange} /> <Toggle enabled={preferHighest} onchange={handlePreferHighestChange} />
</div> </div>
<div class="setting-row"> <div class="setting-row">
<span class="setting-label">Output Device</span> <div class="label-with-tooltip">
<span class="setting-label">Output Device</span>
<Tooltip text="Select your preferred audio output device. Changes take effect on next playback." />
</div>
<Dropdown <Dropdown
value={outputDevice} value={outputDevice}
options={audioDeviceOptions.length > 1 ? audioDeviceOptions : ['System Default']} options={audioDeviceOptions.length > 1 ? audioDeviceOptions : ['System Default']}
onchange={(v) => (outputDevice = v)} onchange={handleOutputDeviceChange}
/> />
</div> </div>
<div class="setting-row"> <div class="setting-row">
@@ -466,14 +514,14 @@
<span class="setting-label">Exclusive Mode</span> <span class="setting-label">Exclusive Mode</span>
<Tooltip text="Locks the audio device for exclusive use by QBZ for better quality" /> <Tooltip text="Locks the audio device for exclusive use by QBZ for better quality" />
</div> </div>
<Toggle enabled={exclusiveMode} onchange={(v) => (exclusiveMode = v)} /> <Toggle enabled={exclusiveMode} onchange={handleExclusiveModeChange} />
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="label-with-tooltip"> <div class="label-with-tooltip">
<span class="setting-label">DAC Passthrough</span> <span class="setting-label">DAC Passthrough</span>
<Tooltip text="Bypass the system audio mixer to send audio directly to your DAC at its native sample rate. Recommended for external DACs." /> <Tooltip text="Bypass the system audio mixer to send audio directly to your DAC at its native sample rate. Recommended for external DACs." />
</div> </div>
<Toggle enabled={dacPassthrough} onchange={(v) => (dacPassthrough = v)} /> <Toggle enabled={dacPassthrough} onchange={handleDacPassthroughChange} />
</div> </div>
<div class="setting-row last"> <div class="setting-row last">
<span class="setting-label">Sample Rate</span> <span class="setting-label">Sample Rate</span>