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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

126 changes: 126 additions & 0 deletions app/frontend/js/components/settings-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,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 +47,15 @@ export function createSettingsView(Alpine) {
progress: null,
},

networkCache: {
enabled: false,
persistent: false,
maxGb: 2,
usedBytes: 0,
fileCount: 0,
isPurging: false,
},

// Column settings for Settings > Columns section
columnSettings: {
visibleCount: 0,
Expand Down Expand Up @@ -134,6 +144,33 @@ export function createSettingsView(Alpine) {
return this.lastfm.enabled ? 'translate-x-6' : 'translate-x-1';
},

networkCacheToggleTrackClass() {
return this.networkCache.enabled ? 'bg-primary' : 'bg-muted';
},

networkCacheToggleThumbClass() {
return this.networkCache.enabled ? 'translate-x-6' : 'translate-x-1';
},

networkCachePersistentTrackClass() {
return this.networkCache.persistent ? 'bg-primary' : 'bg-muted';
},

networkCachePersistentThumbClass() {
return this.networkCache.persistent ? 'translate-x-6' : 'translate-x-1';
},

purgeButtonText() {
return this.networkCache.isPurging ? 'Clearing...' : 'Clear Cache';
},

formatCacheSize(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
},

connectButtonText() {
return this.lastfm.isConnecting ? 'Connecting...' : 'Connect';
},
Expand Down Expand Up @@ -162,6 +199,7 @@ export function createSettingsView(Alpine) {
await this.loadAppInfo();
await this.loadWatchedFolders();
await this.loadLastfmSettings();
await this.loadNetworkCacheStatus();
this.loadColumnSettings();
},

Expand Down Expand Up @@ -350,6 +388,94 @@ export function createSettingsView(Alpine) {
}
},

// ============================================
// Network Cache methods
// ============================================

async loadNetworkCacheStatus() {
if (!window.__TAURI__) return;

try {
const { invoke } = window.__TAURI__.core;
const status = await invoke('network_cache_status');
this.networkCache.enabled = status.enabled;
this.networkCache.persistent = status.persistent;
this.networkCache.maxGb = status.max_bytes / 1_073_741_824;
this.networkCache.usedBytes = status.used_bytes;
this.networkCache.fileCount = status.file_count;
} catch (error) {
console.error('[settings] Failed to load network cache status:', error);
}
},

async toggleNetworkCache() {
if (!window.__TAURI__) return;

try {
const newValue = !this.networkCache.enabled;
await window.settings.set('network_cache_enabled', newValue);
this.networkCache.enabled = newValue;
Alpine.store('ui').toast(
`Network file caching ${newValue ? 'enabled' : 'disabled'}`,
'success',
);
} catch (error) {
console.error('[settings] Failed to toggle network cache:', error);
Alpine.store('ui').toast('Failed to update network cache setting', 'error');
}
},

async toggleNetworkCachePersistent() {
if (!window.__TAURI__) return;

try {
const newValue = !this.networkCache.persistent;
await window.settings.set('network_cache_persistent', newValue);
this.networkCache.persistent = newValue;
Alpine.store('ui').toast(
`Persistent cache ${newValue ? 'enabled' : 'disabled'}`,
'success',
);
} catch (error) {
console.error('[settings] Failed to toggle persistent cache:', error);
Alpine.store('ui').toast('Failed to update persistent cache setting', 'error');
}
},

async updateNetworkCacheMaxGb() {
if (!window.__TAURI__) return;

try {
const clamped = Math.max(0.5, Math.min(20, this.networkCache.maxGb));
if (clamped !== this.networkCache.maxGb) {
this.networkCache.maxGb = clamped;
}

await window.settings.set('network_cache_max_gb', this.networkCache.maxGb);
} catch (error) {
console.error('[settings] Failed to update cache size limit:', error);
Alpine.store('ui').toast('Failed to update cache size limit', 'error');
}
},

async purgeNetworkCache() {
if (!window.__TAURI__) return;

this.networkCache.isPurging = true;
try {
const { invoke } = window.__TAURI__.core;
await invoke('network_cache_purge');
this.networkCache.usedBytes = 0;
this.networkCache.fileCount = 0;
Alpine.store('ui').toast('Network cache cleared', 'success');
} catch (error) {
console.error('[settings] Failed to purge network cache:', error);
Alpine.store('ui').toast('Failed to clear network cache', 'error');
} finally {
this.networkCache.isPurging = false;
}
},

// ============================================
// Last.fm methods
// ============================================
Expand Down
110 changes: 110 additions & 0 deletions app/frontend/tests/network-cache-settings.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { expect, test } from '@playwright/test';
import { waitForAlpine } from './fixtures/helpers.js';
import { createLibraryState, setupLibraryMocks } from './fixtures/mock-library.js';

test.describe('Network Cache Settings', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1624, height: 1057 });

const libraryState = createLibraryState();
await setupLibraryMocks(page, libraryState);

// Mock Last.fm to prevent error toasts
await page.route(/\/api\/lastfm\/settings/, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
enabled: false,
username: null,
authenticated: false,
configured: false,
scrobble_threshold: 50,
}),
});
});

await page.goto('/');
await waitForAlpine(page);

// Navigate to Settings > Audio
await page.click('[data-testid="sidebar-settings"]');
await page.waitForTimeout(500);
await page.click('[data-testid="settings-nav-audio"]');
await page.waitForTimeout(300);
});

test('should show Audio nav item in settings', async ({ page }) => {
const navItem = page.locator('[data-testid="settings-nav-audio"]');
await expect(navItem).toBeVisible();
await expect(navItem).toHaveText('Audio');
});

test('should display the Audio section', async ({ page }) => {
const section = page.locator('[data-testid="settings-section-audio"]');
await expect(section).toBeVisible();
});

test('should show network cache toggle defaulting to off', async ({ page }) => {
const toggle = page.locator('[data-testid="network-cache-toggle"]');
await expect(toggle).toBeVisible();

// Default is off, so the toggle should have the muted class
const classes = await toggle.getAttribute('class');
expect(classes).toContain('bg-muted');
});

test('should show sub-settings when cache is enabled', async ({ page }) => {
// Persistent toggle, slider, and purge should not be visible initially
const persistentToggle = page.locator('[data-testid="network-cache-persistent-toggle"]');
await expect(persistentToggle).not.toBeVisible();

const slider = page.locator('[data-testid="network-cache-size-slider"]');
await expect(slider).not.toBeVisible();

const purgeButton = page.locator('[data-testid="network-cache-purge"]');
await expect(purgeButton).not.toBeVisible();

// Enable the cache
await page.click('[data-testid="network-cache-toggle"]');
await page.waitForTimeout(300);

// Now sub-settings should be visible
await expect(persistentToggle).toBeVisible();
await expect(slider).toBeVisible();
await expect(purgeButton).toBeVisible();
});

test('should have range slider with correct min/max', async ({ page }) => {
// Enable cache to show slider
await page.click('[data-testid="network-cache-toggle"]');
await page.waitForTimeout(300);

const slider = page.locator('[data-testid="network-cache-size-slider"]');
await expect(slider).toHaveAttribute('min', '0.5');
await expect(slider).toHaveAttribute('max', '20');
await expect(slider).toHaveAttribute('step', '0.5');
});

test('should show cache status when enabled', async ({ page }) => {
// Enable cache
await page.click('[data-testid="network-cache-toggle"]');
await page.waitForTimeout(300);

// Cache status card should show "0 B" and "0" files
const usedText = page.locator('text=Used');
await expect(usedText).toBeVisible();

const filesText = page.locator('text=Files');
await expect(filesText).toBeVisible();
});

test('should show purge button as disabled when cache is empty', async ({ page }) => {
// Enable cache
await page.click('[data-testid="network-cache-toggle"]');
await page.waitForTimeout(300);

const purgeButton = page.locator('[data-testid="network-cache-purge"]');
await expect(purgeButton).toBeDisabled();
});
});
110 changes: 110 additions & 0 deletions app/frontend/views/settings-audio.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<!-- Audio Section -->
<div x-show="isSection('audio')" data-testid="settings-section-audio">
<h3 class="text-xl font-semibold mb-4">Audio</h3>

<!-- Network File Caching -->
<div class="mb-8">
<h4 class="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3">
Network File Caching
</h4>
<div class="space-y-4">
<!-- Enable toggle -->
<div class="flex items-center justify-between">
<div>
<div class="text-sm font-medium">Cache network files locally</div>
<div class="text-xs text-muted-foreground">
Copy files from network mounts (SMB/NFS) to a local cache before playback
</div>
</div>
<button
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="networkCacheToggleTrackClass()"
@click="toggleNetworkCache()"
data-testid="network-cache-toggle"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="networkCacheToggleThumbClass()"
></span>
</button>
</div>

<!-- Sub-settings (shown when enabled) -->
<div x-show="networkCache.enabled" class="space-y-4 pl-0">
<!-- Persistent cache toggle -->
<div class="flex items-center justify-between">
<div>
<div class="text-sm font-medium">Persistent cache</div>
<div class="text-xs text-muted-foreground">
Keep cached files across app restarts (otherwise cleared on exit)
</div>
</div>
<button
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="networkCachePersistentTrackClass()"
@click="toggleNetworkCachePersistent()"
data-testid="network-cache-persistent-toggle"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="networkCachePersistentThumbClass()"
></span>
</button>
</div>

<!-- Max cache size slider -->
<div>
<div class="text-sm font-medium mb-2">Maximum cache size</div>
<div class="text-xs text-muted-foreground mb-3">
Limit local cache to <span x-text="networkCache.maxGb"></span> GB
</div>
<div class="px-2">
<input
type="range"
min="0.5"
max="20"
step="0.5"
x-model.number="networkCache.maxGb"
@change="updateNetworkCacheMaxGb()"
class="w-full cursor-pointer accent-primary"
style="-webkit-appearance: auto; appearance: auto; box-shadow: none;"
data-testid="network-cache-size-slider"
>
<div class="flex justify-between text-xs text-muted-foreground mt-1">
<span>0.5 GB</span>
<span>10 GB</span>
<span>20 GB</span>
</div>
</div>
</div>

<!-- Cache status -->
<div class="bg-muted/30 rounded-lg p-4">
<div class="grid grid-cols-2 gap-4 text-center">
<div>
<div class="text-2xl font-semibold" x-text="formatCacheSize(networkCache.usedBytes)"></div>
<div class="text-xs text-muted-foreground">Used</div>
</div>
<div>
<div class="text-2xl font-semibold" x-text="networkCache.fileCount"></div>
<div class="text-xs text-muted-foreground">Files</div>
</div>
</div>
</div>

<!-- Purge button -->
<button
class="w-full text-left px-4 py-3 rounded-lg border border-destructive/50 hover:bg-destructive/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="networkCache.isPurging || networkCache.fileCount === 0"
@click="purgeNetworkCache()"
data-testid="network-cache-purge"
>
<div class="font-medium text-sm text-destructive" x-text="purgeButtonText()"></div>
<div class="text-xs text-muted-foreground mt-0.5">
Remove all locally cached network files
</div>
</button>
</div>
</div>
</div>
</div>
Loading
Loading