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:
1 parent
f2d3b0e65b
commit
c80f2d9583
4 files changed
+170
-136
No files matched your search
+31
-107
@@ -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<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`
|
||||
|
||||
**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<AudioSettings>('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.
|
||||
+18
-2
@@ -47,9 +47,13 @@ pub struct AppState {
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Self::with_device(None)
|
||||
}
|
||||
|
||||
pub fn with_device(device_name: Option<String>) -> 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();
|
||||
|
||||
+62
-16
@@ -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<String>) -> Self {
|
||||
let (tx, rx) = mpsc::channel::<AudioCommand>();
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<AudioDevice[]>('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<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() {
|
||||
try {
|
||||
cacheStats = await invoke<CacheStats>('get_cache_stats');
|
||||
@@ -454,11 +499,14 @@
|
||||
<Toggle enabled={preferHighest} onchange={handlePreferHighestChange} />
|
||||
</div>
|
||||
<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
|
||||
value={outputDevice}
|
||||
options={audioDeviceOptions.length > 1 ? audioDeviceOptions : ['System Default']}
|
||||
onchange={(v) => (outputDevice = v)}
|
||||
onchange={handleOutputDeviceChange}
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
@@ -466,14 +514,14 @@
|
||||
<span class="setting-label">Exclusive Mode</span>
|
||||
<Tooltip text="Locks the audio device for exclusive use by QBZ for better quality" />
|
||||
</div>
|
||||
<Toggle enabled={exclusiveMode} onchange={(v) => (exclusiveMode = v)} />
|
||||
<Toggle enabled={exclusiveMode} onchange={handleExclusiveModeChange} />
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="label-with-tooltip">
|
||||
<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." />
|
||||
</div>
|
||||
<Toggle enabled={dacPassthrough} onchange={(v) => (dacPassthrough = v)} />
|
||||
<Toggle enabled={dacPassthrough} onchange={handleDacPassthroughChange} />
|
||||
</div>
|
||||
<div class="setting-row last">
|
||||
<span class="setting-label">Sample Rate</span>
|
||||
|
||||
Reference in new issue
Block a user