Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions app/frontend/js/api/audio.js
Original file line number Diff line number Diff line change
@@ -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<void>}
*/
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());
}
}
},
};
4 changes: 3 additions & 1 deletion app/frontend/js/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@
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';
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).
Expand All @@ -27,6 +28,7 @@ export const api = {
return request('/health');
},

audio,
library,
queue,
favorites,
Expand Down
40 changes: 40 additions & 0 deletions app/frontend/js/components/settings-view.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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' },
Expand Down Expand Up @@ -46,6 +49,10 @@ export function createSettingsView(Alpine) {
progress: null,
},

audioDevices: [],
selectedAudioDevice: 'default',
audioDevicesLoading: false,

// Column settings for Settings > Columns section
columnSettings: {
visibleCount: 0,
Expand Down Expand Up @@ -160,6 +167,7 @@ export function createSettingsView(Alpine) {

async init() {
await this.loadAppInfo();
await this.loadAudioDevices();
await this.loadWatchedFolders();
await this.loadLastfmSettings();
this.loadColumnSettings();
Expand Down Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions app/frontend/js/stores/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export function createUIStore(Alpine) {
if (
[
'general',
'audio',
'library',
'appearance',
'columns',
Expand Down
165 changes: 165 additions & 0 deletions app/frontend/tests/settings-audio.spec.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
28 changes: 28 additions & 0 deletions app/frontend/views/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,34 @@ <h3 class="text-xl font-semibold mb-4">General</h3>
</div>
</div>

<!-- Audio Section -->
<div x-show="isSection('audio')" data-testid="settings-section-audio">
<h3 class="text-xl font-semibold mb-4">Audio</h3>
<div class="space-y-6">
<div>
<label class="block text-sm font-medium mb-2" for="audio-output-device">
Output Device
</label>
<p class="text-xs text-muted-foreground mb-3">
Select the audio output device for playback.
</p>
<select
id="audio-output-device"
class="w-full max-w-md px-3 py-2 rounded-md border border-border bg-background text-sm"
:value="selectedAudioDevice"
@change="setAudioDevice($event.target.value)"
:disabled="audioDevicesLoading"
data-testid="audio-device-select"
>
<option value="default">Default</option>
<template x-for="device in audioDevices" :key="device">
<option :value="device" x-text="device"></option>
</template>
</select>
</div>
</div>
</div>

{{> settings-library}}

<!-- Appearance Section -->
Expand Down
Loading
Loading