Skip to content

Commit d5c9f35

Browse files
feat(audio): add output device selection in Settings > Audio (#26)
* 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 <noreply@anthropic.com> * fix(audio): drop old stream before creating new one on device switch On macOS CoreAudio, two simultaneous OutputStream instances targeting the same physical device (e.g. switching from named "Mac Studio Speakers" to "Default" which resolves to the same hardware) causes the new stream to produce silence. Change stream field to Option<OutputStream> so the old stream is fully released before opening the new one. Both device paths (named and default) now use from_device().open_stream(), and set_device() resolves the target cpal Device first, tears down the old sink and stream, then creates the new stream. Add 9 tests covering no-stream error handling (load, backward seek) and device switch state preservation (stopped, paused, playing, volume, failed switch, consecutive switches, post-switch load). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8c94c29 commit d5c9f35

14 files changed

Lines changed: 1053 additions & 30 deletions

File tree

app/frontend/js/api/audio.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Audio API
3+
*
4+
* Audio output device enumeration and selection via Tauri commands.
5+
*/
6+
7+
import { ApiError, invoke } from './shared.js';
8+
9+
export const audio = {
10+
/**
11+
* List available audio output devices
12+
* @returns {Promise<{devices: string[]}>}
13+
*/
14+
async listDevices() {
15+
if (invoke) {
16+
try {
17+
return await invoke('audio_list_devices');
18+
} catch (error) {
19+
console.error('[api.audio.listDevices] Tauri error:', error);
20+
throw new ApiError(500, error.toString());
21+
}
22+
}
23+
// No HTTP fallback — audio device selection requires Tauri runtime
24+
return { devices: [] };
25+
},
26+
27+
/**
28+
* Set the audio output device
29+
* @param {string|null} deviceName - Device name, or null for system default
30+
* @returns {Promise<void>}
31+
*/
32+
async setDevice(deviceName) {
33+
if (invoke) {
34+
try {
35+
return await invoke('audio_set_device', { deviceName });
36+
} catch (error) {
37+
console.error('[api.audio.setDevice] Tauri error:', error);
38+
throw new ApiError(500, error.toString());
39+
}
40+
}
41+
},
42+
};

app/frontend/js/api/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { request } from './shared.js';
1010
export { ApiError } from './shared.js';
1111

12+
import { audio } from './audio.js';
1213
import { library } from './library.js';
1314
import { queue } from './queue.js';
1415
import { favorites } from './favorites.js';
@@ -17,7 +18,7 @@ import { lastfm } from './lastfm.js';
1718
import { lyrics } from './lyrics.js';
1819
import { settings } from './settings.js';
1920

20-
export { favorites, lastfm, library, lyrics, playlists, queue, settings };
21+
export { audio, favorites, lastfm, library, lyrics, playlists, queue, settings };
2122

2223
/**
2324
* Unified API object (backward compatibility).
@@ -28,6 +29,7 @@ export const api = {
2829
return request('/health');
2930
},
3031

32+
audio,
3133
library,
3234
lyrics,
3335
queue,

app/frontend/js/components/settings-view.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { audio } from '../api/audio.js';
12
import { lastfm } from '../api/lastfm.js';
3+
import { settings } from '../api/settings.js';
24
import { modLabel, SHORTCUT_DEFINITIONS } from '../shortcuts.js';
35

46
export function createSettingsView(Alpine) {
@@ -11,6 +13,7 @@ export function createSettingsView(Alpine) {
1113

1214
navSections: [
1315
{ id: 'general', label: 'General' },
16+
{ id: 'audio', label: 'Audio' },
1417
{ id: 'appearance', label: 'Appearance' },
1518
{ id: 'library', label: 'Library' },
1619
{ id: 'columns', label: 'Columns' },
@@ -46,6 +49,10 @@ export function createSettingsView(Alpine) {
4649
progress: null,
4750
},
4851

52+
audioDevices: [],
53+
selectedAudioDevice: 'default',
54+
audioDevicesLoading: false,
55+
4956
// Column settings for Settings > Columns section
5057
columnSettings: {
5158
visibleCount: 0,
@@ -162,6 +169,7 @@ export function createSettingsView(Alpine) {
162169

163170
async init() {
164171
await this.loadAppInfo();
172+
await this.loadAudioDevices();
165173
await this.loadWatchedFolders();
166174
await this.loadLastfmSettings();
167175
this.loadColumnSettings();
@@ -199,6 +207,38 @@ export function createSettingsView(Alpine) {
199207
}
200208
},
201209

210+
async loadAudioDevices() {
211+
this.audioDevicesLoading = true;
212+
try {
213+
const response = await audio.listDevices();
214+
this.audioDevices = response.devices || [];
215+
216+
// Load saved device selection
217+
const saved = await settings.get('audio_output_device');
218+
if (saved && saved.value && saved.value !== 'default') {
219+
this.selectedAudioDevice = saved.value;
220+
} else {
221+
this.selectedAudioDevice = 'default';
222+
}
223+
} catch (error) {
224+
console.error('[settings] Failed to load audio devices:', error);
225+
this.audioDevices = [];
226+
} finally {
227+
this.audioDevicesLoading = false;
228+
}
229+
},
230+
231+
async setAudioDevice(deviceName) {
232+
const previous = this.selectedAudioDevice;
233+
this.selectedAudioDevice = deviceName;
234+
try {
235+
await audio.setDevice(deviceName === 'default' ? null : deviceName);
236+
} catch (error) {
237+
console.error('[settings] Failed to set audio device:', error);
238+
this.selectedAudioDevice = previous;
239+
}
240+
},
241+
202242
async loadWatchedFolders() {
203243
if (!window.__TAURI__) return;
204244

app/frontend/js/stores/ui.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export function createUIStore(Alpine) {
156156
if (
157157
[
158158
'general',
159+
'audio',
159160
'library',
160161
'appearance',
161162
'columns',
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForAlpine } from './fixtures/helpers.js';
3+
4+
test.describe('Audio Settings UI', () => {
5+
test.beforeEach(async ({ page }) => {
6+
await page.goto('/');
7+
await waitForAlpine(page);
8+
9+
await page.click('[data-testid="sidebar-settings"]');
10+
await page.waitForSelector('[data-testid="settings-nav-audio"]', {
11+
state: 'visible',
12+
});
13+
});
14+
15+
test('should display Audio nav item in settings sidebar', async ({ page }) => {
16+
const audioNav = page.locator('[data-testid="settings-nav-audio"]');
17+
await expect(audioNav).toBeVisible();
18+
await expect(audioNav).toHaveText('Audio');
19+
});
20+
21+
test('should navigate to Audio section when clicked', async ({ page }) => {
22+
await page.click('[data-testid="settings-nav-audio"]');
23+
const audioSection = page.locator(
24+
'[data-testid="settings-section-audio"]',
25+
);
26+
await expect(audioSection).toBeVisible();
27+
});
28+
29+
test('should display device selector with Default option', async ({ page }) => {
30+
await page.click('[data-testid="settings-nav-audio"]');
31+
32+
const select = page.locator('[data-testid="audio-device-select"]');
33+
await expect(select).toBeVisible();
34+
35+
const defaultOption = select.locator('option[value="default"]');
36+
await expect(defaultOption).toHaveText('Default');
37+
});
38+
});
39+
40+
test.describe('Audio Settings with Mocked Tauri', () => {
41+
test.beforeEach(async ({ page }) => {
42+
await page.addInitScript(() => {
43+
const mockDevices = ['Built-in Output', 'External DAC'];
44+
let selectedDevice = 'default';
45+
window.__tauriInvocations = [];
46+
47+
window.__TAURI__ = {
48+
core: {
49+
invoke: (cmd, args) => {
50+
window.__tauriInvocations.push({ cmd, args });
51+
if (cmd === 'audio_list_devices') {
52+
return Promise.resolve({ devices: mockDevices });
53+
}
54+
if (cmd === 'audio_set_device') {
55+
selectedDevice = args?.deviceName || 'default';
56+
return Promise.resolve(null);
57+
}
58+
if (cmd === 'app_get_info') {
59+
return Promise.resolve({ version: 'test', build: 'test', platform: 'test' });
60+
}
61+
if (cmd === 'watched_folders_list') {
62+
return Promise.resolve([]);
63+
}
64+
if (cmd === 'lastfm_get_settings') {
65+
return Promise.resolve({
66+
enabled: false,
67+
authenticated: false,
68+
scrobble_threshold: 90,
69+
});
70+
}
71+
if (cmd === 'settings_get') {
72+
if (args?.key === 'audio_output_device') {
73+
return Promise.resolve({ key: 'audio_output_device', value: selectedDevice });
74+
}
75+
return Promise.resolve({ key: args?.key, value: null });
76+
}
77+
if (cmd === 'settings_set') {
78+
return Promise.resolve({ key: args?.key, value: args?.value });
79+
}
80+
return Promise.resolve(null);
81+
},
82+
},
83+
event: {
84+
listen: () => Promise.resolve(() => {}),
85+
},
86+
dialog: {
87+
confirm: () => Promise.resolve(true),
88+
},
89+
};
90+
});
91+
92+
await page.goto('/');
93+
await waitForAlpine(page);
94+
95+
await page.click('[data-testid="sidebar-settings"]');
96+
await page.waitForSelector('[data-testid="settings-nav-audio"]', {
97+
state: 'visible',
98+
});
99+
await page.click('[data-testid="settings-nav-audio"]');
100+
});
101+
102+
test('should list mocked audio devices in dropdown', async ({ page }) => {
103+
const select = page.locator('[data-testid="audio-device-select"]');
104+
await expect(select).toBeVisible();
105+
106+
const options = select.locator('option');
107+
// Default + 2 mocked devices = 3 options
108+
await expect(options).toHaveCount(3);
109+
110+
await expect(options.nth(0)).toHaveText('Default');
111+
await expect(options.nth(1)).toHaveText('Built-in Output');
112+
await expect(options.nth(2)).toHaveText('External DAC');
113+
});
114+
115+
test('should call audio_set_device when device is selected', async ({ page }) => {
116+
// Clear prior invocations from init
117+
await page.evaluate(() => {
118+
window.__tauriInvocations = [];
119+
});
120+
121+
const select = page.locator('[data-testid="audio-device-select"]');
122+
await select.selectOption('External DAC');
123+
124+
await page.waitForFunction(
125+
() =>
126+
window.__tauriInvocations.some(
127+
(inv) => inv.cmd === 'audio_set_device',
128+
),
129+
{ timeout: 5000 },
130+
);
131+
132+
const setDeviceCall = await page.evaluate(() =>
133+
window.__tauriInvocations.find((inv) => inv.cmd === 'audio_set_device')
134+
);
135+
expect(setDeviceCall).toBeDefined();
136+
expect(setDeviceCall.args.deviceName).toBe('External DAC');
137+
});
138+
139+
test('should send null deviceName when Default is selected', async ({ page }) => {
140+
// First select a non-default device
141+
const select = page.locator('[data-testid="audio-device-select"]');
142+
await select.selectOption('Built-in Output');
143+
144+
// Clear invocations and select default
145+
await page.evaluate(() => {
146+
window.__tauriInvocations = [];
147+
});
148+
149+
await select.selectOption('default');
150+
151+
await page.waitForFunction(
152+
() =>
153+
window.__tauriInvocations.some(
154+
(inv) => inv.cmd === 'audio_set_device',
155+
),
156+
{ timeout: 5000 },
157+
);
158+
159+
const setDeviceCall = await page.evaluate(() =>
160+
window.__tauriInvocations.find((inv) => inv.cmd === 'audio_set_device')
161+
);
162+
expect(setDeviceCall).toBeDefined();
163+
expect(setDeviceCall.args.deviceName).toBeNull();
164+
});
165+
});

app/frontend/views/settings.html

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,34 @@ <h3 class="text-xl font-semibold mb-4">General</h3>
3636
</div>
3737
</div>
3838

39+
<!-- Audio Section -->
40+
<div x-show="isSection('audio')" data-testid="settings-section-audio">
41+
<h3 class="text-xl font-semibold mb-4">Audio</h3>
42+
<div class="space-y-6">
43+
<div>
44+
<label class="block text-sm font-medium mb-2" for="audio-output-device">
45+
Output Device
46+
</label>
47+
<p class="text-xs text-muted-foreground mb-3">
48+
Select the audio output device for playback.
49+
</p>
50+
<select
51+
id="audio-output-device"
52+
class="w-full max-w-md px-3 py-2 rounded-md border border-border bg-background text-sm"
53+
:value="selectedAudioDevice"
54+
@change="setAudioDevice($event.target.value)"
55+
:disabled="audioDevicesLoading"
56+
data-testid="audio-device-select"
57+
>
58+
<option value="default">Default</option>
59+
<template x-for="device in audioDevices" :key="device">
60+
<option :value="device" x-text="device"></option>
61+
</template>
62+
</select>
63+
</div>
64+
</div>
65+
</div>
66+
3967
{{> settings-library}}
4068

4169
<!-- Appearance Section -->

0 commit comments

Comments
 (0)