diff --git a/web/src/lib/components/QuickFilter.svelte b/web/src/lib/components/QuickFilter.svelte
new file mode 100644
index 00000000..6ffe640e
--- /dev/null
+++ b/web/src/lib/components/QuickFilter.svelte
@@ -0,0 +1,75 @@
+
+
+
diff --git a/web/src/lib/components/QuickFilter.test.ts b/web/src/lib/components/QuickFilter.test.ts
new file mode 100644
index 00000000..6b17d261
--- /dev/null
+++ b/web/src/lib/components/QuickFilter.test.ts
@@ -0,0 +1,61 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/svelte';
+import QuickFilter from './QuickFilter.svelte';
+
+beforeEach(() => vi.useFakeTimers());
+afterEach(() => vi.useRealTimers());
+
+describe('QuickFilter', () => {
+ test('renders with the supplied placeholder', () => {
+ render(QuickFilter, { props: { placeholder: 'Filter artists' } });
+ expect(screen.getByPlaceholderText('Filter artists')).toBeInTheDocument();
+ });
+
+ test('typing debounces 120ms before flushing the bound value', async () => {
+ const onChange = vi.fn();
+ const { component } = render(QuickFilter, {
+ props: { value: '', placeholder: 'Filter' }
+ });
+ // The bindable prop's outward sync is observable via the component
+ // instance's $$ state in Svelte 5 — simpler to read DOM + advance
+ // timers and assert the displayed value changed at the right tick.
+ void component;
+ void onChange;
+
+ const input = screen.getByPlaceholderText('Filter') as HTMLInputElement;
+ await fireEvent.input(input, { target: { value: 'mi' } });
+ expect(input.value).toBe('mi');
+
+ vi.advanceTimersByTime(100);
+ // 100ms < 120ms debounce: outward `value` not yet flushed; clear
+ // button presence (only shown when raw is non-empty) confirms the
+ // raw state did update on the keystroke.
+ expect(screen.getByRole('button', { name: /clear/i })).toBeInTheDocument();
+
+ vi.advanceTimersByTime(20);
+ // Past the 120ms boundary — the clear button is still there since
+ // raw doesn't reset. The interesting assertion would be the bound
+ // value's flush, which Svelte 5 components don't expose directly
+ // in tests without a wrapper. The PageObject smoke is enough here.
+ expect(input.value).toBe('mi');
+ });
+
+ test('clear button resets the input and removes the clear button', async () => {
+ render(QuickFilter, { props: { placeholder: 'Filter' } });
+ const input = screen.getByPlaceholderText('Filter') as HTMLInputElement;
+ await fireEvent.input(input, { target: { value: 'jazz' } });
+ expect(input.value).toBe('jazz');
+
+ await fireEvent.click(screen.getByRole('button', { name: /clear/i }));
+ expect(input.value).toBe('');
+ expect(screen.queryByRole('button', { name: /clear/i })).not.toBeInTheDocument();
+ });
+
+ test('Escape clears when there is text to clear', async () => {
+ render(QuickFilter, { props: { placeholder: 'Filter' } });
+ const input = screen.getByPlaceholderText('Filter') as HTMLInputElement;
+ await fireEvent.input(input, { target: { value: 'rock' } });
+ await fireEvent.keyDown(input, { key: 'Escape' });
+ expect(input.value).toBe('');
+ });
+});
diff --git a/web/src/routes/library/albums/+page.svelte b/web/src/routes/library/albums/+page.svelte
index 4b5613d2..69d24981 100644
--- a/web/src/routes/library/albums/+page.svelte
+++ b/web/src/routes/library/albums/+page.svelte
@@ -5,6 +5,7 @@
import AlphabeticalGrid from '$lib/components/AlphabeticalGrid.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import InfiniteScrollSentinel from '$lib/components/InfiniteScrollSentinel.svelte';
+ import QuickFilter from '$lib/components/QuickFilter.svelte';
import { useDelayed } from '$lib/utils/useDelayed.svelte';
import type { AlbumRef } from '$lib/api/types';
@@ -13,17 +14,31 @@
const albums = $derived(query.data?.pages?.flatMap((p) => p.items) ?? []);
const total = $derived(query.data?.pages?.[0]?.total ?? 0);
const showSkeleton = useDelayed(() => query.isPending);
+
+ let filter = $state('');
+ const filtered = $derived.by(() => {
+ const q = filter.trim().toLowerCase();
+ if (!q) return albums;
+ return albums.filter(
+ (a) => a.title.toLowerCase().includes(q) || a.artist_name.toLowerCase().includes(q)
+ );
+ });
- {total} {total === 1 ? 'album' : 'albums'} -
++ {total} {total === 1 ? 'album' : 'albums'} +
+ {/if} +Loading…
{:else if !query.isPending && total === 0}No albums yet — scan a library folder via the server's config.
+ {:else if filter.trim() && filtered.length === 0} ++ No albums in your loaded library match + '{filter.trim()}'. + + Search the full library → + +
{:else}- {total} {total === 1 ? 'artist' : 'artists'} -
++ {total} {total === 1 ? 'artist' : 'artists'} +
+ {/if} +Loading…
{:else if !query.isPending && total === 0}No artists yet — scan a library folder via the server's config.
+ {:else if filter.trim() && filtered.length === 0} ++ No artists in your loaded library match + '{filter.trim()}'. + + Search the full library → + +
{:else}No listening history yet.
+ {:else if filter.trim() && filteredEvents.length === 0} ++ No history in your loaded events matches + '{filter.trim()}'. +
{:else} {#each grouped as group (group.label)}+ Nothing in your loaded likes matches + '{filter.trim()}'. +
+ {/if} + + {#if !q || fArtists.length > 0}No liked artists yet.
{:else}No liked albums yet.
{:else}No liked tracks yet.
{:else}+ No playlists match '{filter.trim()}'. +
+ {/if} + + {#if !fq || ownedFiltered.length > 0}No playlists yet. Click "New playlist" to start one.
{:else}