search: the results page, the typeahead, and a session that survives a reload
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
7547fed56d
commit
b173f60085
6 files changed
+3315
-493
No files matched your search
@@ -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();
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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)}
|
||||
<img src={coverOf(row.album)} alt="" draggable="false" />
|
||||
|
||||
+547
-115
@@ -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 @@
|
||||
</div>
|
||||
<!-- The Soulseek status lives in the sidebar's glass bar now: it is an
|
||||
account presence, and it belongs next to the library it feeds. -->
|
||||
<!-- The dropdown is a CHILD of the field, not of the titlebar: it is
|
||||
positioned against the field's own box (left/right: -70px). Parented to
|
||||
the bar it rendered 1223px wide. -->
|
||||
<form class="bar-search" onsubmit={submitSearch} onfocusout={searchFocusOut}>
|
||||
<Icon name="search" size={13} />
|
||||
<input
|
||||
@@ -231,43 +441,130 @@
|
||||
bind:value={searchText}
|
||||
oninput={onSearchInput}
|
||||
onkeydown={searchKeydown}
|
||||
onfocus={() => { if (sugg && searchText.trim().length >= 2) suggOpen = true; }}
|
||||
onfocus={searchFocus}
|
||||
spellcheck="false"
|
||||
/>
|
||||
{#if suggOpen && suggRows.length > 0}
|
||||
{#if focused}
|
||||
<span class="kb" aria-hidden="true">↵</span>
|
||||
{/if}
|
||||
{#if suggMode}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="suggest" onmousedown={(e) => e.preventDefault()}>
|
||||
{#each suggRows as row, i (row.kind + row.data.id)}
|
||||
<div class="suggest" use:floatScroll onmousedown={(e) => e.preventDefault()}>
|
||||
{#if suggMode === "empty"}
|
||||
{#if recentQueries.length}
|
||||
<div class="sg-h">Recent<span class="line"></span></div>
|
||||
<div class="chiprow">
|
||||
{#each recentQueries as q (q)}
|
||||
<button type="button" class="rc" onclick={() => pickQuery(q)}>
|
||||
<Icon name="search" size={11} />{q}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if jumpBackIn.length}
|
||||
<div class="sg-h">Jump back in<span class="line"></span></div>
|
||||
{#each jumpBackIn as a (a.kind === "catalog" ? `c:${a.catalog_album_id}` : `l:${a.album_id}`)}
|
||||
<button
|
||||
type="button"
|
||||
class="sg"
|
||||
onclick={() => {
|
||||
closeSuggest();
|
||||
openRecentAlbum(a);
|
||||
}}
|
||||
>
|
||||
{#if recentCover(a)}
|
||||
<img src={recentCover(a)} alt="" draggable="false" />
|
||||
{:else}
|
||||
<span class="sg-img placeholder"><Icon name="disc" size={13} /></span>
|
||||
{/if}
|
||||
<span class="sg-text">
|
||||
<span class="t">{a.title}</span>
|
||||
<span class="s">{a.artist}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else if suggMode === "none"}
|
||||
<!-- Never a vanishing panel: the network is still an answer. -->
|
||||
<div class="nomatch">
|
||||
<div class="nomatch-h">No quick matches</div>
|
||||
<div class="nomatch-s">
|
||||
Nothing in the catalogue starts with “{searchText.trim()}”.
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="sg-ft" onclick={networkSearch}>
|
||||
<Icon name="globe" size={13} strokeWidth={1.8} />
|
||||
<span>Search the network for files</span>
|
||||
<span class="kb">↵</span>
|
||||
</button>
|
||||
{:else}
|
||||
{#each suggGroups as group (group.label)}
|
||||
<div class="sg-h">
|
||||
{group.label}<span class="c">{group.count}</span><span class="line"
|
||||
></span>
|
||||
</div>
|
||||
{#each group.rows as row (row.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="sg"
|
||||
class:on={row.key === hiKey}
|
||||
onmouseenter={() => (hiKey = row.key)}
|
||||
onclick={() => pickRow(row)}
|
||||
>
|
||||
{#if row.img}
|
||||
<img
|
||||
class:round={row.round}
|
||||
src={gridCover(row.img)}
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
{:else}
|
||||
<span class="sg-img placeholder" class:round={row.round}>
|
||||
<Icon name="music" size={13} />
|
||||
</span>
|
||||
{/if}
|
||||
<span class="sg-text">
|
||||
<span class="t">
|
||||
{row.label}
|
||||
{#if row.kind === "track" && owned[row.data.id]}
|
||||
<span class="own">SAVED</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="s">{row.sub}</span>
|
||||
</span>
|
||||
{#if row.dur}
|
||||
<span class="d">{row.dur}</span>
|
||||
{/if}
|
||||
{#if row.kind !== "artist"}
|
||||
<!-- Hover swaps the duration for this. -->
|
||||
<span
|
||||
class="play"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
playRow(row);
|
||||
}}
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
<Icon name="play" size={11} />
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="sugg-row"
|
||||
class:active={i === hi}
|
||||
onmouseenter={() => (hi = i)}
|
||||
onclick={() => pickRow(row)}
|
||||
class="sg-ft"
|
||||
class:on={hiKey === null}
|
||||
onclick={() => submitSearch()}
|
||||
>
|
||||
{#if row.img}
|
||||
<img class="sugg-img" class:round={row.round} src={row.img} alt="" draggable="false" />
|
||||
{:else}
|
||||
<div class="sugg-img placeholder" class:round={row.round}>
|
||||
<Icon name="music" size={13} />
|
||||
</div>
|
||||
{/if}
|
||||
<span class="sugg-text">
|
||||
<span class="sugg-label">{row.label}</span>
|
||||
<span class="sugg-sub">{row.sub}</span>
|
||||
</span>
|
||||
{#if row.kind !== "artist"}
|
||||
<span class="sugg-play"><Icon name="play" size={12} /></span>
|
||||
{/if}
|
||||
<Icon name="search" size={13} />
|
||||
<span>See all results for “{searchText.trim()}”</span>
|
||||
<span class="kb">↵</span>
|
||||
</button>
|
||||
{/each}
|
||||
<button type="button" class="sugg-row all" class:active={hi === -1} onclick={submitSearch}>
|
||||
<span class="sugg-all-icon"><Icon name="search" size={13} /></span>
|
||||
<span class="sugg-text">
|
||||
<span class="sugg-label">See all results for “{searchText.trim()}”</span>
|
||||
<span class="sugg-sub">Enter</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
@@ -381,8 +678,19 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* The ONE change to the field: the focus ring was `--text-muted`, which
|
||||
read as a dead grey border rather than as the field being live. It is the
|
||||
accent now, and a ↵ hint appears beside the caret to say what Enter does.
|
||||
Every other measurement above is untouched. */
|
||||
.bar-search {
|
||||
outline: 1px solid transparent;
|
||||
transition:
|
||||
outline-color 0.18s,
|
||||
color 0.18s;
|
||||
}
|
||||
|
||||
.bar-search:focus-within {
|
||||
outline: 1px solid var(--text-muted);
|
||||
outline: 1px solid var(--accent);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -400,12 +708,28 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.kb {
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-muted);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ---- live suggestions dropdown ---- */
|
||||
/* Wider than the field on both sides: at the field's own 360px, real record
|
||||
titles ("Superman (Garage Practice) / The BBC Tracks / …") truncated in
|
||||
every group. This is the one place the old -8px rule is deliberately
|
||||
broken. Capped in height because the full grouped list is taller than the
|
||||
window; the overflow is the app's own floating overlay bar
|
||||
(`use:floatScroll`), which is invisible at rest. */
|
||||
.suggest {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
left: -8px;
|
||||
right: -8px;
|
||||
left: -70px;
|
||||
right: -70px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px;
|
||||
@@ -413,101 +737,209 @@
|
||||
background: var(--bg-elevated);
|
||||
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.55);
|
||||
z-index: 50;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
animation: ddin 0.17s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
@keyframes ddin {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.suggest {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sugg-row {
|
||||
/* A section header: name, its total, and a hairline running to the edge. */
|
||||
.sg-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 9px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.13em;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
padding: 7px 8px 4px;
|
||||
}
|
||||
.sg-h .c {
|
||||
background: var(--bg-highlight);
|
||||
border-radius: 99px;
|
||||
padding: 1px 6px;
|
||||
font-size: 8.5px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.sg-h .line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.sg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
border-radius: 7px;
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sugg-row.active {
|
||||
.sg:hover,
|
||||
.sg.on {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
.sugg-img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
/* The keyboard selection carries an accent edge, so arrowing down the list
|
||||
is legible as a *selection* rather than as another hover. */
|
||||
.sg.on::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
.sg img,
|
||||
.sg-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
flex: none;
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
.sugg-img.round {
|
||||
.sg img.round,
|
||||
.sg-img.round {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sugg-img.placeholder {
|
||||
.sg-img.placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sugg-text {
|
||||
.sg-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sugg-label {
|
||||
font-size: 12px;
|
||||
.sg .t {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sugg-sub {
|
||||
font-size: 11px;
|
||||
.sg .s {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sugg-play {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
.sg .d {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
}
|
||||
/* Hover swaps the duration for the thing you actually want at that moment. */
|
||||
.sg .play {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
opacity: 0;
|
||||
flex-shrink: 0;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
display: none;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sg:hover .play,
|
||||
.sg.on .play {
|
||||
display: grid;
|
||||
}
|
||||
.sg:hover .d,
|
||||
.sg.on .d {
|
||||
display: none;
|
||||
}
|
||||
.own {
|
||||
font-size: 8px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-press);
|
||||
color: var(--text-secondary);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sugg-row:hover .sugg-play,
|
||||
.sugg-row.active .sugg-play {
|
||||
opacity: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sugg-row.all {
|
||||
margin-top: 2px;
|
||||
border-top: 1px solid var(--bg-highlight);
|
||||
border-radius: 0 0 7px 7px;
|
||||
.sg-ft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 7px 8px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11.5px;
|
||||
font-weight: 800;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sugg-row.all.active,
|
||||
.sugg-row.all:hover {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-primary);
|
||||
.sg-ft:hover,
|
||||
.sg-ft.on {
|
||||
color: #fff;
|
||||
}
|
||||
.sg-ft .kb {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sugg-all-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
.chiprow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 2px 6px 6px;
|
||||
}
|
||||
.rc {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 99px;
|
||||
background: var(--bg-highlight);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
margin: 2px;
|
||||
}
|
||||
.rc:hover {
|
||||
background: var(--bg-press);
|
||||
}
|
||||
|
||||
.nomatch {
|
||||
padding: 18px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.nomatch-h {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.nomatch-s {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
|
||||
@@ -77,6 +77,10 @@ export const playLocal = (path, duration) => invoke("play_local", { path, durati
|
||||
export const preloadLocal = (path, duration) => invoke("preload_local", { path, duration });
|
||||
export const playerControl = (action, value, rate) =>
|
||||
invoke("player_control", { action, value: value ?? null, rate: rate ?? null });
|
||||
// What the audio thread is doing right now. The webview only ever learns about
|
||||
// playback from events, so after a reload this is the only way to find out that
|
||||
// audio is still running — see `restoreSession`.
|
||||
export const playerState = () => invoke("player_state");
|
||||
export const waveform = (path, buckets) => invoke("waveform", { path, buckets });
|
||||
export const libraryAlbums = () => invoke("library_albums");
|
||||
export const albumTracks = (albumId) => invoke("album_tracks", { albumId });
|
||||
@@ -149,6 +153,15 @@ export const catalogArtistPopular = (artistId) =>
|
||||
export const pickArtistHero = (artistId) => invoke("pick_artist_hero", { artistId });
|
||||
export const clearArtistHero = (artistId) => invoke("clear_artist_hero", { artistId });
|
||||
export const catalogRelated = (artistId) => invoke("catalog_related", { artistId });
|
||||
// Deferred Last.fm pass for search results: `tracks` is [{ artistId, title }]
|
||||
// and the answer comes back in the same order. Kept OUT of catalog_search,
|
||||
// which the typeahead calls per keystroke and which must never wait on
|
||||
// MusicBrainz's 1 req/s gate.
|
||||
export const catalogTrackListeners = (tracks) =>
|
||||
invoke("catalog_track_listeners", { tracks });
|
||||
// Genres + Last.fm totals for the search hero's artist. Deferred, like the rest.
|
||||
export const catalogArtistProfile = (artistId) =>
|
||||
invoke("catalog_artist_profile", { artistId });
|
||||
export const catalogAlbum = (albumId) => checkedAlbumPayload(albumId);
|
||||
export const catalogAlbumInfo = (artistId, artist, title) =>
|
||||
invoke("catalog_album_info", { artistId: artistId ?? null, artist, title });
|
||||
|
||||
+600
-2
@@ -6,6 +6,7 @@ import {
|
||||
recordVisit,
|
||||
refreshVisit,
|
||||
captureScroll,
|
||||
restoreScroll,
|
||||
goBack,
|
||||
goForward,
|
||||
goToCrumb,
|
||||
@@ -823,6 +824,9 @@ export async function refreshLibrary() {
|
||||
// Artist/art refreshes replace files at the same cached path. Drop
|
||||
// data-URL entries so the webview fetches the newly resized image.
|
||||
artCache.clear();
|
||||
// Saving a song adds tracks to an album this map has already answered
|
||||
// for; a stale answer would leave the new SAVED badge off until reload.
|
||||
libraryAlbumTitles.clear();
|
||||
libraryStore.albums = await api.libraryAlbums();
|
||||
libraryStore.loaded = true;
|
||||
window.__libErr = null;
|
||||
@@ -860,6 +864,36 @@ export const catalog = $state({
|
||||
albumSeed: null, // { cover, title, artist, kind } | null
|
||||
pendingTrackId: null, // a track to scroll to/flash on the album page
|
||||
error: "",
|
||||
// ---- search page supplements ------------------------------------------
|
||||
// Three things the results page shows that a Deezer search does not
|
||||
// return. Each lands on its own schedule and each is additive: the page
|
||||
// is complete without any of them, and none of them ever re-orders or
|
||||
// removes a row that is already on screen.
|
||||
//
|
||||
// Records only MusicBrainz knows about (Death Grips' Exmilitary, MBV's
|
||||
// Loveless). Merged into Albums with no badge and no section of their
|
||||
// own — see `catalogSearchIdentityAlbums`.
|
||||
searchAlbumExtras: [],
|
||||
// { [albumId]: "YYYY-MM-DD" }. Deezer's album SEARCH carries no release
|
||||
// dates at all — 0 of 12 on a live "panda bear" — while the MusicBrainz
|
||||
// supplement carries one for every row. Sorting the merged list by date
|
||||
// without this therefore did the exact opposite of what it was for: every
|
||||
// supplemented record floated above every result Deezer actually returned,
|
||||
// and provenance decided position again. The dates come from the artist
|
||||
// OVERVIEW, which `warmCatalogArtist` already fetches on every search, so
|
||||
// this costs nothing.
|
||||
searchAlbumDates: {},
|
||||
// { [trackId]: listeners } — Last.fm counts for the top artist's songs,
|
||||
// lifted from the same command the artist page's Popular list uses.
|
||||
searchListeners: {},
|
||||
// { [trackId]: true } for result songs already saved in the library.
|
||||
searchOwned: {},
|
||||
// { listeners, plays, tags } for the artist the hero is about.
|
||||
searchArtistProfile: null,
|
||||
// The hero album's own page — its real tracklist and headline totals. Only
|
||||
// fetched when an ALBUM is the top result, which is the only time the hero
|
||||
// has anything to do with it.
|
||||
searchAlbumPage: null,
|
||||
});
|
||||
let artistPageRequest = 0;
|
||||
let artistWarmGeneration = 0;
|
||||
@@ -1341,10 +1375,23 @@ export async function catalogGo(query) {
|
||||
catalog.results = null;
|
||||
catalog.loading = true;
|
||||
catalog.error = "";
|
||||
catalog.searchAlbumExtras = [];
|
||||
catalog.searchAlbumDates = {};
|
||||
catalog.searchListeners = {};
|
||||
catalog.searchOwned = {};
|
||||
catalog.searchArtistProfile = null;
|
||||
catalog.searchAlbumPage = null;
|
||||
try {
|
||||
const results = await api.catalogSearch(q);
|
||||
if (request !== catalogSearchRequest) return;
|
||||
catalog.results = results;
|
||||
// Only a query that found something is worth offering back, or learning
|
||||
// as a correctly-spelled name — see `recentSearches`.
|
||||
if (results?.artists?.length || results?.albums?.length || results?.tracks?.length) {
|
||||
fruitfulSearches.add(q);
|
||||
} else {
|
||||
fruitfulSearches.delete(q);
|
||||
}
|
||||
for (const artist of results?.artists ?? []) rememberArtistSeed(artist);
|
||||
// Search results are normally read for a moment before the first artist
|
||||
// is opened. Spend that otherwise-idle time warming its identity/art
|
||||
@@ -1354,6 +1401,14 @@ export async function catalogGo(query) {
|
||||
if (likelyArtistId != null) {
|
||||
warmCatalogArtist(results.artists[0]);
|
||||
}
|
||||
// Every supplement is fired AFTER the results are on screen and none is
|
||||
// awaited: the page must never wait on MusicBrainz's 1 req/s gate or on
|
||||
// Last.fm to paint what Deezer already answered.
|
||||
const stillCurrent = () => request === catalogSearchRequest;
|
||||
void applySearchAlbumDates(results, stillCurrent);
|
||||
void applySearchIdentityAlbums(results, stillCurrent);
|
||||
void applySearchListeners(results, stillCurrent);
|
||||
void applySearchOwnership(results, stillCurrent);
|
||||
} catch (e) {
|
||||
if (request === catalogSearchRequest) catalog.error = String(e);
|
||||
} finally {
|
||||
@@ -1361,6 +1416,288 @@ export async function catalogGo(query) {
|
||||
}
|
||||
}
|
||||
|
||||
/* The two hero supplements. Both are driven by the VIEW rather than fired from
|
||||
* `catalogGo`, because only the view knows which kind won the hero — and each
|
||||
* costs a rate-limited lookup that would be wasted on the other two kinds. The
|
||||
* ranking rule stays in one place instead of being mirrored here. */
|
||||
let searchProfileRequest = 0;
|
||||
/** Genres + Last.fm totals for an ARTIST hero. */
|
||||
export async function loadSearchArtistProfile(artistId) {
|
||||
const request = ++searchProfileRequest;
|
||||
catalog.searchArtistProfile = null;
|
||||
if (artistId == null) return;
|
||||
try {
|
||||
const profile = await api.catalogArtistProfile(artistId);
|
||||
if (request === searchProfileRequest) catalog.searchArtistProfile = profile;
|
||||
} catch {
|
||||
// The hero reads fine on Deezer's own two figures alone.
|
||||
}
|
||||
}
|
||||
|
||||
let searchAlbumPageRequest = 0;
|
||||
/** The real tracklist and headline totals for an ALBUM hero. The search's own
|
||||
* track results are a different list entirely — they are what matched the
|
||||
* query, not what is on the record. */
|
||||
export async function loadSearchAlbumPage(albumId) {
|
||||
const request = ++searchAlbumPageRequest;
|
||||
catalog.searchAlbumPage = null;
|
||||
if (albumId == null) return;
|
||||
try {
|
||||
const page = await api.catalogAlbum(albumId);
|
||||
if (request === searchAlbumPageRequest) catalog.searchAlbumPage = page;
|
||||
} catch {
|
||||
// Falls back to the matched songs, which is what it showed before.
|
||||
}
|
||||
}
|
||||
|
||||
/** Hand the query off to the Soulseek network file search.
|
||||
*
|
||||
* A catalogue miss is not the end of the road in a Soulseek client — the
|
||||
* network routinely carries what no catalogue lists — so every dead end on the
|
||||
* results page offers this rather than nothing. */
|
||||
export async function searchNetworkFor(query) {
|
||||
const q = (query ?? "").trim();
|
||||
if (!q) return;
|
||||
// Seeded before the view swaps: Search.svelte reads `search.query` once, at
|
||||
// mount, to show what it is looking for.
|
||||
search.query = q;
|
||||
goToView("search");
|
||||
if (app.connection !== "online") {
|
||||
app.showLogin = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runSearch(q);
|
||||
} catch (err) {
|
||||
app.loginError = String(err);
|
||||
app.showLogin = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** The records a Deezer SEARCH cannot see, for the artist a search is about.
|
||||
*
|
||||
* Deezer's search index is not a discography: Death Grips' Exmilitary, My
|
||||
* Bloody Valentine's Loveless and half of Panda Bear's early run are simply
|
||||
* absent from it. The artist page already solves this — it merges a
|
||||
* MusicBrainz supplement over the Deezer list — so the results page runs the
|
||||
* SAME merge (`mergeArtistIdentity`) rather than a second, disagreeing one.
|
||||
*
|
||||
* Returns only the rows the merge INJECTED, so the caller can fold them into
|
||||
* the album list it already has. The albums handed in are narrowed to the top
|
||||
* artist's own first: an MB release group belongs to that artist, and letting
|
||||
* a namesake's row block one would delete a real record from the page.
|
||||
*/
|
||||
export async function catalogSearchIdentityAlbums(seedArtist, searchAlbums) {
|
||||
const name = seedArtist?.name ?? "";
|
||||
if (seedArtist?.id == null || !name) return [];
|
||||
const identity = await api.catalogArtistIdentity(seedArtist.id, name);
|
||||
if (!identity?.albums?.length) return [];
|
||||
const key = catNorm(name);
|
||||
const mine = (searchAlbums ?? []).filter((a) => catNorm(a?.artist) === key);
|
||||
const merged = mergeArtistIdentity(
|
||||
{ artist: { name }, albums: mine, socials: [], typeHints: [] },
|
||||
identity,
|
||||
);
|
||||
const known = new Set(mine.map((a) => a?.id));
|
||||
return (merged?.albums ?? []).filter((a) => a && !known.has(a.id));
|
||||
}
|
||||
|
||||
/** Release dates for the albums a search returned.
|
||||
*
|
||||
* `/search/album` omits `release_date` entirely; `/artist/{id}/albums` — which
|
||||
* the artist OVERVIEW wraps, and which `warmCatalogArtist` has already asked
|
||||
* for by the time this runs — carries one for every release. Matched by album
|
||||
* id, so this is a lookup and never a title guess. Albums by an artist other
|
||||
* than the one searched for keep no date, and simply show none.
|
||||
*/
|
||||
/** At most this many artists get a discography lookup for their dates. An
|
||||
* album-title query returns rows from tribute bands, karaoke acts and piano
|
||||
* covers; only the acts that actually hold several rows here are worth one. */
|
||||
const ALBUM_DATE_ARTIST_BUDGET = 3;
|
||||
|
||||
async function applySearchAlbumDates(results, active) {
|
||||
const undated = (results?.albums ?? []).filter((a) => a?.id != null && !a.release_date);
|
||||
if (!undated.length) return;
|
||||
// Grouped by the album's OWN artist rather than by the search's top artist.
|
||||
// Deezer's ARTIST search answers nothing at all for "weezer blue album", so
|
||||
// keying this on `results.artists[0]` left every row on an album-title
|
||||
// query dateless — which is what showed "Weezer · — · 10 tracks".
|
||||
const byArtist = new Map();
|
||||
for (const album of undated) {
|
||||
const key = catNorm(album.artist);
|
||||
if (!key) continue;
|
||||
if (!byArtist.has(key)) byArtist.set(key, { name: album.artist, rows: [] });
|
||||
byArtist.get(key).rows.push(album);
|
||||
}
|
||||
const groups = [...byArtist.values()]
|
||||
.sort((a, b) => b.rows.length - a.rows.length)
|
||||
.slice(0, ALBUM_DATE_ARTIST_BUDGET);
|
||||
const dates = {};
|
||||
await Promise.all(
|
||||
groups.map(async (group) => {
|
||||
try {
|
||||
const seed = await resolveArtistSeedByName(group.name);
|
||||
if (seed?.id == null || !active()) return;
|
||||
const overview = await getCatalogArtistOverview(seed.id);
|
||||
if (!active()) return;
|
||||
const byId = new Map();
|
||||
const byTitle = new Map();
|
||||
for (const album of overview?.albums ?? []) {
|
||||
if (album?.id == null || !album.release_date) continue;
|
||||
byId.set(String(album.id), album.release_date);
|
||||
// First listing wins: the discography arrives newest-first,
|
||||
// and the original pressing is the date a card should show.
|
||||
const key = normText(album.title);
|
||||
if (key && !byTitle.has(key)) byTitle.set(key, album.release_date);
|
||||
}
|
||||
for (const row of group.rows) {
|
||||
// A search hit and the same record in the artist's own
|
||||
// discography are frequently DIFFERENT Deezer ids — two
|
||||
// pressings of one album — so an id miss falls back to the
|
||||
// title. Safe here by construction: the only titles in play
|
||||
// are this one artist's own.
|
||||
const date =
|
||||
byId.get(String(row.id)) ?? byTitle.get(normText(row.title));
|
||||
if (date) dates[String(row.id)] = date;
|
||||
}
|
||||
} catch {
|
||||
// One artist failing to date does not stop the others.
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!active() || !Object.keys(dates).length) return;
|
||||
catalog.searchAlbumDates = dates;
|
||||
}
|
||||
|
||||
async function applySearchIdentityAlbums(results, active) {
|
||||
const seed = results?.artists?.[0];
|
||||
if (!seed) return;
|
||||
try {
|
||||
let extras = await catalogSearchIdentityAlbums(seed, results.albums);
|
||||
// A COLD MusicBrainz call answers with nothing under per-IP contention,
|
||||
// and "no albums" is indistinguishable from "this artist has none". One
|
||||
// delayed retry, exactly as the artist page's identity pass does — for a
|
||||
// genuinely album-less artist both calls answer from the URL caches, so
|
||||
// the retry costs the delay and no MusicBrainz traffic.
|
||||
if (!extras.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 8000));
|
||||
if (!active()) return;
|
||||
extras = await catalogSearchIdentityAlbums(seed, results.albums);
|
||||
}
|
||||
if (!active() || !extras.length) return;
|
||||
catalog.searchAlbumExtras = extras;
|
||||
} catch {
|
||||
// Best-effort supplement: the Deezer list is a complete page without it.
|
||||
}
|
||||
}
|
||||
|
||||
/** Last.fm listener counts for the result songs.
|
||||
*
|
||||
* Search results carry no counts — `catalog_search` is three Deezer calls and
|
||||
* nothing else, deliberately, because the typeahead calls it per keystroke.
|
||||
* The counts are a separate, deferred pass over EVERY row, resolved per
|
||||
* performing artist by that row's own Deezer artist id (see
|
||||
* `MetadataClient::track_listeners`). A row the backend has no count for keeps
|
||||
* `null` — a dash in the UI, never a fabricated zero.
|
||||
*
|
||||
* An earlier version asked only the TOP artist's popular list and matched
|
||||
* titles against it. That left every row credited to somebody else blank —
|
||||
* Daft Punk's "Doin' it Right (feat. Panda Bear)" showed a dash on a Panda Bear
|
||||
* search while the album page showed its 464k listeners.
|
||||
*/
|
||||
async function applySearchListeners(results, active) {
|
||||
const tracks = results?.tracks ?? [];
|
||||
if (!tracks.length) return;
|
||||
try {
|
||||
const counts = await api.catalogTrackListeners(
|
||||
tracks.map((t) => ({ artistId: t.artist_id ?? null, title: t.title })),
|
||||
);
|
||||
if (!active() || !Array.isArray(counts)) return;
|
||||
const out = {};
|
||||
tracks.forEach((t, i) => {
|
||||
if (counts[i] != null) out[t.id] = counts[i];
|
||||
});
|
||||
if (!active() || !Object.keys(out).length) return;
|
||||
catalog.searchListeners = out;
|
||||
} catch {
|
||||
// No counts is a page without bars, not a broken page.
|
||||
}
|
||||
}
|
||||
|
||||
const libraryAlbumTitles = new Map(); // library album id -> Promise<Set<normTitle>>
|
||||
|
||||
/** Which of these catalog songs are already saved, as `{ [trackId]: true }`.
|
||||
*
|
||||
* Ownership is per TRACK and the library index is per album, so this walks only
|
||||
* the library albums whose artist appears in the list — usually none, at most a
|
||||
* handful — and memoizes each album's tracklist for the session. Shared by the
|
||||
* results page and the typeahead so one SAVED badge cannot contradict another.
|
||||
*/
|
||||
export async function catalogTrackOwnership(tracks) {
|
||||
const rows = tracks ?? [];
|
||||
if (!rows.length || !libraryStore.albums.length) return {};
|
||||
const wanted = new Set(rows.map((t) => normText(t?.artist)).filter(Boolean));
|
||||
const albums = libraryStore.albums.filter((al) => wanted.has(normText(al.artist)));
|
||||
if (!albums.length) return {};
|
||||
const titlesByArtist = new Map();
|
||||
await Promise.all(
|
||||
albums.map(async (al) => {
|
||||
if (!libraryAlbumTitles.has(al.id)) {
|
||||
libraryAlbumTitles.set(
|
||||
al.id,
|
||||
api
|
||||
.albumTracks(al.id)
|
||||
.then((r) => new Set(r.map((row) => normText(row.title || ""))))
|
||||
.catch(() => new Set()),
|
||||
);
|
||||
}
|
||||
const titles = await libraryAlbumTitles.get(al.id);
|
||||
const key = normText(al.artist);
|
||||
if (!titlesByArtist.has(key)) titlesByArtist.set(key, new Set());
|
||||
const into = titlesByArtist.get(key);
|
||||
for (const t of titles) into.add(t);
|
||||
}),
|
||||
);
|
||||
const out = {};
|
||||
for (const t of rows) {
|
||||
if (titlesByArtist.get(normText(t?.artist))?.has(normText(t?.title))) out[t.id] = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function applySearchOwnership(results, active) {
|
||||
try {
|
||||
const owned = await catalogTrackOwnership(results?.tracks);
|
||||
if (!active() || !Object.keys(owned).length) return;
|
||||
catalog.searchOwned = owned;
|
||||
} catch {
|
||||
// No badge is a fine answer; a wrong badge is not.
|
||||
}
|
||||
}
|
||||
|
||||
/** Queries that actually found something this session.
|
||||
*
|
||||
* History alone is the wrong list to offer back: a typo you made two minutes
|
||||
* ago is in it, and re-running a known miss is not a suggestion. It is also the
|
||||
* wrong vocabulary for the results page's spelling correction, which would
|
||||
* otherwise learn the misspelling as a word and stop correcting it. */
|
||||
const fruitfulSearches = new Set();
|
||||
|
||||
/** The searches you ran this session that returned something, newest first —
|
||||
* read out of the app's own history rather than a second store, and never
|
||||
* written to disk. */
|
||||
export function recentSearches(exclude = "", limit = 6) {
|
||||
const out = [];
|
||||
for (let i = nav.stack.length - 1; i >= 0 && out.length < limit; i--) {
|
||||
const e = nav.stack[i];
|
||||
if (e.view !== "catalog" || !e.query) continue;
|
||||
if (e.query === exclude || out.includes(e.query)) continue;
|
||||
if (!fruitfulSearches.has(e.query)) continue;
|
||||
out.push(e.query);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Open an artist as a four-stage page: synchronous identity shell, fast
|
||||
* Deezer overview, independent provider artwork, then exhaustive refinement.
|
||||
* Every ID gets a shell; known search/discovery seeds add the name + blurred
|
||||
@@ -2617,6 +2954,42 @@ export async function openAlbumByName(artist, title, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a row from the recently-played list.
|
||||
*
|
||||
* Shared, because two surfaces show that list — the sidebar's history and the
|
||||
* search page's "Jump back in" — and the rules below are identity rules. A
|
||||
* second copy would be a second opinion about which record a history row is,
|
||||
* which is exactly the mistake the namesake guard in `openAlbumByName` exists
|
||||
* to prevent.
|
||||
*/
|
||||
export async function openRecentAlbum(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);
|
||||
}
|
||||
|
||||
/** Save the file(s) you're already streaming into the library — no re-search,
|
||||
* no re-source. `scope`: "track" (current) or "album" (all streamed tracks in
|
||||
* the queue). Downloads the KNOWN peer file(s) directly (skipping the search
|
||||
@@ -4226,6 +4599,11 @@ export const player = $state({
|
||||
waveform: [],
|
||||
// Which catalog entity is playing (drives now-playing badges).
|
||||
catalogRef: { albumId: null, trackId: null },
|
||||
// True for a track restored from the last session whose AUDIO did not
|
||||
// survive — an app restart rather than a page reload. The transport reads
|
||||
// "paused", but there is nothing loaded to resume, so play has to re-source
|
||||
// it from the queue. See `resumePlayback`.
|
||||
needsResource: false,
|
||||
});
|
||||
|
||||
// Adaptive accent theme, derived from the playing track's album art.
|
||||
@@ -4559,9 +4937,15 @@ export function initEvents() {
|
||||
player.scrubbing = false;
|
||||
player.error = "";
|
||||
break;
|
||||
case "playing":
|
||||
case "playing": {
|
||||
player.status = "playing";
|
||||
// A track re-sourced by a session restore starts at zero; put
|
||||
// the reader back where they were. Taken exactly once, so an
|
||||
// ordinary resume later in the same track is unaffected.
|
||||
const seekTo = takeResumeSeek();
|
||||
if (seekTo != null) api.playerControl("seek", seekTo);
|
||||
break;
|
||||
}
|
||||
case "paused":
|
||||
player.status = "paused";
|
||||
listenFlush();
|
||||
@@ -5286,10 +5670,224 @@ export async function runSearch(query) {
|
||||
// instance of this module (?t=-stamped vs bare), so a probe that imports by
|
||||
// URL may read/mutate a phantom copy while the app runs another. Probes must
|
||||
// use this global instead of importing.
|
||||
/* ------------------------------------------------------------------ session
|
||||
* Where you were, and what you were listening to, across a reload.
|
||||
*
|
||||
* The audio thread outlives the webview. A dev reload — or a crash, or a
|
||||
* WebView2 restart — leaves it playing while the page comes back with an empty
|
||||
* store, so the player bar drew a MOVING scrubber under the words "Nothing
|
||||
* playing" and the sidebar showed nothing playing either: position events kept
|
||||
* arriving, but `status`, `track` and `queue` had gone with the page.
|
||||
*
|
||||
* Two halves, and they have different owners:
|
||||
*
|
||||
* TRANSPORT — playing/paused and the position — belongs to the audio thread,
|
||||
* and is asked for at boot (`api.playerState`). Never guessed
|
||||
* from the snapshot: only the audio thread knows whether the
|
||||
* sound survived.
|
||||
* EVERYTHING ELSE — which song, the queue around it, where you had navigated
|
||||
* to and how far down the page — is the frontend's own and is
|
||||
* the part worth writing down.
|
||||
*
|
||||
* `app_status` already recovers the Soulseek connection this way at boot; this
|
||||
* is the same repair for playback and navigation.
|
||||
*/
|
||||
const SESSION_KEY = "soulstream.session.v1";
|
||||
/** Crumbs kept. The trail only ever renders a handful; the rest is history for
|
||||
* the back arrow, and 60 entries of it is not worth the quota. */
|
||||
const SESSION_NAV_MAX = 20;
|
||||
const SESSION_TICK_MS = 3000;
|
||||
/** Position moves constantly and is not part of the change signature, so a
|
||||
* long-playing track still gets its progress written down periodically. */
|
||||
const SESSION_MAX_STALE_MS = 12_000;
|
||||
|
||||
let sessionRestoring = false;
|
||||
let sessionSignature = "";
|
||||
let sessionWrittenAt = 0;
|
||||
|
||||
/** A crumb, minus anything that would be silly to store. `art` is frequently a
|
||||
* data: URL for library records — megabytes each, and they re-resolve from the
|
||||
* art cache on their own. */
|
||||
function packCrumb(entry) {
|
||||
if (!entry) return null;
|
||||
const art = typeof entry.art === "string" && entry.art.startsWith("data:") ? null : entry.art;
|
||||
return { ...entry, art };
|
||||
}
|
||||
|
||||
function writeSession() {
|
||||
if (sessionRestoring) return;
|
||||
try {
|
||||
// Puts the reader's scroll position on the entry they are standing on,
|
||||
// which is the same thing the back arrow relies on.
|
||||
captureScroll();
|
||||
const snapshot = {
|
||||
v: 1,
|
||||
view: ui.view,
|
||||
albumId: ui.albumId,
|
||||
playlistId: ui.playlistId,
|
||||
nav: {
|
||||
stack: nav.stack.slice(-SESSION_NAV_MAX).map(packCrumb),
|
||||
origin: packCrumb(nav.origin),
|
||||
},
|
||||
player: {
|
||||
queue: player.queue,
|
||||
queueIndex: player.queueIndex,
|
||||
track: player.track,
|
||||
position: player.position,
|
||||
duration: player.duration,
|
||||
catalogRef: player.catalogRef,
|
||||
shuffle: player.shuffle,
|
||||
repeat: player.repeat,
|
||||
volume: player.volume,
|
||||
muted: player.muted,
|
||||
},
|
||||
};
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(snapshot));
|
||||
sessionWrittenAt = Date.now();
|
||||
} catch {
|
||||
// Quota, or a profile with storage disabled. The session is a
|
||||
// convenience; nothing downstream may depend on it having been written.
|
||||
}
|
||||
}
|
||||
|
||||
/** Cheap "has anything worth saving changed" probe, run on a timer rather than
|
||||
* from a reactive effect: the alternative is subscribing to a store that ticks
|
||||
* five times a second, and the only field that does is deliberately excluded. */
|
||||
function sessionTick() {
|
||||
if (sessionRestoring) return;
|
||||
const signature = [
|
||||
ui.view, ui.albumId, ui.playlistId, nav.stack.length,
|
||||
player.status, player.queueIndex, player.queue.length,
|
||||
player.shuffle, player.repeat, player.volume, player.muted,
|
||||
].join("|");
|
||||
const stale = player.status !== "idle" && Date.now() - sessionWrittenAt > SESSION_MAX_STALE_MS;
|
||||
if (signature === sessionSignature && !stale) return;
|
||||
sessionSignature = signature;
|
||||
writeSession();
|
||||
}
|
||||
|
||||
/** Re-source a track that was restored without its audio — an app restart,
|
||||
* where the queue survived in the snapshot but the sound did not. Resuming a
|
||||
* live track is the ordinary path and goes straight through. */
|
||||
let resumeSeekSecs = null;
|
||||
export function resumePlayback() {
|
||||
if (player.status === "paused" && !player.needsResource) {
|
||||
api.playerControl("resume");
|
||||
return;
|
||||
}
|
||||
const index = player.queueIndex;
|
||||
if (index < 0 || index >= player.queue.length) return;
|
||||
// Pick the track up where it was left rather than at zero; applied once the
|
||||
// audio thread reports it is actually playing (see the `playing` case).
|
||||
resumeSeekSecs = player.position > 1 ? player.position : null;
|
||||
player.needsResource = false;
|
||||
playQueueIndex(index);
|
||||
}
|
||||
|
||||
/** Consumed by the player-event handler the first time a restored track
|
||||
* reaches `playing`. */
|
||||
export function takeResumeSeek() {
|
||||
const secs = resumeSeekSecs;
|
||||
resumeSeekSecs = null;
|
||||
return secs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put back what the last page left behind. Called once at boot, after the
|
||||
* stores exist.
|
||||
*/
|
||||
export async function restoreSession() {
|
||||
let saved = null;
|
||||
try {
|
||||
saved = JSON.parse(localStorage.getItem(SESSION_KEY) ?? "null");
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
if (!saved || saved.v !== 1) {
|
||||
setInterval(sessionTick, SESSION_TICK_MS);
|
||||
return;
|
||||
}
|
||||
sessionRestoring = true;
|
||||
try {
|
||||
// THE AUDIO THREAD IS THE AUTHORITY. A reload finds it still playing; a
|
||||
// restart finds it idle, and the snapshot's own status would be a lie.
|
||||
let transport = null;
|
||||
try {
|
||||
transport = await api.playerState();
|
||||
} catch {
|
||||
// Not running under Tauri, or IPC not up yet: treat as idle.
|
||||
}
|
||||
const saveState = saved.player ?? {};
|
||||
if (typeof saveState.volume === "number") player.volume = saveState.volume;
|
||||
player.muted = !!saveState.muted;
|
||||
player.shuffle = !!saveState.shuffle;
|
||||
player.repeat = saveState.repeat ?? "off";
|
||||
|
||||
const item = saveState.queue?.[saveState.queueIndex] ?? null;
|
||||
if (item && saveState.track) {
|
||||
player.queue = saveState.queue;
|
||||
player.queueIndex = saveState.queueIndex;
|
||||
player.track = saveState.track;
|
||||
player.duration = saveState.duration ?? null;
|
||||
player.catalogRef = saveState.catalogRef ?? { albumId: null, trackId: null };
|
||||
const live = transport && transport.status !== "idle";
|
||||
player.status = live ? transport.status : "paused";
|
||||
player.position = live ? (transport.position ?? 0) : (saveState.position ?? 0);
|
||||
if (live && transport.duration != null) player.duration = transport.duration;
|
||||
// Nothing is loaded in the audio thread, so the play button has to
|
||||
// re-source rather than resume — see `resumePlayback`.
|
||||
player.needsResource = !live;
|
||||
// The bar's artwork and the app accent follow the track exactly as
|
||||
// they would had it just been started.
|
||||
if (item.stream) setAccentFromArtUrl(item.artUrl ?? null);
|
||||
else setAccentFromArt(item.art ?? null);
|
||||
}
|
||||
|
||||
// Where you were. The trail and the history arrows are restored first so
|
||||
// that re-opening the page finds its own crumb already on top —
|
||||
// `recordVisit` then updates it in place instead of pushing a duplicate.
|
||||
const stack = (saved.nav?.stack ?? []).filter((e) => e && e.key);
|
||||
if (stack.length) {
|
||||
nav.stack = stack;
|
||||
nav.future = [];
|
||||
nav.origin = saved.nav?.origin ?? null;
|
||||
ui.albumId = saved.albumId ?? null;
|
||||
ui.playlistId = saved.playlistId ?? null;
|
||||
const here = stack[stack.length - 1];
|
||||
try {
|
||||
await openEntry(here);
|
||||
} catch {
|
||||
ui.view = saved.view ?? "home";
|
||||
}
|
||||
// Back where the reader was on the page, once it has height to
|
||||
// scroll — the same staged restore the back arrow uses.
|
||||
const target = here.scroll ?? 0;
|
||||
if (target > 0) {
|
||||
requestAnimationFrame(() =>
|
||||
restoreScroll(document.querySelector(".content"), target),
|
||||
);
|
||||
}
|
||||
} else if (saved.view) {
|
||||
ui.view = saved.view;
|
||||
}
|
||||
} finally {
|
||||
sessionRestoring = false;
|
||||
sessionSignature = "";
|
||||
setInterval(sessionTick, SESSION_TICK_MS);
|
||||
// A reload is the case this whole thing exists for, and it is the one
|
||||
// moment the position is exactly known.
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("beforeunload", writeSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
globalThis.__soulstream = {
|
||||
app, ui, player, catalog, resolver,
|
||||
app, ui, player, catalog, resolver, nav,
|
||||
libraryStore, recentsStore,
|
||||
streamCatalogAlbum, streamSong, openCatalogArtist,
|
||||
catalogGo, refreshLibrary, refreshRecents,
|
||||
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
|
||||
};
|
||||
}
|
||||
+2148
-344
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user