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
|
# 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
@@ -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
@@ -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;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user