} */
+const pendingArt = new Set();
+/** @type {{ key: string; track: Track }[]} */
+const artQueue = [];
+let artActive = 0;
+const MAX_ART_CONCURRENT = 4;
+
+/**
+ * Fetch artwork for a given key (album or artist key) using a representative track.
+ * Results are cached as blob URLs (or null if no art found).
+ * @param {string} key
+ * @param {Track} track
+ */
+function fetchArt(key, track) {
+ if (artCache.has(key)) return;
+ if (pendingArt.has(key)) return;
+ pendingArt.add(key);
+ artQueue.push({ key, track });
+ drainArtQueue();
+}
+
+function drainArtQueue() {
+ while (artActive < MAX_ART_CONCURRENT && artQueue.length > 0) {
+ const job = artQueue.shift();
+ if (!job) break;
+ artActive++;
+ doFetchArt(job.key, job.track);
+ }
+}
+
+/**
+ * @param {string} key
+ * @param {Track} track
+ */
+async function doFetchArt(key, track) {
+ try {
+ const timeout = new Promise((resolve) =>
+ setTimeout(() => resolve(null), 15_000)
+ );
+ const bytes = await Promise.race([artwork.get(track), timeout]);
+ if (bytes) {
+ const mime = detectMime(bytes);
+ const url = URL.createObjectURL(new Blob([bytes], { type: mime }));
+ artCache.set(key, url);
+ } else {
+ artCache.set(key, null);
+ }
+ } catch {
+ // don't cache on error — allow retry
+ } finally {
+ pendingArt.delete(key);
+ artActive--;
+ drainArtQueue();
+ }
+ scheduleArtRender();
+}
+
+/** Re-render to show newly loaded artwork. */
+let artRenderRaf = false;
+function scheduleArtRender() {
+ if (artRenderRaf) return;
+ artRenderRaf = true;
+ requestAnimationFrame(() => {
+ artRenderRaf = false;
+ renderContent();
+ });
+}
+
+////////////////////////////////////////////
+// PLAYBACK ACTIONS
+////////////////////////////////////////////
+
+const playPause = () => {
+ const audioId = c.$queue.value?.now()?.id;
+ if (!audioId) return;
+ if (c.isPlaying()) c.$audio.value?.pause({ audioId });
+ else c.$audio.value?.play({ audioId });
+};
+
+const next = () => c.$queue.value?.shift();
+const previous = () => c.$queue.value?.unshift();
+
+/** Play a track immediately. @param {string} trackId */
+const playTrack = (trackId) => {
+ const q = c.$queue.value;
+ if (!q) return;
+ q.add({ inFront: true, trackIds: [trackId] });
+ q.shift();
+};
+
+/** Play a list of tracks, replacing the upcoming queue. @param {string[]} trackIds */
+const playTracks = (trackIds) => {
+ if (trackIds.length === 0) return;
+ const q = c.$queue.value;
+ if (!q) return;
+ q.add({ inFront: true, trackIds });
+ q.shift();
+};
+
+/** Append tracks to the end of the queue. @param {string[]} trackIds */
+const addToQueue = (trackIds) => {
+ if (trackIds.length === 0) return;
+ const q = c.$queue.value;
+ if (!q) return;
+ q.add({ trackIds });
+};
+
+/** Jump to a flat queue index. @param {number} idx */
+const playAtQueueIndex = (idx) => {
+ const q = c.$queue.value;
+ if (!q) return;
+ const pastLen = queue.past().length;
+ if (idx === pastLen) {
+ const audioId = queue.now()?.id;
+ if (audioId) {
+ c.$audio.value?.seek({ audioId, currentTime: 0 });
+ c.$audio.value?.play({ audioId });
+ }
+ return;
+ }
+ if (idx < pastLen) {
+ for (let i = 0; i < pastLen - idx; i++) q.unshift();
+ } else {
+ for (let i = 0; i < idx - pastLen; i++) q.shift();
+ }
+};
+
+/** @param {number} percentage 0..1 */
+const seekTo = (percentage) => {
+ const audioId = c.$queue.value?.now()?.id;
+ if (audioId) c.$audio.value?.seek({ audioId, percentage });
+};
+
+/** Toggle favourite on a track. @param {Track} track */
+const toggleFavourite = (track) => {
+ favourites.toggle(track);
+};
+
+/** Find a track by ID from the full collection. @param {string} id */
+const findTrack = (id) => allTracks().find((t) => t.id === id);
+
+/** Is this track currently playing? @param {string} trackId */
+const isCurrentTrack = (trackId) => {
+ const now = queue.now();
+ return !!now && now.id === trackId;
+};
+
+////////////////////////////////////////////
+// CONTEXT MENU
+////////////////////////////////////////////
+
+/** @type {{ x: number; y: number; track: Track } | null} */
+let contextMenuState = null;
+
+/**
+ * @param {number} x
+ * @param {number} y
+ * @param {Track} track
+ */
+function openContextMenu(x, y, track) {
+ contextMenuState = { x, y, track };
+ renderContextMenu();
+}
+
+function closeContextMenu() {
+ contextMenuState = null;
+ renderContextMenu();
+}
+
+let contextMenuEl = /** @type {HTMLDivElement | null} */ (null);
+
+function renderContextMenu() {
+ if (contextMenuEl) {
+ contextMenuEl.remove();
+ contextMenuEl = null;
+ }
+ if (!contextMenuState) return;
+
+ const { x, y, track } = contextMenuState;
+ const isFav = favourites.isFavourite(track);
+
+ contextMenuEl = document.createElement("div");
+ contextMenuEl.className = "cat-menu";
+ contextMenuEl.style.left = `${Math.min(x, globalThis.innerWidth - 220)}px`;
+ contextMenuEl.style.top = `${Math.min(y, globalThis.innerHeight - 200)}px`;
+
+ litRender(
+ html`
+
+
+
+ `,
+ contextMenuEl,
+ );
+
+ document.body.appendChild(contextMenuEl);
+}
+
+// Close context menu on outside click
+document.addEventListener("click", (e) => {
+ if (
+ contextMenuEl && !contextMenuEl.contains(/** @type {Node} */ (e.target))
+ ) {
+ closeContextMenu();
+ }
+});
+
+document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") closeContextMenu();
+});
+
+////////////////////////////////////////////
+// RENDER — content area
+////////////////////////////////////////////
+
+const artPlaceholder = html`
+
+
+
+`;
+
+/**
+ * @param {string} key
+ * @param {Track} track
+ */
+function artBlock(key, track) {
+ fetchArt(key, track);
+ const url = artCache.get(key);
+ if (url) {
+ return html`
`;
+ }
+ return artPlaceholder;
+}
+
+/**
+ * @param {string} key
+ * @param {Track} track
+ */
+function detailArtBlock(key, track) {
+ fetchArt(key, track);
+ const url = artCache.get(key);
+ if (url) {
+ return html`
`;
+ }
+ return html`
+
+
+
+ `;
+}
+
+function renderAlbumGrid() {
+ const list = albums();
+ if (list.length === 0) return renderEmpty();
+
+ return html`
+
+ ${list.map(
+ (album) =>
+ html`
+
+ `,
+ )}
+
+ `;
+}
+
+function renderArtistGrid() {
+ const list = artists();
+ if (list.length === 0) return renderEmpty();
+
+ return html`
+
+ ${list.map(
+ (artist) =>
+ html`
+
+ `,
+ )}
+
+ `;
+}
+
+function renderSongList() {
+ const list = tracks();
+ if (list.length === 0) return renderEmpty();
+
+ return html`
+
+ ${list.map(
+ (track, i) =>
+ html`
+ - playTrack(track.id)}
+ @contextmenu=${/** @param {MouseEvent} e */ (e) => {
+ e.preventDefault();
+ openContextMenu(e.clientX, e.clientY, track);
+ }}
+ >
+ ${i + 1}
+
+
${titleOf(track)}
+
${artistOf(track)}
+
+ ${trackDuration(track)}
+
+
+ `,
+ )}
+
+ `;
+}
+
+function renderDetailView() {
+ const item = detailItem.value;
+ if (!item) return null;
+
+ const list = detailTracks();
+ if (list.length === 0) {
+ detailItem.value = null;
+ return null;
+ }
+
+ const isAlbum = item.type === "album";
+ const title = isAlbum ? albumOf(list[0]) : artistOf(list[0]);
+ const subtitle = isAlbum
+ ? artistOf(list[0])
+ : `${list.length} track${list.length !== 1 ? "s" : ""}`;
+
+ const artKey = item.key;
+ const firstTrack = list[0];
+ const trackIds = list.map((t) => t.id);
+
+ return html`
+
+
+
+
+
+
+ ${list.map(
+ (track, i) =>
+ html`
+ - playTrack(track.id)}
+ @contextmenu=${/** @param {MouseEvent} e */ (e) => {
+ e.preventDefault();
+ openContextMenu(e.clientX, e.clientY, track);
+ }}
+ >
+ ${i + 1}
+
+
${titleOf(track)}
+ ${!isAlbum
+ ? html`
${
+ albumOf(track)
+ }
`
+ : null}
+
+ ${trackDuration(track)}
+
+
+ `,
+ )}
+
+
+ `;
+}
+
+function renderEmpty() {
+ const term = scope.searchTerm();
+ const playlist = scope.playlist();
+ let msg = "No tracks found.\nAdd an audio source to get started.";
+ if (term) msg = `No tracks match "${term}".`;
+ else if (playlist) msg = "This playlist is empty.";
+
+ return html`
+
+ `;
+}
+
+function renderContent() {
+ const detail = detailItem.value;
+ const mode = viewMode.value;
+
+ // Read scoped tracks to register signal dependency
+ const _tracks = tracks();
+ void _tracks;
+
+ if (detail) {
+ litRender(renderDetailView(), el.content);
+ return;
+ }
+
+ if (mode === "albums") litRender(renderAlbumGrid(), el.content);
+ else if (mode === "artists") litRender(renderArtistGrid(), el.content);
+ else litRender(renderSongList(), el.content);
+}
+
+////////////////////////////////////////////
+// RENDER — queue panel
+////////////////////////////////////////////
+
+function renderQueue() {
+ const items = queueItems();
+ const past = items.filter((i) => i.state === "past");
+ const now = items.filter((i) => i.state === "now");
+ const future = items.filter((i) => i.state === "future");
+
+ /**
+ * @param {QueueItem & { state: "past" | "now" | "future" }} item
+ */
+ const renderItem = (item) => {
+ const track = findTrack(item.id);
+ const title = track ? titleOf(track) : item.id;
+ const artist = track ? artistOf(track) : "";
+ const flatIndex = items.indexOf(item);
+
+ return html`
+ playAtQueueIndex(flatIndex)}
+ >
+
+ ${track
+ ? (() => {
+ const key = albumOf(track).toLowerCase();
+ fetchArt(key, track);
+ const url = artCache.get(key);
+ return url ? html`

` : html`
+
+ `;
+ })()
+ : html`
+
+ `}
+
+
+ ${item.state === "now"
+ ? html`
`
+ : null}
+
+ `;
+ };
+
+ return html`
+
+
+ ${now.length > 0
+ ? html`
+
+
Now Playing
+ ${now.map(renderItem)}
+
+ `
+ : null}
+ ${future.length > 0
+ ? html`
+
+
Up Next
+ ${future.map(renderItem)}
+
+ `
+ : null}
+ ${past.length > 0
+ ? html`
+
+
History
+ ${past.map(renderItem)}
+
+ `
+ : null}
+ ${items.length === 0
+ ? html`
+
+ `
+ : null}
+
+ `;
+}
+
+function renderQueuePanel() {
+ const isOpen = queueOpen.value;
+ el.queuePanel.hidden = !isOpen;
+ el.queueToggle.setAttribute("data-active", isOpen ? "t" : "f");
+ if (!isOpen) return;
+ litRender(renderQueue(), el.queuePanel);
+}
+
+////////////////////////////////////////////
+// RENDER — player bar
+////////////////////////////////////////////
+
+function renderPlayer() {
+ const track = c.currentTrack();
+ const audio = c.audio();
+ const playing = c.isPlaying();
+ const hasTrack = !!c.$queue.value?.now();
+
+ const currentTime = audio?.currentTime() ?? 0;
+ const duration = track?.stats?.duration
+ ? track.stats.duration / 1000
+ : (audio?.duration() ?? 0);
+ const progress = duration > 0 ? currentTime / duration : 0;
+
+ const repeat = repeatShuffle.repeat();
+ const shuffle = repeatShuffle.shuffle();
+
+ const artKey = track ? albumOf(track).toLowerCase() : "";
+ const artUrl = track ? artCache.get(artKey) : undefined;
+
+ litRender(
+ html`
+
+ ${hasTrack && track
+ ? (artUrl ? html`

` : html`
+
+ `)
+ : html`
+
+ `}
+
+
+
+
${hasTrack && track
+ ? titleOf(track)
+ : "Nothing playing"}
+
${hasTrack && track
+ ? artistOf(track)
+ : ""}
+
+
+
+
+
+
+
+
+
+
+
+ ${formatTime(currentTime)}
+ {
+ const val = parseFloat(
+ /** @type {HTMLInputElement} */ (e.target).value,
+ );
+ seekTo(val / 100);
+ }}
+ />
+
+ ${duration > 0 ? formatTime(duration) : "0:00"}
+
+
+ `,
+ el.playerBar,
+ );
+}
+
+////////////////////////////////////////////
+// RENDER — playlist select
+////////////////////////////////////////////
+
+function renderPlaylistSelect() {
+ const list = playlists();
+ const current = scope.playlist();
+
+ litRender(
+ html`
+
+ ${list.map(
+ (p) =>
+ html``,
+ )}
+ `,
+ el.playlistSelect,
+ );
+}
+
+////////////////////////////////////////////
+// TABS
+////////////////////////////////////////////
+
+function updateTabs() {
+ const mode = viewMode.value;
+ el.tabs.forEach((tab) => {
+ tab.setAttribute("data-active", tab.dataset.view === mode ? "t" : "f");
+ });
+}
+
+el.tabs.forEach((tab) => {
+ tab.addEventListener("click", () => {
+ viewMode.value = /** @type {"albums" | "artists" | "songs"} */ (
+ tab.dataset.view
+ );
+ detailItem.value = null;
+ });
+});
+
+////////////////////////////////////////////
+// SEARCH
+////////////////////////////////////////////
+
+/** @type {ReturnType} */
+let searchDebounce;
+el.search.addEventListener("input", () => {
+ clearTimeout(searchDebounce);
+ const value = el.search.value;
+ searchDebounce = setTimeout(() => {
+ scope.setSearchTerm(value || undefined);
+ }, 250);
+});
+
+// Restore saved search term
+const savedSearch = scope.searchTerm();
+if (savedSearch) el.search.value = savedSearch;
+
+////////////////////////////////////////////
+// PLAYLIST SELECT
+////////////////////////////////////////////
+
+el.playlistSelect.addEventListener("change", () => {
+ const value = el.playlistSelect.value;
+ scope.setPlaylist(value || undefined);
+ detailItem.value = null;
+});
+
+////////////////////////////////////////////
+// QUEUE TOGGLE
+////////////////////////////////////////////
+
+el.queueToggle.addEventListener("click", () => {
+ queueOpen.value = !queueOpen.value;
+});
+
+////////////////////////////////////////////
+// KEYBOARD SHORTCUTS
+////////////////////////////////////////////
+
+document.addEventListener("keydown", (e) => {
+ // Ignore when typing in inputs
+ const tag = (e.target instanceof HTMLElement) ? e.target.tagName : "";
+ if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
+
+ if (e.key === " ") {
+ e.preventDefault();
+ playPause();
+ } else if (e.key === "ArrowRight" && e.shiftKey) {
+ next();
+ } else if (e.key === "ArrowLeft" && e.shiftKey) {
+ previous();
+ }
+});
+
+////////////////////////////////////////////
+// EFFECTS — re-render on signal changes
+////////////////////////////////////////////
+
+// Content area
+effect(() => {
+ // Register dependencies
+ viewMode.value;
+ detailItem.value;
+ const _tracks = tracks();
+ const _albums = albums();
+ const _artists = artists();
+ void _tracks;
+ void _albums;
+ void _artists;
+ renderContent();
+});
+
+// Tabs
+effect(() => {
+ viewMode.value;
+ updateTabs();
+});
+
+// Queue panel
+effect(() => {
+ queueOpen.value;
+ const _items = queueItems();
+ void _items;
+ renderQueuePanel();
+});
+
+// Queue toggle badge
+effect(() => {
+ const count = queueCount();
+ const existing = el.queueToggle.querySelector(".cat-queue-toggle__badge");
+ if (count > 0) {
+ if (!existing) {
+ const badge = document.createElement("span");
+ badge.className = "cat-queue-toggle__badge";
+ el.queueToggle.append(badge);
+ }
+ const badge = el.queueToggle.querySelector(".cat-queue-toggle__badge");
+ if (badge) badge.textContent = String(count);
+ } else {
+ if (existing) existing.remove();
+ }
+});
+
+// Player bar
+effect(() => {
+ const _track = c.currentTrack();
+ const _playing = c.isPlaying();
+ const _audio = c.audio();
+ const _repeat = repeatShuffle.repeat();
+ const _shuffle = repeatShuffle.shuffle();
+ void _track;
+ void _playing;
+ void _audio;
+ void _repeat;
+ void _shuffle;
+ renderPlayer();
+});
+
+// Playlist select
+effect(() => {
+ const _playlists = playlists();
+ void _playlists;
+ renderPlaylistSelect();
+});
+
+// Auto-fetch artwork for currently playing track
+effect(() => {
+ const track = c.currentTrack();
+ if (track) {
+ const key = albumOf(track).toLowerCase();
+ fetchArt(key, track);
+ }
+});
+
+////////////////////////////////////////////
+// BOOT — auto-fill queue when empty
+////////////////////////////////////////////
+
+let autoFilled = false;
+
+effect(() => {
+ const fingerprint = queue.supplyFingerprint();
+ const now = queue.now();
+ const future = queue.future();
+
+ if (fingerprint === undefined) return;
+ if (now !== null || future.length > 0) return;
+ if (autoFilled) return;
+
+ const all = scopedTracks.tracks();
+ if (all.length === 0) return;
+
+ autoFilled = true;
+ queue.supply({ trackIds: all.map((t) => t.id) });
+ queue.fill({ augment: true, amount: 50, shuffled: true });
+});
+
+////////////////////////////////////////////
+// 🚀
+////////////////////////////////////////////
+
+foundation.ready();
diff --git a/src/facets/themes/catalogue-one/variables.css b/src/facets/themes/catalogue-one/variables.css
new file mode 100644
index 00000000..b13189cc
--- /dev/null
+++ b/src/facets/themes/catalogue-one/variables.css
@@ -0,0 +1,12 @@
+:root,
+:host {
+ --cat-bg: oklch(14% 0.01 280);
+ --cat-surface: oklch(20% 0.012 280);
+ --cat-surface-hover: oklch(25% 0.012 280);
+ --cat-text: oklch(88% 0.005 280);
+ --cat-text-dim: oklch(58% 0.008 280);
+ --cat-accent: oklch(68% 0.18 300);
+ --cat-accent-text: oklch(98% 0 0);
+ --cat-border: oklch(30% 0.01 280);
+ --cat-shadow: 0 8px 32px oklch(0% 0 0 / 0.4);
+}