From b173f600858305a1d8983158ee776b3a6be31ba3 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:03:45 -0500 Subject: [PATCH] search: the results page, the typeahead, and a session that survives a reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page is the signed-off prototype built out: floating rounded hero, inline top-tracks panel, filter chips, two-column Songs|Albums beside Artists, and all seven states — results, searching, no results, one-kind-only, library-empty, provider-unreachable, first visit. MusicBrainz records are merged in silently behind the Deezer rows. The top result is ranked by POPULARITY, not by kind: each kind puts up its most popular exact match and the winner is whichever holds the largest share of its own kind's leader. A raw fans-vs-rank comparison was tried first and handed "panda bear" to an Owl City song. Two provenance traps are paid for here. Deezer's /search/album returns no release_date at all, so sorting newest-first ordered by PROVENANCE rather than by date; dates are backfilled from the artist overview, resolved PER ALBUM ARTIST because Deezer's artist search answers nothing for "weezer blue album". And the typeahead holds its keyboard selection as the row's KEY rather than its index, because the MusicBrainz supplement lands ~600ms later and re-orders the album group under the cursor. The hero's middle third is hidden on the width of the PANE, not the window. The sidebar and the now-playing rail take ~490px between them, so a 1504px window can leave the hero 1012px — a window-width media query cannot see that, and the "latest release" card ended up sheared down to 26px of itself. The budget is written out at the @container rule. Session restore rounds it off: reload or restart returns to the same place with the same track, everything but the transport coming from localStorage. Co-Authored-By: Claude Opus 5 --- src/App.svelte | 5 + src/components/Sidebar.svelte | 34 +- src/components/TitleBar.svelte | 662 +++++++-- src/lib/api.js | 13 + src/lib/state.svelte.js | 602 +++++++- src/views/CatalogSearch.svelte | 2492 +++++++++++++++++++++++++++----- 6 files changed, 3315 insertions(+), 493 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index f9c5d80..3426022 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -26,6 +26,7 @@ refreshLibrary, refreshPlaylists, refreshRecents, + restoreSession, } from "./lib/state.svelte.js"; import { floatScroll } from "./lib/scrollbar.js"; @@ -60,6 +61,10 @@ refreshLibrary(); refreshPlaylists(); refreshRecents(); + // Last, and after initEvents: the restore asks the audio thread what it is + // doing, and re-opens the page the last session was on. See the session + // section at the foot of state.svelte.js. + void restoreSession(); diff --git a/src/components/Sidebar.svelte b/src/components/Sidebar.svelte index dc6981c..5938075 100644 --- a/src/components/Sidebar.svelte +++ b/src/components/Sidebar.svelte @@ -11,9 +11,7 @@ refreshRecents, toggleSidebar, goToView, - openCatalogAlbum, - openAlbumByName, - openLibraryAlbum, + openRecentAlbum, getArt, } from "../lib/state.svelte.js"; import * as api from "../lib/api.js"; @@ -139,34 +137,6 @@ return !!a.art_path && a.art_path === playingArt; } - async function openRecent(a) { - if (a.kind === "catalog" && a.catalog_album_id) { - // The catalog page, not the old library album view — streamed - // records live in the catalog. The seed paints title/artist/cover - // immediately instead of flashing a skeleton. - openCatalogAlbum(a.catalog_album_id, { - seed: { - id: a.catalog_album_id, - title: a.title, - artist: a.artist, - cover: a.art_url ?? null, - }, - }); - return; - } - // A library row has no catalog id to open with — local albums carry no - // catalog identity at all (`albums.mbid` is null and never surfaced) — - // so the record is resolved by artist + title, where BOTH must match - // normalized or nothing opens. That namesake guard lives in - // openAlbumByName and is the reason this can't just take a first hit. - // - // Either way it is the SAME album page: a record no catalog has (Panda - // Bear's "A ? of WHEN", verified live) opens from the library's own - // data instead. There is no second album view to fall back to. - if (await openAlbumByName(a.artist, a.title)) return; - void openLibraryAlbum(a.album_id); - } - $effect(() => { if (!recentsStore.loaded) refreshRecents(); }); @@ -261,7 +231,7 @@ class="rec" class:on={isPlaying(row.album)} title="{row.album.title} — {row.album.artist}" - onclick={() => openRecent(row.album)} + onclick={() => openRecentAlbum(row.album)} > {#if coverOf(row.album)} diff --git a/src/components/TitleBar.svelte b/src/components/TitleBar.svelte index 756248e..bbcd5d3 100644 --- a/src/components/TitleBar.svelte +++ b/src/components/TitleBar.svelte @@ -3,13 +3,24 @@ app, ui, downloads, + catalog, + recentsStore, catalogGo, + catalogSearchIdentityAlbums, + catalogTrackOwnership, + recentSearches, + searchNetworkFor, openCatalogArtist, + openCatalogAlbum, + openRecentAlbum, warmCatalogArtist, cancelCatalogArtistWarmup, streamSong, streamCatalogAlbum, + getArt, + gridCover, } from "../lib/state.svelte.js"; + import { floatScroll } from "../lib/scrollbar.js"; import * as api from "../lib/api.js"; import Icon from "./Icon.svelte"; @@ -76,36 +87,157 @@ } }); - // ---- live search suggestions (Spotify-style dropdown) ---- + // ---- live search suggestions ------------------------------------------ + // The typeahead has three faces, all of them designed (see the prototype at + // research/search-redesign/PROTOTYPE-search.html): + // + // typing grouped Artists / Albums / Songs, each section under a + // hairline rule with its total; the keyboard selection + // carries a 3px accent edge so arrowing through it reads. + // focused, empty your recent searches and what you were last playing. + // no matches it does NOT vanish — a catalogue miss can still succeed + // on the Soulseek network, and it offers that. + // + // It is deliberately WIDER than the field (left/right: -70px, ~500px): at + // the field's own width real record titles truncated in every group. let sugg = $state(null); // last catalogSearch results for the dropdown let suggOpen = $state(false); - let hi = $state(-1); // highlighted row index, -1 = none + let focused = $state(false); + // The keyboard selection is held as the row's KEY, not its index. The + // MusicBrainz supplement lands ~600ms after the rest and re-orders the album + // group; an index would then point at a different record than the one the + // accent edge is drawn on, and Enter would open that one. `null` = the + // footer, which is what Enter does by default. + let hiKey = $state(null); let suggSeq = 0; let debounceTimer = null; + let mbTimer = null; + let mbExtras = $state([]); + let owned = $state({}); + // Keyed by artist AND query: the extras are deduped against the album list + // they were computed for, and a different query has a different one. The + // MusicBrainz traffic behind this is cached backend-side per artist, so a + // repeat costs nothing. + const mbCache = new Map(); + const MB_CACHE_MAX = 32; - const suggRows = $derived.by(() => { - if (!sugg) return []; - const rows = []; - for (const a of sugg.artists.slice(0, 2)) { - rows.push({ kind: "artist", label: a.name, sub: "Artist", img: a.picture, round: true, data: a }); + const recentQueries = $derived(recentSearches(catalog.query, 4)); + const jumpBackIn = $derived(recentsStore.albums.slice(0, 3)); + let recentArt = $state({}); + $effect(() => { + for (const a of jumpBackIn) { + const p = a.art_path; + if (!p || recentArt[p] !== undefined) continue; + recentArt[p] = null; + getArt(p) + .then((u) => (recentArt[p] = u)) + .catch(() => {}); } - for (const al of sugg.albums.slice(0, 3)) { - const year = al.release_date ? ` · ${al.release_date.slice(0, 4)}` : ""; - rows.push({ kind: "album", label: al.title, sub: `Album · ${al.artist}${year}`, img: al.cover, round: false, data: al }); - } - for (const t of sugg.tracks.slice(0, 4)) { - rows.push({ kind: "track", label: t.title, sub: `Song · ${t.artist}`, img: t.cover, round: false, data: t }); - } - return rows; }); + const recentCover = (a) => a.art_url ?? (a.art_path ? recentArt[a.art_path] : null); + + /** Records only MusicBrainz carries are merged into Albums with no badge and + * no section of their own, exactly as on the results page — and the merged + * list is sorted newest-first, or a four-row slice would never reach them. */ + const suggAlbums = $derived( + [...(sugg?.albums ?? []), ...mbExtras].sort((a, b) => + (b.release_date ?? "").localeCompare(a.release_date ?? ""), + ), + ); + + const suggGroups = $derived.by(() => { + if (!sugg) return []; + const groups = []; + if (sugg.artists.length) { + groups.push({ + label: "Artists", + count: sugg.artists.length, + rows: sugg.artists.slice(0, 2).map((a) => ({ + kind: "artist", + key: `artist:${a.id}`, + label: a.name, + sub: a.fans ? `Artist · ${fmtFans(a.fans)} fans` : "Artist", + img: a.picture, + round: true, + data: a, + })), + }); + } + if (suggAlbums.length) { + groups.push({ + label: "Albums", + count: suggAlbums.length, + rows: suggAlbums.slice(0, 4).map((al) => ({ + kind: "album", + key: `album:${al.id}`, + label: al.title, + sub: [al.release_date?.slice(0, 4), al.artist].filter(Boolean).join(" · "), + img: al.cover, + round: false, + data: al, + })), + }); + } + if (sugg.tracks.length) { + groups.push({ + label: "Songs", + count: sugg.tracks.length, + rows: sugg.tracks.slice(0, 3).map((t) => ({ + kind: "track", + key: `track:${t.id}`, + label: t.title, + sub: `${t.artist} · ${t.album}`, + dur: fmtDuration(t.duration_secs), + img: t.cover, + round: false, + data: t, + })), + }); + } + return groups; + }); + + /** Flat, in visual order — what the arrow keys walk. */ + const suggRows = $derived(suggGroups.flatMap((g) => g.rows)); + + /** Which of the three panels is on screen. */ + const suggMode = $derived.by(() => { + if (!suggOpen) return null; + if (!searchText.trim()) { + return recentQueries.length || jumpBackIn.length ? "empty" : null; + } + if (!sugg) return null; + return suggRows.length ? "list" : "none"; + }); + + const fmtFans = (n) => { + if (n == null) return null; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`; + if (n >= 1_000) return `${Math.round(n / 1_000)}K`; + return String(n); + }; + const fmtDuration = (s) => + s == null ? "" : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; function onSearchInput() { clearTimeout(debounceTimer); + clearTimeout(mbTimer); cancelCatalogArtistWarmup(); const q = searchText.trim(); + if (!q) { + // An emptied field falls back to the recent panel rather than + // closing: the reader is still in the field, still searching. + suggSeq += 1; + sugg = null; + mbExtras = []; + hiKey = null; + suggOpen = focused; + return; + } if (q.length < 2) { suggOpen = false; sugg = null; + mbExtras = []; return; } debounceTimer = setTimeout(async () => { @@ -115,38 +247,94 @@ // Ignore stale responses and results for superseded queries. if (seq !== suggSeq || searchText.trim() !== q) return; sugg = res; - hi = -1; + mbExtras = []; + hiKey = null; suggOpen = true; // The first artist is overwhelmingly the next click. Warm its // one-round-trip overview and art resolver while the user is // still reading the dropdown; keyed caches share the work. if (res.artists?.[0]) warmCatalogArtist(res.artists[0]); + void markOwned(res.tracks, seq); + scheduleMbSupplement(res, q, seq); } catch { // backend unavailable — no suggestions } }, 250); } + async function markOwned(tracks, seq) { + try { + const map = await catalogTrackOwnership(tracks); + if (seq === suggSeq) owned = map; + } catch { + owned = {}; + } + } + + /** The MusicBrainz supplement is NOT fetched per keystroke. It waits for the + * same 600ms of the artist staying the top hit that the artist warm-up + * waits for, so live-search churn can never pace-probe MusicBrainz. */ + function scheduleMbSupplement(res, query, seq) { + const seed = res?.artists?.[0]; + if (seed?.id == null) return; + const key = `${seed.id}|${query}`; + mbTimer = setTimeout(() => { + if (seq !== suggSeq) return; + if (!mbCache.has(key)) { + mbCache.set( + key, + catalogSearchIdentityAlbums(seed, res.albums).catch(() => []), + ); + // A long typing session would otherwise grow this without end. + while (mbCache.size > MB_CACHE_MAX) { + mbCache.delete(mbCache.keys().next().value); + } + } + mbCache.get(key).then((extras) => { + if (seq === suggSeq && extras.length) mbExtras = extras; + }); + }, 600); + } + function closeSuggest() { suggOpen = false; - hi = -1; + hiKey = null; } function pickRow(row) { closeSuggest(); cancelCatalogArtistWarmup(); if (row.kind === "artist") openCatalogArtist(row.data.id, row.data); - else if (row.kind === "album") streamCatalogAlbum(row.data); - else { - const t = row.data; - streamSong(t.artist, t.title, { - cover: t.cover, - trackId: t.id, - albumId: t.album_id, - album: t.album || null, - expectSecs: t.duration_secs ?? null, + else if (row.kind === "album") { + // Clicking a row GOES there; the play button on the row plays it. + const al = row.data; + openCatalogAlbum(al.id, { + seed: { id: al.id, title: al.title, artist: al.artist, cover: al.cover ?? null }, }); - } + } else playTrack(row.data); + } + + function playTrack(t) { + streamSong(t.artist, t.title, { + cover: t.cover, + trackId: t.id, + albumId: t.album_id, + album: t.album || null, + expectSecs: t.duration_secs ?? null, + }); + } + + function playRow(row) { + closeSuggest(); + cancelCatalogArtistWarmup(); + if (row.kind === "album") streamCatalogAlbum(row.data); + else if (row.kind === "track") playTrack(row.data); + } + + function pickQuery(q) { + closeSuggest(); + searchText = q; + catalogGo(q); } function searchKeydown(e) { @@ -154,15 +342,18 @@ if (e.key === "Escape") closeSuggest(); return; } + // A row that has since left the list reads as "no selection", so the + // arrows resume from the top rather than from a place that is gone. + const at = suggRows.findIndex((r) => r.key === hiKey); if (e.key === "ArrowDown") { e.preventDefault(); - hi = (hi + 1) % suggRows.length; + hiKey = suggRows[(at + 1) % suggRows.length].key; } else if (e.key === "ArrowUp") { e.preventDefault(); - hi = hi <= 0 ? suggRows.length - 1 : hi - 1; - } else if (e.key === "Enter" && hi >= 0) { + hiKey = suggRows[at <= 0 ? suggRows.length - 1 : at - 1].key; + } else if (e.key === "Enter" && at >= 0) { e.preventDefault(); - pickRow(suggRows[hi]); + pickRow(suggRows[at]); } else if (e.key === "Escape") { closeSuggest(); } @@ -170,17 +361,33 @@ function searchFocusOut(e) { // Keep the panel open while focus moves within the search form. - if (!e.currentTarget.contains(e.relatedTarget)) closeSuggest(); + if (!e.currentTarget.contains(e.relatedTarget)) { + focused = false; + closeSuggest(); + } + } + + function searchFocus() { + focused = true; + // Returning to the field re-opens what it last had to say. + if (!searchText.trim()) suggOpen = recentQueries.length > 0 || jumpBackIn.length > 0; + else if (sugg && searchText.trim().length >= 2) suggOpen = true; } function submitSearch(e) { - e.preventDefault(); + e?.preventDefault(); clearTimeout(debounceTimer); + clearTimeout(mbTimer); suggSeq += 1; // cancel any in-flight suggestion fetch closeSuggest(); catalogGo(searchText); } + function networkSearch() { + closeSuggest(); + searchNetworkFor(searchText); + } + async function syncMaximized() { try { maximized = await api.win.isMaximized(); @@ -223,6 +430,9 @@ +