forked from Keelrleek/SoulStream
album page: the record gets a page of its own, under the same glass bar
The album page was a back link, a 150px cover and a list of titles — a
placeholder beside the artist page it is reached from. It is now built from the
same parts: the frosted glass header, a hero carrying the record's own art and
palette, the play-count bars, and the edition cards.
**The glass bar is the artist page's, not a copy of it.** Same 46px, same
rgba(20,20,24,0.28) over blur(12px) saturate(1.7), same sticky top:-24px with
the -24/-28/-22 bleed that spans the pane and leaves zero flow height. It is
meant to be the header every page navigates by, so it had to be the same
object rather than something that resembles it.
It gains one thing here: the record's cover, name and a play button fade into
the bar once the title itself has gone under it, so the bar always says where
you are. That watches the TITLE, not the identity block — watching the block
meant waiting for the artwork to clear too, about 240px of scroll on a page
that only has 199px of it, so it would never have fired on a ten-track album.
**The hero is one proportional block.** `--cover-scale` sizes the artwork
(282px) and everything beside it is `em` off that one root, so type, gaps, the
artist portrait, the pills and the stat divider all move by the same factor.
Two earlier attempts are worth naming because they both looked wrong:
- damping the type (0.6 + 0.4·s) made the words visibly lag the artwork;
- `space-between` filled the height but opened the gaps faster than the type
grew, so the block came apart as it got bigger.
The gap is 1.8em, which is what the five rows need to fill the cover exactly,
and being an em it keeps that fit at any size. Measured live: the label's top
and the buttons' bottom both sit 0px from the artwork's edges. The hero's own
height derives from the artwork (`--cover + 68px`), so the 24px under the glass
bar holds at every scale instead of being a second number that drifts.
**Editions come from src/lib/editions.js — the artist page's own grouping.**
`releaseFamilies`, `editionBaseTitle`, `isEdition` and `editionLabel` moved out
of CatalogArtist.svelte (143 lines deleted there, not duplicated) so both pages
fold a record's pressings the same way. Two implementations would have drifted
invisibly: each page would show a plausible edition list and disagree about
which pressings are one album.
The album payload has no siblings, so the family is rebuilt from the artist's
discography. It reads the FULL page rather than the overview — the overview is
the artist page's first-paint subset and both dropped the anniversary edition
and carried no track counts, so the rows read "1994" where the discography
reads "1994 · 50 tracks". Cached per artist, and already warm whenever this
page was reached from theirs. Live, the Blue Album now lists Original 10 /
Deluxe 24 / Anniversary Super Deluxe 50, matching the discography exactly.
One deliberate narrowing: the artist page buckets releases by category before
grouping, and that categorisation leans on page state this view does not load
(harvested description hints, MusicBrainz type hints). So a sibling is accepted
only when the backend LINKED it (`edition_group`, a verified ordered core-track
match) or it declares the same record type. That can only ever show fewer
editions than the discography, never promote a separate live album or
compilation into this record's edition list.
**Switching edition is a swap, not a navigation.** `openCatalogAlbum` grew a
`keepCurrent` option that holds the page already on screen instead of clearing
to the loading state; the incoming cover is decoded before the swap so the
artwork crossfades rather than blinking out; and the parts are re-dealt on the
same clock as the expander — 400ms quintOut, 7px lift, banner → cover → kind →
title → meta → stats → actions → tracklist → description → editions → credits.
Polled at 16ms through a swap: 0 blank frames. The blurred banner fades without
lifting, because sliding a 42px blur reads as a wobble, and every part animates
to its OWN resting opacity — the banner rests at 0.62 and animating it to 1
would have run the colour wash half again too bright for the whole swap. A
failed load under `keepCurrent` clears anyway, so a dead edition surfaces the
error instead of looking like the click did nothing.
**The byline shows the artist, not the album cover again.** The album payload
carries the artist's NAME and nothing else, so `resolveArtistSeedByName` reads
and fills the same cache `openArtistByName` uses: the click that navigates
there has usually already paid for it, in-flight lookups are deduped so the
page and its own back button cannot fire the same search twice, and a transport
failure stays retryable rather than being cached as "no artist". A neutral dot
holds the space until it lands, so the line never reflows.
Shuffle and Preview join Play and Save. Shuffle is an action on this record,
not a toggle — it always turns shuffle on rather than flipping a global and
silently starting the album in order — and stays lit while it is on so that
state is visible from here. Preview opens the same modal the artist page does;
`buildAlbumManifest` already existed in the preview layer with no caller, which
is what this surface was for. It reports partial availability the same way, and
the manifest is a snapshot so an edition swap cannot rewrite an open session.
Also: plays bars, the whole-track/listener totals, the editorial description
and a released/label line, all off data `catalog_album` was already returning
and the page was throwing away. The plays column hides below three tracks — a
bar measured against itself is not a comparison, and on a single it read as a
rating. Track counts are pluralised, so a single says "1 track".
No add-to-playlist button. `add_to_playlist` requires a file path per track, so
a catalog album can only be added once it is in the library; that is a playlist
decision, not something to slip in behind an icon.
150 Rust tests and 85 JS tests pass.
This commit is contained in:
1 parent
fa3cbc64ca
commit
ea9a040d09
4 files changed
+1512
-298
No files matched your search
+66
-3
@@ -2002,8 +2002,15 @@ export async function openCatalogAlbum(albumId, opts = {}) {
|
||||
albumResolveRequest += 1;
|
||||
const request = ++albumPageRequest;
|
||||
ui.view = "catalogAlbum";
|
||||
catalog.albumPage = null;
|
||||
catalog.albumLoading = true;
|
||||
// `keepCurrent` holds the page that is already on screen until the new one
|
||||
// lands, instead of clearing to the loading state. Switching between two
|
||||
// editions of the same record is a swap, not a navigation — blanking the
|
||||
// whole page for it reads as a flash. The album view crossfades on top of
|
||||
// this; every other caller still gets the plain loading state.
|
||||
if (!opts.keepCurrent) {
|
||||
catalog.albumPage = null;
|
||||
catalog.albumLoading = true;
|
||||
}
|
||||
catalog.pendingTrackId = opts.trackId ?? null;
|
||||
catalog.error = "";
|
||||
const active = () => request === albumPageRequest && ui.view === "catalogAlbum";
|
||||
@@ -2022,7 +2029,13 @@ export async function openCatalogAlbum(albumId, opts = {}) {
|
||||
if (t) catalog.pendingTrackId = t.id;
|
||||
}
|
||||
} catch (e) {
|
||||
if (active()) catalog.error = String(e);
|
||||
if (active()) {
|
||||
catalog.error = String(e);
|
||||
// A held page would swallow the failure — the old edition stays up
|
||||
// and the click looks like it did nothing. Clear so the error is
|
||||
// the thing on screen, exactly as a normal navigation would.
|
||||
if (opts.keepCurrent) catalog.albumPage = null;
|
||||
}
|
||||
} finally {
|
||||
if (request === albumPageRequest) catalog.albumLoading = false;
|
||||
}
|
||||
@@ -2032,6 +2045,56 @@ const catNorm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
|
||||
/** Resolve an artist by name (cached) and open their catalog page — used by
|
||||
* the now-playing artist link, which only knows the name. */
|
||||
/** Resolve an artist NAME to their catalog seed — `{ id, name, picture, fans,
|
||||
* album_count }` — or null when no artist by that name exists.
|
||||
*
|
||||
* Shares the very caches `openArtistByName` fills, so a page that shows an
|
||||
* artist's portrait beside a name has already paid for the click that
|
||||
* navigates to them, and vice versa. In-flight lookups are deduped by name:
|
||||
* an album page mounting can otherwise fire the same search its own back
|
||||
* button is about to.
|
||||
*/
|
||||
const artistSeedRequests = new Map(); // normalized name → Promise<seed|null>
|
||||
export function resolveArtistSeedByName(name) {
|
||||
const norm = (name || "").trim().toLowerCase();
|
||||
if (!norm) return Promise.resolve(null);
|
||||
const cached = artistSeedCache.has(norm) ? touchCache(artistSeedCache, norm) : null;
|
||||
if (cached) return Promise.resolve(cached);
|
||||
// A remembered miss stays a miss — the negative is cached as a null id.
|
||||
if (artistIdCache.has(norm) && touchCache(artistIdCache, norm) == null) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const inflight = artistSeedRequests.get(norm);
|
||||
if (inflight) return inflight;
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const res = await api.catalogSearch(name);
|
||||
const artists = res.artists ?? [];
|
||||
// Same rule as openArtistByName: the most-followed exact match, so
|
||||
// a shared name resolves to the canonical artist rather than an
|
||||
// obscure namesake the search happened to list first.
|
||||
const exact = artists.filter((a) => a.name.trim().toLowerCase() === norm);
|
||||
const pool = exact.length ? exact : artists;
|
||||
const best = pool.reduce(
|
||||
(top, artist) => ((artist.fans ?? 0) > (top?.fans ?? -1) ? artist : top),
|
||||
null,
|
||||
);
|
||||
if (!best) {
|
||||
setBoundedMap(artistIdCache, norm, null, ARTIST_IDENTITY_CACHE_MAX);
|
||||
return null;
|
||||
}
|
||||
return rememberArtistSeed(best);
|
||||
} catch {
|
||||
// Transport failures stay retryable — never negative-cached.
|
||||
return null;
|
||||
} finally {
|
||||
artistSeedRequests.delete(norm);
|
||||
}
|
||||
})();
|
||||
artistSeedRequests.set(norm, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export async function openArtistByName(name) {
|
||||
const norm = (name || "").trim().toLowerCase();
|
||||
if (!norm) return;
|
||||
|
||||
Reference in new issue
Block a user