From 51eef46f7434a36c95c548c49b17d39c551a1e8c Mon Sep 17 00:00:00 2001 From: pythoninthegrass <4097471+pythoninthegrass@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:39:46 -0600 Subject: [PATCH 1/2] feat(audio): add output device selection in Settings > Audio Enumerate available audio output devices via cpal (through rodio) and present them in a dropdown under Settings > Audio. Switching devices preserves current playback position by reloading the track on the new output stream. Selection persists in the Tauri settings store and restores on startup with fallback to system default. Backend: AudioEngine::set_device(), list_output_devices(), two new Tauri commands (audio_list_devices, audio_set_device), DeviceListResponse type. Frontend: api/audio.js module, Audio nav section in settings view, select dropdown with Default + enumerated devices. Tests: 10 new Rust unit tests, 6 new Playwright E2E tests. Closes TASK-268 Co-Authored-By: Claude Opus 4.6 --- app/frontend/js/api/audio.js | 42 +++++ app/frontend/js/api/index.js | 4 +- app/frontend/js/components/settings-view.js | 40 ++++ app/frontend/js/stores/ui.js | 1 + app/frontend/tests/settings-audio.spec.js | 165 +++++++++++++++++ app/frontend/views/settings.html | 28 +++ ...268 - Add-audio-output-device-selection.md | 105 ++++++++++- crates/mt-tauri/src/audio/audio_error.rs | 3 + crates/mt-tauri/src/audio/engine.rs | 116 +++++++++++- crates/mt-tauri/src/audio/engine_test.rs | 70 +++++++ crates/mt-tauri/src/audio/mod.rs | 2 +- crates/mt-tauri/src/commands/audio.rs | 173 +++++++++++++++++- crates/mt-tauri/src/commands/mod.rs | 5 +- crates/mt-tauri/src/lib.rs | 20 +- 14 files changed, 748 insertions(+), 26 deletions(-) create mode 100644 app/frontend/js/api/audio.js create mode 100644 app/frontend/tests/settings-audio.spec.js diff --git a/app/frontend/js/api/audio.js b/app/frontend/js/api/audio.js new file mode 100644 index 00000000..d5bc303a --- /dev/null +++ b/app/frontend/js/api/audio.js @@ -0,0 +1,42 @@ +/** + * Audio API + * + * Audio output device enumeration and selection via Tauri commands. + */ + +import { ApiError, invoke } from './shared.js'; + +export const audio = { + /** + * List available audio output devices + * @returns {Promise<{devices: string[]}>} + */ + async listDevices() { + if (invoke) { + try { + return await invoke('audio_list_devices'); + } catch (error) { + console.error('[api.audio.listDevices] Tauri error:', error); + throw new ApiError(500, error.toString()); + } + } + // No HTTP fallback — audio device selection requires Tauri runtime + return { devices: [] }; + }, + + /** + * Set the audio output device + * @param {string|null} deviceName - Device name, or null for system default + * @returns {Promise} + */ + async setDevice(deviceName) { + if (invoke) { + try { + return await invoke('audio_set_device', { deviceName }); + } catch (error) { + console.error('[api.audio.setDevice] Tauri error:', error); + throw new ApiError(500, error.toString()); + } + } + }, +}; diff --git a/app/frontend/js/api/index.js b/app/frontend/js/api/index.js index 34b63b78..1cbc4d14 100644 --- a/app/frontend/js/api/index.js +++ b/app/frontend/js/api/index.js @@ -9,6 +9,7 @@ import { request } from './shared.js'; export { ApiError } from './shared.js'; +import { audio } from './audio.js'; import { library } from './library.js'; import { queue } from './queue.js'; import { favorites } from './favorites.js'; @@ -16,7 +17,7 @@ import { playlists } from './playlists.js'; import { lastfm } from './lastfm.js'; import { settings } from './settings.js'; -export { favorites, lastfm, library, playlists, queue, settings }; +export { audio, favorites, lastfm, library, playlists, queue, settings }; /** * Unified API object (backward compatibility). @@ -27,6 +28,7 @@ export const api = { return request('/health'); }, + audio, library, queue, favorites, diff --git a/app/frontend/js/components/settings-view.js b/app/frontend/js/components/settings-view.js index aa60e8d8..3b111182 100644 --- a/app/frontend/js/components/settings-view.js +++ b/app/frontend/js/components/settings-view.js @@ -1,4 +1,6 @@ +import { audio } from '../api/audio.js'; import { lastfm } from '../api/lastfm.js'; +import { settings } from '../api/settings.js'; import { modLabel, SHORTCUT_DEFINITIONS } from '../shortcuts.js'; export function createSettingsView(Alpine) { @@ -11,6 +13,7 @@ export function createSettingsView(Alpine) { navSections: [ { id: 'general', label: 'General' }, + { id: 'audio', label: 'Audio' }, { id: 'appearance', label: 'Appearance' }, { id: 'library', label: 'Library' }, { id: 'columns', label: 'Columns' }, @@ -46,6 +49,10 @@ export function createSettingsView(Alpine) { progress: null, }, + audioDevices: [], + selectedAudioDevice: 'default', + audioDevicesLoading: false, + // Column settings for Settings > Columns section columnSettings: { visibleCount: 0, @@ -160,6 +167,7 @@ export function createSettingsView(Alpine) { async init() { await this.loadAppInfo(); + await this.loadAudioDevices(); await this.loadWatchedFolders(); await this.loadLastfmSettings(); this.loadColumnSettings(); @@ -193,6 +201,38 @@ export function createSettingsView(Alpine) { } }, + async loadAudioDevices() { + this.audioDevicesLoading = true; + try { + const response = await audio.listDevices(); + this.audioDevices = response.devices || []; + + // Load saved device selection + const saved = await settings.get('audio_output_device'); + if (saved && saved.value && saved.value !== 'default') { + this.selectedAudioDevice = saved.value; + } else { + this.selectedAudioDevice = 'default'; + } + } catch (error) { + console.error('[settings] Failed to load audio devices:', error); + this.audioDevices = []; + } finally { + this.audioDevicesLoading = false; + } + }, + + async setAudioDevice(deviceName) { + const previous = this.selectedAudioDevice; + this.selectedAudioDevice = deviceName; + try { + await audio.setDevice(deviceName === 'default' ? null : deviceName); + } catch (error) { + console.error('[settings] Failed to set audio device:', error); + this.selectedAudioDevice = previous; + } + }, + async loadWatchedFolders() { if (!window.__TAURI__) return; diff --git a/app/frontend/js/stores/ui.js b/app/frontend/js/stores/ui.js index fd36e805..893e9340 100644 --- a/app/frontend/js/stores/ui.js +++ b/app/frontend/js/stores/ui.js @@ -156,6 +156,7 @@ export function createUIStore(Alpine) { if ( [ 'general', + 'audio', 'library', 'appearance', 'columns', diff --git a/app/frontend/tests/settings-audio.spec.js b/app/frontend/tests/settings-audio.spec.js new file mode 100644 index 00000000..be9fc159 --- /dev/null +++ b/app/frontend/tests/settings-audio.spec.js @@ -0,0 +1,165 @@ +import { expect, test } from '@playwright/test'; +import { waitForAlpine } from './fixtures/helpers.js'; + +test.describe('Audio Settings UI', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await waitForAlpine(page); + + await page.click('[data-testid="sidebar-settings"]'); + await page.waitForSelector('[data-testid="settings-nav-audio"]', { + state: 'visible', + }); + }); + + test('should display Audio nav item in settings sidebar', async ({ page }) => { + const audioNav = page.locator('[data-testid="settings-nav-audio"]'); + await expect(audioNav).toBeVisible(); + await expect(audioNav).toHaveText('Audio'); + }); + + test('should navigate to Audio section when clicked', async ({ page }) => { + await page.click('[data-testid="settings-nav-audio"]'); + const audioSection = page.locator( + '[data-testid="settings-section-audio"]', + ); + await expect(audioSection).toBeVisible(); + }); + + test('should display device selector with Default option', async ({ page }) => { + await page.click('[data-testid="settings-nav-audio"]'); + + const select = page.locator('[data-testid="audio-device-select"]'); + await expect(select).toBeVisible(); + + const defaultOption = select.locator('option[value="default"]'); + await expect(defaultOption).toHaveText('Default'); + }); +}); + +test.describe('Audio Settings with Mocked Tauri', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + const mockDevices = ['Built-in Output', 'External DAC']; + let selectedDevice = 'default'; + window.__tauriInvocations = []; + + window.__TAURI__ = { + core: { + invoke: (cmd, args) => { + window.__tauriInvocations.push({ cmd, args }); + if (cmd === 'audio_list_devices') { + return Promise.resolve({ devices: mockDevices }); + } + if (cmd === 'audio_set_device') { + selectedDevice = args?.deviceName || 'default'; + return Promise.resolve(null); + } + if (cmd === 'app_get_info') { + return Promise.resolve({ version: 'test', build: 'test', platform: 'test' }); + } + if (cmd === 'watched_folders_list') { + return Promise.resolve([]); + } + if (cmd === 'lastfm_get_settings') { + return Promise.resolve({ + enabled: false, + authenticated: false, + scrobble_threshold: 90, + }); + } + if (cmd === 'settings_get') { + if (args?.key === 'audio_output_device') { + return Promise.resolve({ key: 'audio_output_device', value: selectedDevice }); + } + return Promise.resolve({ key: args?.key, value: null }); + } + if (cmd === 'settings_set') { + return Promise.resolve({ key: args?.key, value: args?.value }); + } + return Promise.resolve(null); + }, + }, + event: { + listen: () => Promise.resolve(() => {}), + }, + dialog: { + confirm: () => Promise.resolve(true), + }, + }; + }); + + await page.goto('/'); + await waitForAlpine(page); + + await page.click('[data-testid="sidebar-settings"]'); + await page.waitForSelector('[data-testid="settings-nav-audio"]', { + state: 'visible', + }); + await page.click('[data-testid="settings-nav-audio"]'); + }); + + test('should list mocked audio devices in dropdown', async ({ page }) => { + const select = page.locator('[data-testid="audio-device-select"]'); + await expect(select).toBeVisible(); + + const options = select.locator('option'); + // Default + 2 mocked devices = 3 options + await expect(options).toHaveCount(3); + + await expect(options.nth(0)).toHaveText('Default'); + await expect(options.nth(1)).toHaveText('Built-in Output'); + await expect(options.nth(2)).toHaveText('External DAC'); + }); + + test('should call audio_set_device when device is selected', async ({ page }) => { + // Clear prior invocations from init + await page.evaluate(() => { + window.__tauriInvocations = []; + }); + + const select = page.locator('[data-testid="audio-device-select"]'); + await select.selectOption('External DAC'); + + await page.waitForFunction( + () => + window.__tauriInvocations.some( + (inv) => inv.cmd === 'audio_set_device', + ), + { timeout: 5000 }, + ); + + const setDeviceCall = await page.evaluate(() => + window.__tauriInvocations.find((inv) => inv.cmd === 'audio_set_device') + ); + expect(setDeviceCall).toBeDefined(); + expect(setDeviceCall.args.deviceName).toBe('External DAC'); + }); + + test('should send null deviceName when Default is selected', async ({ page }) => { + // First select a non-default device + const select = page.locator('[data-testid="audio-device-select"]'); + await select.selectOption('Built-in Output'); + + // Clear invocations and select default + await page.evaluate(() => { + window.__tauriInvocations = []; + }); + + await select.selectOption('default'); + + await page.waitForFunction( + () => + window.__tauriInvocations.some( + (inv) => inv.cmd === 'audio_set_device', + ), + { timeout: 5000 }, + ); + + const setDeviceCall = await page.evaluate(() => + window.__tauriInvocations.find((inv) => inv.cmd === 'audio_set_device') + ); + expect(setDeviceCall).toBeDefined(); + expect(setDeviceCall.args.deviceName).toBeNull(); + }); +}); diff --git a/app/frontend/views/settings.html b/app/frontend/views/settings.html index d61c5272..9918e92f 100644 --- a/app/frontend/views/settings.html +++ b/app/frontend/views/settings.html @@ -36,6 +36,34 @@

General

+ +
+

Audio

+
+
+ +

+ Select the audio output device for playback. +

+ +
+
+
+ {{> settings-library}} diff --git a/backlog/tasks/task-268 - Add-audio-output-device-selection.md b/backlog/tasks/task-268 - Add-audio-output-device-selection.md index a3c7886b..3e7be6a1 100644 --- a/backlog/tasks/task-268 - Add-audio-output-device-selection.md +++ b/backlog/tasks/task-268 - Add-audio-output-device-selection.md @@ -1,10 +1,11 @@ --- id: TASK-268 title: Add audio output device selection -status: In Progress -assignee: [] +status: Done +assignee: + - claude created_date: '2026-02-16 15:08' -updated_date: '2026-03-08 02:03' +updated_date: '2026-03-08 02:50' labels: - audio - feature @@ -32,11 +33,95 @@ Use cpal (already in dependency tree via rodio) to enumerate devices via `cpal:: ## Acceptance Criteria -- [ ] #1 Device selector dropdown in settings view lists available output devices -- [ ] #2 Switching device preserves current playback position -- [ ] #3 Selected device persisted in settings and restored on startup -- [ ] #4 Falls back to default device if saved device unavailable -- [ ] #5 New audio_list_devices and audio_set_device Tauri commands -- [ ] #6 Rust unit tests for device enumeration and switching -- [ ] #7 Playwright E2E test for device selector in settings +- [x] #1 Device selector dropdown in settings view lists available output devices +- [x] #2 Switching device preserves current playback position +- [x] #3 Selected device persisted in settings and restored on startup +- [x] #4 Falls back to default device if saved device unavailable +- [x] #5 New audio_list_devices and audio_set_device Tauri commands +- [x] #6 Rust unit tests for device enumeration and switching +- [x] #7 Playwright E2E test for device selector in settings + +## Implementation Plan + + +## Implementation Plan + +### Overview +Add device selector dropdown under Settings > Audio that lists available output devices (with "Default" first), switches output immediately, persists choice, falls back to default if saved device unavailable. + +### Key Files + +**Rust backend:** +1. `crates/mt-tauri/src/audio/audio_error.rs` - Add `Device` error variant +2. `crates/mt-tauri/src/audio/engine.rs` - Add `set_device()` method, device enumeration +3. `crates/mt-tauri/src/commands/audio.rs` - Add `AudioCommand::ListDevices` / `SetDevice`, new Tauri commands +4. `crates/mt-tauri/src/lib.rs` - Register new commands + +**Frontend:** +5. `app/frontend/js/api/audio.js` (new) - `listDevices()` and `setDevice()` API calls +6. `app/frontend/js/api/index.js` - Re-export audio API +7. `app/frontend/js/components/settings-view.js` - Audio section state/methods +8. `app/frontend/views/settings.html` - Audio section HTML with dropdown + +**Tests:** +9. `crates/mt-tauri/src/commands/audio.rs` (tests module) - Unit tests for new types +10. `app/frontend/tests/settings-audio.spec.js` - Playwright E2E + +### Steps + +#### Step 1: Rust - AudioError + AudioEngine device support +- Add `Device(String)` to `AudioError` +- Add `set_device(&mut self, name: Option<&str>)` to `AudioEngine` that enumerates devices via `rodio::cpal`, finds match, creates new OutputStream, re-attaches playback +- Add `list_devices()` standalone fn returning Vec + +#### Step 2: Rust - AudioCommand + Tauri commands +- Add `ListDevices(Sender>)` and `SetDevice(Option, Sender>)` to AudioCommand +- Handle in audio_thread match +- Add `audio_list_devices` and `audio_set_device` Tauri command fns +- Register in lib.rs invoke_handler +- Persist selection via AppHandle settings store on SetDevice +- On audio_thread startup: read saved device, apply if available + +#### Step 3: Rust unit tests +- Test AudioCommand new variants +- Test device list response serialization +- Test PlaybackStatus serialization unchanged + +#### Step 4: Frontend - API + Settings UI +- New `api/audio.js` with listDevices/setDevice +- Add "Audio" nav section in settings-view.js +- Add Audio section in settings.html with select dropdown +- On mount: fetch device list + saved setting +- On change: call setDevice, update settings store + +#### Step 5: Playwright E2E test +- Navigate to Settings > Audio +- Verify dropdown with "Default" option +- Mock device list, verify selection triggers command + +### Design Decisions +- No cpal dep needed: rodio re-exports cpal types +- Device switch = capture position, new stream, reload track, seek (brief gap acceptable) +- Settings key: `audio_output_device` in Tauri Store via existing settings_get/set +- "default" sentinel string to distinguish from unconfigured + + +## Implementation Notes + + +## Implementation Notes (2026-03-07) + +- Used rodio's re-exported cpal types (`rodio::cpal::traits::{DeviceTrait, HostTrait}`) for device enumeration rather than adding cpal as a separate dependency +- Device switching preserves playback position by capturing state (position_ms, was_playing, track_info), creating new OutputStream, reloading the track on new stream's mixer, seeking to saved position +- Settings persistence uses the existing Tauri Store plugin (`mt-settings.json`) with key `audio_output_device` - the same store used by all other settings +- Audio thread restores saved device on startup with fallback to default if the saved device is unavailable +- The `setAudioDevice` frontend method passes `null` for "Default" (maps to `None` in Rust), which triggers `OutputStreamBuilder::open_default_stream()` +- Added 'audio' to the UI store's `setSettingsSection` whitelist to enable navigation + + +## Final Summary + + +## Audio Output Device Selection\n\nAdd Settings > Audio section with device selector dropdown that enumerates available output devices via cpal (through rodio), switches output immediately while preserving playback position, and persists the selection across restarts.\n\n### Changes\n\n**Rust backend (4 files modified):**\n- `audio/audio_error.rs`: Added `Device(String)` error variant\n- `audio/engine.rs`: Added `list_output_devices()` function and `AudioEngine::set_device()` method that creates new OutputStream on selected device, reloads current track preserving position/state\n- `commands/audio.rs`: Added `AudioCommand::ListDevices`/`SetDevice` variants, `audio_list_devices`/`audio_set_device` Tauri commands, `DeviceListResponse` struct, saved device restoration on audio thread startup\n- `lib.rs` + `commands/mod.rs`: Registered and re-exported new commands\n\n**Frontend (5 files modified/created):**\n- `api/audio.js` (new): `listDevices()` and `setDevice()` API functions\n- `api/index.js`: Re-exported audio API module\n- `components/settings-view.js`: Audio section state, `loadAudioDevices()`/`setAudioDevice()` methods, \"Audio\" nav entry\n- `views/settings.html`: Audio section with labeled `