Skip to content

Commit d92184d

Browse files
merge: resolve conflict in task-268 backlog file
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2 parents 1be396b + d5c9f35 commit d92184d

14 files changed

Lines changed: 1044 additions & 21 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)