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