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 @@ +