diff --git a/app/frontend/js/api/audio.js b/app/frontend/js/api/audio.js new file mode 100644 index 0000000..d5bc303 --- /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 0f41be0..50a5c61 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'; @@ -17,7 +18,7 @@ import { lastfm } from './lastfm.js'; import { lyrics } from './lyrics.js'; import { settings } from './settings.js'; -export { favorites, lastfm, library, lyrics, playlists, queue, settings }; +export { audio, favorites, lastfm, library, lyrics, playlists, queue, settings }; /** * Unified API object (backward compatibility). @@ -28,6 +29,7 @@ export const api = { return request('/health'); }, + audio, library, lyrics, queue, diff --git a/app/frontend/js/components/settings-view.js b/app/frontend/js/components/settings-view.js index 37ccac6..f548c4a 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, @@ -162,6 +169,7 @@ export function createSettingsView(Alpine) { async init() { await this.loadAppInfo(); + await this.loadAudioDevices(); await this.loadWatchedFolders(); await this.loadLastfmSettings(); this.loadColumnSettings(); @@ -199,6 +207,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 fd36e80..893e934 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 0000000..be9fc15 --- /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 d61c527..9918e92 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 a3c7886..3e7be6a 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 `