diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js
index 9084393..c9e1a90 100644
--- a/src/lib/state.svelte.js
+++ b/src/lib/state.svelte.js
@@ -158,6 +158,20 @@ const catalogArtCache = new Map();
const CATALOG_ART_CACHE_MAX = 160;
const CATALOG_ART_CACHE_MAX_CHARS = 32 * MIB;
+/** The proxied data URL for `url` if it is ALREADY resolved, synchronously.
+ *
+ * Exists so a second consumer can paint art the first one has already fetched
+ * without a round trip through a promise — one microtask is still a frame with
+ * the raw CDN url in `src`, which is a network request for a file we already
+ * hold. Returns null while a fetch is in flight (the cache holds the promise
+ * then) and null for anything unproxied. */
+export function peekCatalogArt(url) {
+ if (!url) return null;
+ if (url.startsWith("data:")) return url;
+ const hit = catalogArtCache.get(url);
+ return typeof hit === "string" ? hit : null;
+}
+
export async function getCatalogArt(url) {
if (!url) return null;
// Already-inline art (e.g. a cover-derived logo) has nothing to proxy.
@@ -1021,7 +1035,11 @@ export function invalidateCatalogArtist(artistId) {
// an instant first paint, and a miss is one ordinary load.
// v3: same argument for release ownership. A v2 replay carries a fused
// record's co-tenants and its wrong portrait, both stamped as merged.
-const PAGE_REPLAY_RECIPE = "replay-v3";
+// v4: same argument for original-album attribution. A v3 replay carries
+// Deezer's licensed attribution on every popular row — "Bohemian Rhapsody" on
+// the 2018 movie soundtrack, with that poster as its cover — and painting a
+// known-wrong album name instantly is worse than one ordinary load.
+const PAGE_REPLAY_RECIPE = "replay-v4";
const PAGE_REPLAY_LIMIT = 16;
// Matches the backend's provider disk-cache TTL on purpose: a replay must
// never outlive the MusicBrainz warmth it was saved with, or the identity
@@ -3041,25 +3059,43 @@ function hqCover(url) {
);
}
+/** The tier gridCover asks for, chosen by device pixel ratio.
+ *
+ * The biggest consumer is the album expander's rail art at a FIXED 209px — the
+ * rail column is 250px regardless of window or monitor size, so a 4K display
+ * does not make the art box bigger; only DPR does. 209px needs 418 device
+ * pixels at DPR 2, which 500 covers, and 4K on Windows normally runs 150% or
+ * 200% scaling. Measured side by side at DPR 2 the 500 and 1000 sources are
+ * indistinguishable at actual size; at DPR 3 the 500 is visibly soft.
+ * So: 500 below that line, 1000 above it, rather than paying 3.6x the bytes
+ * (148KB -> 501KB per cover) on every display to fix the top one.
+ * Coverart Archive has no 1000 rendition — 1200 is its next step up. */
+function coverTier() {
+ const dpr = typeof devicePixelRatio === "number" ? devicePixelRatio : 1;
+ return dpr > 2 ? { square: 1000, caa: 1200 } : { square: 500, caa: 500 };
+}
+
/** Responsive card-sized variant of a canonical cover URL. The resolver keeps
- * its original/high-resolution winner for the player and album expander; the
- * 150px grid should not decode 9-million-pixel Apple masters into GPU memory. */
+ * its original/high-resolution winner for the player; neither the 150px grid
+ * nor the expander's 209px rail should decode 9-million-pixel Apple masters
+ * into GPU memory to do it. */
export function gridCover(url) {
if (!url || url.startsWith("data:")) return url;
+ const { square, caa } = coverTier();
try {
const parsed = new URL(url);
if (parsed.hostname.endsWith("dzcdn.net")) {
return url.replace(
/\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i,
- "/500x500$1.$2",
+ `/${square}x${square}$1.$2`,
);
}
if (parsed.hostname.endsWith("mzstatic.com")) {
- parsed.pathname = parsed.pathname.replace(/\/[^/]+$/, "/500x500bb.jpg");
+ parsed.pathname = parsed.pathname.replace(/\/[^/]+$/, `/${square}x${square}bb.jpg`);
return parsed.toString();
}
if (parsed.hostname.endsWith("coverartarchive.org")) {
- parsed.pathname = parsed.pathname.replace(/\/front(?:-\d+)?$/, "/front-500");
+ parsed.pathname = parsed.pathname.replace(/\/front(?:-\d+)?$/, `/front-${caa}`);
return parsed.toString();
}
} catch {
diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte
index d61b15e..1a7060d 100644
--- a/src/views/CatalogArtist.svelte
+++ b/src/views/CatalogArtist.svelte
@@ -13,6 +13,7 @@
resolver,
player,
getCatalogArt,
+ peekCatalogArt,
getCatalogHeroPreview,
getCachedCatalogHeroArt,
getCatalogHeroArt,
@@ -39,6 +40,25 @@
const nowPlaying = (kind, id) =>
player.status !== "idle" && player.catalogRef[kind] === id;
+ // A popular row re-attributed onto the song's original album carries that
+ // album's Deezer track id, not the one Deezer licensed. Search, the
+ // suggest dropdown and the album page all start a track by the LICENSED
+ // id, so matching on `track.id` alone silently drops the highlight off the
+ // row it belongs to. `origin_track_id` is the id we moved away from.
+ const nowPlayingTrack = (track) =>
+ nowPlaying("trackId", track.id) ||
+ (track.origin_track_id != null &&
+ nowPlaying("trackId", track.origin_track_id));
+
+ // Identity that survives the re-attribution. The overview paints Deezer's
+ // licensed row and the full page replaces it 2-4s later with the same song
+ // on its real album — a different `track.id`. Keying the list on that id
+ // would destroy and recreate every corrected row, throwing away the save
+ // state and making a cover crossfade impossible (a fresh has nothing
+ // to fade FROM). Both stages agree on this value, and a track that was
+ // never moved has no `origin_track_id`, so it is just the id as before.
+ const trackKey = (track) => track.origin_track_id ?? track.id;
+
let saved = $state({});
// A failed save flashes ✗ on the button (the resolver banner up top is
@@ -78,10 +98,15 @@
}
async function saveTrackAction(artist, t) {
- if (saved[`t${t.id}`]) return;
- saved[`t${t.id}`] = "…";
- if (await saveSong(artist, t.title)) saved[`t${t.id}`] = "ok";
- else flagSaveError(`t${t.id}`);
+ // Keyed on the identity that survives re-attribution, not on the raw
+ // Deezer id: a corrected popular row keeps the ✓ it earned before the
+ // full page landed. An expander track has no `origin_track_id`, so
+ // this is its own id, unchanged.
+ const key = `t${trackKey(t)}`;
+ if (saved[key]) return;
+ saved[key] = "…";
+ if (await saveSong(artist, t.title)) saved[key] = "ok";
+ else flagSaveError(key);
}
const page = $derived(catalog.artistPage);
@@ -896,11 +921,46 @@
const PAD_TOP_PARKED = 28;
const clampPx = (lo, v, hi) => Math.round(Math.max(lo, Math.min(hi, v)));
- // Narrow panes SHED chips rather than letting the parked row overflow: the
- // row has to stay one line to fit a 124px banner, so something has to
- // give and it should be the least important stat. Thresholds are the
- // mock's window presets measured at the content width.
- const chipTier = $derived(heroWidth < 460 ? "xs" : heroWidth < 640 ? "s" : "l");
+ // The row WRAPS, so shedding is the last resort rather than the first: it
+ // survives only below the width where even a wrapped row cannot fit the
+ // parked banner. The old middle tier ("s", under 640) is what deleted nine
+ // of thirteen items on a merely 1280px window — heroWidth is the CONTENT
+ // width, some 660px less than the window once the sidebar and Now Playing
+ // are open, so that threshold fired far wider than it reads. Still measured
+ // at the content width.
+ const chipTier = $derived(heroWidth < 380 ? "xs" : "l");
+
+ // The divider only earns its place while the social block is trailing
+ // something on its own line; alone at the head of a wrapped line it
+ // separates nothing and reads as a stray tick.
+ // This toggle is safe ONLY because the rule is out of flow (see
+ // .chip-divider in the CSS). Were it an in-flow item, hiding it would give
+ // back the very width that caused the wrap, the group would fit again, the
+ // divider would return, and the row would flip between one line and two
+ // forever. Nothing here may change what wraps.
+ // Bound through state and `class:wrapped` rather than written with
+ // classList: Svelte scopes this component's CSS and prunes any selector it
+ // cannot see used in the markup, so a class added imperatively compiles the
+ // rule away entirely ("Unused CSS selector") and the divider never hides.
+ let socialWrapped = $state(false);
+
+ function syncSocialDivider(page) {
+ const set = page.querySelector(".social-set");
+ if (!set) return;
+ // Against the PREVIOUS SIBLING, not the row's first chip: what the
+ // divider means is "these are separate from the thing on my left", so
+ // it should survive a wrap that carries the heart down with it and
+ // only go when the group genuinely starts a line.
+ const prev = set.previousElementSibling;
+ // Tops rather than a line count: .social-set is 29px and a chip 27px,
+ // so same-line items differ by a pixel under align-items: center.
+ const wrapped =
+ !!prev &&
+ set.getBoundingClientRect().top - prev.getBoundingClientRect().top > 4;
+ // Guarded: measure() runs from a ResizeObserver, and an unconditional
+ // write would dirty the graph on every reflow for no change.
+ if (wrapped !== socialWrapped) socialWrapped = wrapped;
+ }
// Re-measure hook, published by the action below so the reactive graph can
// poke it when the wordmark changes identity or size.
@@ -910,6 +970,15 @@
// keeps them; it flips at most twice over the whole travel.
let heroScrolled = $state(false);
+ // Past the fade boundary the social block leaves LAYOUT, not just paint.
+ // Fading it alone would keep its LINE, and the row would stay two lines
+ // tall with nothing in the lower one — which is exactly what left the Play
+ // button stranded below the only chips still visible. Reactive for the same
+ // reason heroScrolled is: Svelte prunes CSS whose selectors it cannot see
+ // used, and [data-park] is no exception. Flips at most twice over the
+ // travel, on the same 0.42 boundary --extra-hit already uses.
+ let extrasParked = $state(false);
+
function heroScroll(pin) {
const page = pin.parentElement;
const band = pin.querySelector(".hero-bg");
@@ -1022,11 +1091,48 @@
);
inkPark = markHeight > markPx ? markPx : markHeight || markPx;
}
- // The Play/chips row's resting height, from the hole it leaves in
- // .popular, so the pinned row and its hole can never drift apart.
+ // The Play/chips row's resting height, MEASURED off the row itself;
+ // the hole it leaves in .popular then follows it (min-height:
+ // var(--row-h)), so the two still cannot drift apart. Read the
+ // other way round — spacer to row, as it was — --row-h is a
+ // constant wearing a measurement's clothes: .actions-gap is empty,
+ // so its height is only ever what the stylesheet says. That was
+ // survivable while the row was locked to one line and fatal the
+ // moment it wraps, because the box is anchored by its BOTTOM: with
+ // --row-h too small the extra line grows the only way left to it,
+ // up into the wordmark. With the real height here the box's top
+ // pins to --edge at every line count and the growth goes down into
+ // the reserve instead.
+ // The row's own min-height reads --row-min, never --row-h, or this
+ // would ratchet — a tall measurement would hold the floor up and
+ // the row could never come back down.
+ // Derived from the CHIP BLOCK, not read off the row's own box: the
+ // box takes its height FROM --row-h (min-height), so measuring the
+ // box and feeding it back would ratchet — the row could grow and
+ // never shrink again. The chips are never stretched (the row is
+ // align-items: flex-start), so their height is pure content.
+ // Measured with the parked collapse forced OFF, because --row-h
+ // also sizes the hole in .popular: let the hole follow the
+ // collapsed row and the whole track list would slide up under the
+ // reader halfway through a scroll. The toggle is synchronous, so
+ // no paint can land between the two writes.
+ const chipsEl = page.querySelector(".stat-chips");
const gap = page.querySelector(".actions-gap");
- const rowH = gap?.offsetHeight ?? 76;
+ const parkAttr = page.dataset.park ?? "0";
+ page.dataset.park = "0";
+ const chipsH = chipsEl?.getBoundingClientRect().height ?? 0;
+ page.dataset.park = parkAttr;
+ // The one-line floor, per breakpoint, and the top padding derived
+ // from it (see --row-pad-top). The row is padTop + the chips'
+ // offset against the 50px lead row + the chips + 8px.
+ const floor =
+ parseFloat(getComputedStyle(page).getPropertyValue("--row-min")) || 76;
+ const padTop = (floor - 66) / 2 + 8;
+ const rowH = chipsEl
+ ? Math.max(floor, Math.ceil(padTop + 11.5 + chipsH + 8))
+ : gap?.offsetHeight ?? floor;
page.style.setProperty("--row-h", `${rowH}px`);
+ syncSocialDivider(page);
// The park point: the content edge stops exactly where the shrunk
// wordmark's top lands PAD_TOP_PARKED below the glass bar with the
// identity still in its resting slots.
@@ -1140,18 +1246,30 @@
// the glue means), so measuring it during any scroll state is
// safe. Rows dissolve over [bottom + 2, bottom + 20], gone two
// pixels under the pills, parked heading ~4px clear.
+ // The anchor is the LOWEST ink in the row, which is the Play button
+ // only while the row is one line: once the chips wrap they hang
+ // below it, and a cut still anchored on the button would dissolve
+ // the list rows above the chips' real bottom edge. .stat-chips
+ // rides a translateX (chips-slide-ride), never a translateY, so its
+ // measured bottom is as scroll-invariant as the button's.
const body = page.querySelector(".scrollbody");
- const playEl = page.querySelector(".hero-play");
+ const rowInk = [
+ page.querySelector(".hero-play"),
+ page.querySelector(".stat-chips"),
+ ].filter(Boolean);
let rowBottom = rowH + 14;
- if (playEl && band) {
+ if (rowInk.length && band) {
const pNow = Math.min(
1,
Math.max(0, scroller.scrollTop / Math.max(1, span - strip)),
);
const edgeNow = span - pNow * (span - strip);
+ const bandTop = band.getBoundingClientRect().top;
rowBottom =
- playEl.getBoundingClientRect().bottom -
- band.getBoundingClientRect().top -
+ Math.max(
+ ...rowInk.map((el) => el.getBoundingClientRect().bottom),
+ ) -
+ bandTop -
edgeNow;
}
// The cut line in scrollport coordinates — scroll-invariant by
@@ -1244,6 +1362,9 @@
// Faded-out extras must stop taking clicks; opacity alone
// doesn't.
page.style.setProperty("--extra-hit", p > 0.42 ? "none" : "auto");
+ // Same boundary, and by it the block is already at opacity 0, so
+ // taking it out of layout here pops nothing.
+ extrasParked = p > 0.42;
// The mask, the veil, the monochrome treatment and the wordmark's
// scale are all gated on [data-scroll], so at the top of the page
// they are absent rather than merely neutral. Every one of them is
@@ -1357,6 +1478,9 @@
if (hit !== lastHit) {
lastHit = hit;
page.style.setProperty("--extra-hit", hit);
+ // The social block's layout collapse rides the same
+ // boundary crossing — see extrasParked.
+ extrasParked = past;
}
},
// Both edges: the 1 guarantees a notification for the
@@ -1369,7 +1493,15 @@
}
reflow();
- remeasureHero = reflow;
+ // Not `reflow` bare: .stat-chips may be a DIFFERENT element than the
+ // one currently observed (the loading branch and the real chip set are
+ // two nodes in two arms of an {#if}), so re-point before measuring.
+ // Safe to reference observeChips here — the body runs long after the
+ // const below is initialised, unlike the reflow() call above it.
+ remeasureHero = () => {
+ observeChips();
+ reflow();
+ };
// Supported path: NO scroll listener. Even an empty per-frame
// handler would keep the main thread in the scroll loop, and
// apply()'s scrollTop read forces style+layout while the
@@ -1393,6 +1525,28 @@
settleTimer = setTimeout(reflow, 200);
};
const observer = new ResizeObserver(reflowAndSettle);
+ // The chip block is what --row-h is derived from, and it changes height
+ // on its own — the set fills out as staged data lands, the pane narrows
+ // and it gains a line, the artist changes and it loses one — with
+ // neither band, zone nor pane resizing at that instant.
+ // It has to be the chips and not .artist-actions: the row's box is
+ // pinned to --row-h by min-height, so a chip set that gets SHORTER
+ // leaves the box exactly as tall as it was and the observer never
+ // fires. Growth alone did fire, which is precisely what made observing
+ // the row look like it worked.
+ // No loop: the callback writes --row-h, which moves the row's `bottom`
+ // (a position, not a size), sets its min-height, and grows
+ // .actions-gap — none of which changes the chips' own height, since
+ // they are never stretched. The gap sits below .hero-zone in flow, so
+ // it cannot feed back into --hero-span, --strip or --travel either.
+ let observedChips = null;
+ const observeChips = () => {
+ const el = page.querySelector(".stat-chips");
+ if (!el || el === observedChips) return;
+ if (observedChips) observer.unobserve(observedChips);
+ observer.observe(el);
+ observedChips = el;
+ };
observer.observe(band);
const zone = page.querySelector(".hero-zone");
if (zone) observer.observe(zone);
@@ -1400,6 +1554,26 @@
// maximize) moves the vh-capped banner/zone limits without
// necessarily resizing band or zone at their measured instant.
observer.observe(scroller);
+ // And the CHIP BLOCK, which is what --row-h is derived from. It
+ // changes height on its own — the set fills out as staged data lands,
+ // and it gains and loses lines as the pane narrows — without band,
+ // zone or pane resizing at that instant.
+ // It must be the chips and not .artist-actions: the row's box is
+ // pinned to --row-h by min-height, so a chip set that gets SHORTER
+ // leaves the box exactly as tall as it was, the observer never fires,
+ // and --row-h stays stuck at the taller value for good. (Growth alone
+ // did fire, which is what made this look like it worked.) The chips
+ // are never stretched, so their height tracks their content both ways.
+ // No loop: the callback writes --row-h, which moves the row's `bottom`
+ // (a position, not a size), sets its min-height, and grows
+ // .actions-gap — and none of those change the chips' own height. The
+ // gap sits below .hero-zone in flow, so it cannot feed back into
+ // --hero-span, --strip or --travel either.
+ // ...but .stat-chips is not one element for the page's lifetime: the
+ // loading branch and the real chip set are two different nodes in two
+ // arms of an {#if}, so observing whichever exists at mount silently
+ // leaves the OTHER one unwatched for good. Re-point on every measure.
+ observeChips();
return {
destroy() {
remeasureHero = () => {};
@@ -1431,6 +1605,10 @@
void logo.mode;
void logoRenderHeight;
void popularTracks.length;
+ // The chip row swaps between the loading status and the real set on
+ // this flag, and the two are different elements — the remeasure is
+ // also what re-points the ResizeObserver at whichever one now exists.
+ void page?.art_preview;
remeasureHero();
});
@@ -1647,16 +1825,42 @@
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
+ // Drop the word introducing a volume number so "Volume 2", "Vol. 2" and a
+ // bare "2" all reduce alike. Deezer and MusicBrainz number a series
+ // differently — measured on Queen, Deezer sells "Deep Cuts 2 (1977-1982)"
+ // while MB files "Deep Cuts, Volume 2 (1977–1982)". Only when a digit
+ // actually follows, so a record called "Volume" keeps its name.
+ const foldVolumes = (s) => s.replace(/\b(?:vol\.?|volume)\s*(?=\d)/gi, "");
+
// MusicBrainz release-group type hints from the deferred identity pass,
// keyed by normalized title. MB's secondary types are the authoritative
// signal for the releases Deezer/Apple mislabel a plain "album" — a
// compilation ("KID A MNESIA"), soundtrack or remix album. Declared above
// `categoryFor`/`tabs` so it is initialized before they first read it (the
// same before-initialization trap the FLIP effects avoid).
+ // Both sides of this map go through `hintKey`, and that is the whole point:
+ // it used to key on the RAW title at both ends, so a hint only landed when
+ // Deezer and MusicBrainz happened to name a release identically. Measured
+ // on Queen, 52 of 90 releases found no hint — "Deep Cuts 2 (1977-1982)"
+ // never met "Deep Cuts, Volume 2 (1977–1982)", so the series kept Deezer's
+ // wrong date and sat in Albums while Volume 1 sat in Compilations.
+ // Volume folding ONLY — deliberately not `editionBaseTitle`. Stripping the
+ // edition/variant tail as well does match more rows, but it makes distinct
+ // releases collide: "We Will Rock You (Raw Sessions Version)" reduces to
+ // "We Will Rock You" and inherits a live release's verdict, which moved a
+ // single into Live Albums. A hint that is merely absent falls through to the
+ // heuristics; a hint taken from the WRONG release is silently authoritative.
+ const hintKey = (title) => norm(foldVolumes(title));
+
const typeHintMap = $derived.by(() => {
const map = new Map();
for (const hint of page?.typeHints ?? []) {
- if (hint?.title) map.set(norm(hint.title), hint);
+ if (!hint?.title) continue;
+ const key = hintKey(hint.title);
+ // An artist can carry several groups whose titles reduce alike (a
+ // work and its live record). First wins, matching how MB returns
+ // them; a later duplicate must not overwrite a good verdict.
+ if (key && !map.has(key)) map.set(key, hint);
}
return map;
});
@@ -1977,7 +2181,7 @@
// "soundtrack" in its title). Live/Soundtrack/Remix are trusted outright;
// MB's "Compilation" is noisy (it also tags original-material sets), so
// it must be corroborated locally, else fall through to the heuristics.
- const hinted = typeHintMap.get(norm(album.title));
+ const hinted = typeHintMap.get(hintKey(album.title));
if (hinted) {
const category = mbCategory(hinted);
if (category === "compilation") {
@@ -2013,7 +2217,21 @@
const families = new Map();
for (const album of items) {
const baseTitle = editionBaseTitle(album.title);
- const group = album.edition_group ?? groupByBase.get(norm(baseTitle));
+ // Only an EDITION may adopt a group by base title. The backend
+ // stamps `edition_group` on the base row as well as its editions,
+ // so a group always contains its own base — which means a
+ // plain-titled row that is unlinked belongs to no group, and
+ // adopting one can only ever attach it to somebody else's work.
+ // Measured: "Weezer (Deluxe Edition)" is linked to the 2008 Red
+ // Album and reduces to the base title "Weezer", so the bare-titled
+ // 2026 Gold Album adopted the Red Album's family and was filed as
+ // one of its editions. The case this heuristic exists for —
+ // "Nevermind (Remastered)" joining "Nevermind" — is an edition
+ // adopting, and still works.
+ const adopted = isEdition(album)
+ ? groupByBase.get(norm(baseTitle))
+ : undefined;
+ const group = album.edition_group ?? adopted;
const key = group != null
? `${category}:work:${group}`
: `${category}:${norm(baseTitle) || album.id}`;
@@ -2199,7 +2417,36 @@
let railArtTransitionKey = $state(0);
let railArtTransitionTimer;
let openTracks = $state([]);
+ /** Last.fm album totals for the open edition: {plays, listeners, summed}. */
+ let openTotals = $state(null);
let openLoading = $state(false);
+ // openLoading is set SYNCHRONOUSLY in showEdition, before its first await,
+ // so the loading DOM is committed on every open no matter how fast the data
+ // is — measured 7-31ms on a warm open, which is a flash and nothing more.
+ // This gate is what is actually rendered: a load has to last long enough to
+ // be worth reporting before anything appears.
+ const LOADING_GRACE = 250;
+ let loadingVisible = $state(false);
+ $effect(() => {
+ if (!openLoading) {
+ loadingVisible = false;
+ return;
+ }
+ const timer = setTimeout(() => (loadingVisible = true), LOADING_GRACE);
+ return () => clearTimeout(timer);
+ });
+ // Placeholder rows stand in the real .trow geometry at the album's own track
+ // count, so the real list lands exactly where they sat and the panel does
+ // not change height when they are replaced. Clamped: track_count is
+ // optional metadata and can disagree with the fetched list, so this is a
+ // plausible shape, never a promise about the count.
+ const SKELETON_WIDTHS = [62, 45, 78, 53, 68, 39, 71, 49];
+ const skeletonRows = $derived(
+ Array.from(
+ { length: Math.min(12, Math.max(4, openAlbum?.track_count ?? 8)) },
+ (_, i) => SKELETON_WIDTHS[i % SKELETON_WIDTHS.length],
+ ),
+ );
let openInfo = $state(null); // AlbumInfo (description/genre/label) for the open family
let descExpanded = $state(false);
let libraryTicks = $state(new Set()); // normalized titles of locally saved tracks
@@ -2243,6 +2490,34 @@
}
}
+ /* Coverart Archive covers are fetched through Rust, not by the
: they
+ * have no CORS headers and the host redirects on to archive.org, so a
+ * direct load is both slow and unreliable (PROXIED_ART_HOST above). The
+ * GRID does this already — smoothSrc swaps in the proxied data URL and
+ * getCatalogArt caches it — but the expander's rail art was a plain
+ * pointed straight at the CDN, so for a CAA-hosted album it refetched over
+ * the network something the card beside it already held decoded. That is
+ * the "still loading" on Made in Timeland and Alien Metal; every other
+ * album on the page is Apple- or Deezer-hosted and needs no proxy, which
+ * is why only those two showed it.
+ * Resolved covers land here so the markup can read them synchronously. */
+ let proxiedArt = $state({});
+
+ function railCover(url) {
+ if (!url) return url;
+ const sized = gridCover(coverUpgrades[url] ?? url);
+ if (!needsArtProxy(sized)) return sized;
+ // Synchronous cache hit first — going through the promise costs a
+ // microtask, and one frame with the raw URL in src is a real network
+ // request for a file already in memory.
+ const ready = proxiedArt[sized] ?? peekCatalogArt(sized);
+ if (ready) return ready;
+ void getCatalogArt(sized).then((data) => {
+ if (data) proxiedArt[sized] = data;
+ });
+ return sized;
+ }
+
function handleCoverError(node, originalCover, title = "") {
const fallback = coverLoadError(
originalCover,
@@ -2389,6 +2664,77 @@
};
}
+ // Crossfade a popular row's thumbnail when the album underneath it is
+ // corrected.
+ //
+ // The overview paints Deezer's licensed attribution ~150ms in and the full
+ // page replaces it 2-4s later with the song's real album. That is a
+ // content swap, not a layout shift — the row is a fixed grid at
+ // min-height 54px, so nothing moves — but a bare `src` reassignment is a
+ // hard cut the instant the new file decodes, and with five rows fetching
+ // uncached CDN images independently it lands as a ragged flicker. Worse,
+ // the browser keeps painting the OLD picture until the new one decodes, so
+ // there is a guaranteed window showing the new album's name beside the old
+ // album's cover. That reads as a glitch rather than as the page resolving.
+ //
+ // So the action owns `src` (the markup deliberately does not bind it):
+ // decode the replacement off-DOM first, then park the outgoing frame in a
+ // ghost layer and fade it out over the new one. First paint is untouched —
+ // there is nothing to fade from, and adding a fade there would slow down
+ // the stage this page is measured on.
+ function resolvedCover(node, url) {
+ let applied = url ?? "";
+ let ghost;
+ let fadeTimer;
+ const dropGhost = () => {
+ clearTimeout(fadeTimer);
+ ghost?.remove();
+ ghost = null;
+ };
+ node.src = applied;
+
+ const swap = async (next) => {
+ next = next ?? "";
+ if (next === applied) return;
+ const outgoing = applied;
+ applied = next;
+ // Nothing painted yet, or nothing to paint: this is first paint or
+ // a fall back to the placeholder, neither of which is a crossfade.
+ if (!next || !outgoing || !node.naturalWidth) {
+ node.src = next;
+ return;
+ }
+ try {
+ const probe = new Image();
+ probe.decoding = "async";
+ probe.src = next;
+ await probe.decode();
+ } catch {
+ // Undecodable. Hand it to the live
anyway so its own
+ // onerror routes through markArtBroken and the row falls back
+ // to the disc placeholder, exactly as it does today.
+ if (applied === next) node.src = next;
+ return;
+ }
+ // A newer correction overtook this one mid-decode.
+ if (applied !== next || !node.isConnected) return;
+ dropGhost();
+ ghost = node.cloneNode(false);
+ ghost.className = "track-cover cover-ghost";
+ ghost.src = outgoing;
+ ghost.removeAttribute("loading");
+ node.parentElement?.appendChild(ghost);
+ node.src = next;
+ requestAnimationFrame(() => ghost?.classList.add("faded"));
+ fadeTimer = setTimeout(dropGhost, 320);
+ };
+
+ return {
+ update: (next) => void swap(next),
+ destroy: dropGhost,
+ };
+ }
+
onDestroy(() => {
leaveCoverUpgradeScope(coverScopeArtistId);
coverViewportObserver?.disconnect();
@@ -2411,7 +2757,59 @@
// sticks at whatever the page happened to measure mid-settle.
const gridMeasures = new Set();
+ // Non-zero while a revealIntoView is driving the pane. See setReserve.
+ let revealActive = 0;
+
function measureCols(node, groupKey) {
+ // The reserve RISES instantly and FALLS on a glide — UNLESS a reveal is
+ // running, in which case a fall is instant too. A reveal eases toward a
+ // target clamped by the scroll range, so a range gliding down underneath
+ // it drags the page: measured on a switch to the last row, 299px out,
+ // a reversal, then 254px more. The reveal owns the scroll and will land
+ // it; the glide exists only for teardown, where nothing else will.
+ // A fall is always the same situation: the content shrank, and the
+ // reveal parks the pane exactly ON the clamp by construction — so the
+ // browser clamps scrollTop by the whole difference in one frame and the
+ // page hops. Spread over ~260ms it clamps a couple of px per frame and
+ // reads as the page settling instead. A rise can never clamp anything,
+ // so it stays immediate; and a rise may interrupt a glide, which is
+ // what keeps the reveal's per-frame re-stand authoritative.
+ let glide = 0;
+ let glideTo = null;
+ const setReserve = (to) => {
+ const from = parseFloat(node.style.minHeight) || 0;
+ if (glide) {
+ if (to <= glideTo) return; // already heading there or lower
+ cancelAnimationFrame(glide);
+ glide = 0;
+ glideTo = null;
+ }
+ // While a reveal owns the pane the reserve may RISE but never fall.
+ // A fall shrinks the range under a running ease, and the pane is
+ // parked on the clamp by construction, so the browser takes the
+ // whole difference in one frame: measured 256px on a switch to a
+ // higher row, front-loaded onto an ease that was about to travel
+ // there anyway. Held high, the ease does all of the motion and the
+ // fall happens afterwards, from retire(), where it can glide.
+ if (revealActive && to < from) return;
+ if (to >= from - 2 || revealActive) {
+ if (node.style.minHeight !== `${to}px`) node.style.minHeight = `${to}px`;
+ return;
+ }
+ glideTo = to;
+ const start = performance.now();
+ const DUR = 260;
+ glide = requestAnimationFrame(function step(now) {
+ if (!node.isConnected) { glide = 0; glideTo = null; return; }
+ const t = Math.min(1, (now - start) / DUR);
+ const ease = 1 - (1 - t) ** 3;
+ node.style.minHeight = `${Math.round(from + (to - from) * ease)}px`;
+ if (t < 1) { glide = requestAnimationFrame(step); return; }
+ glide = 0;
+ glideTo = null;
+ });
+ };
+
const measure = () => {
const n = getComputedStyle(node).gridTemplateColumns.split(" ").length;
if (gridCols[groupKey] !== n) gridCols = { ...gridCols, [groupKey]: n };
@@ -2439,9 +2837,16 @@
// cannot be predicted from the panel — only reserved against. The
// reveal stands the same figure up synchronously; this keeps it
// true across resizes and hands it back on close.
- const openRow = node.querySelector(".album-expander")?.previousElementSibling;
- if (openRow) reserve = Math.max(reserve, revealReserve(node, openRow, pane));
- if (node.style.minHeight !== `${reserve}px`) node.style.minHeight = `${reserve}px`;
+ // The INCOMING expander during the 460ms overlap, not whichever is
+ // first in DOM order: Svelte marks an element inert while it plays
+ // its outro, so the one without that attribute is the one that is
+ // staying. Reserving for the one that is leaving sizes the grid for
+ // a layout that is about to stop existing.
+ const xp = node.querySelector(".album-expander:not([inert])")
+ ?? node.querySelector(".album-expander");
+ const openRow = xp?.previousElementSibling;
+ if (openRow) reserve = Math.max(reserve, revealReserve(node, openRow, pane, xp));
+ setReserve(reserve);
};
measure();
gridMeasures.add(measure);
@@ -2451,6 +2856,7 @@
destroy: () => {
ro.disconnect();
gridMeasures.delete(measure);
+ cancelAnimationFrame(glide);
},
};
}
@@ -2514,6 +2920,7 @@
railArtAlbumId = null;
previousRailArtAlbumId = null;
openTracks = [];
+ openTotals = null;
openInfo = null;
}
@@ -2541,10 +2948,18 @@
});
void preloadEditionArt(album);
openTracks = [];
+ openTotals = null;
openLoading = true;
try {
const details = await api.catalogAlbum(album.id);
- if (openAlbumId === album.id) openTracks = details.tracks;
+ if (openAlbumId === album.id) {
+ openTracks = details.tracks;
+ openTotals = {
+ plays: details.album_plays ?? null,
+ listeners: details.album_listeners ?? null,
+ summed: Boolean(details.totals_summed),
+ };
+ }
} catch {
if (openAlbumId === album.id) openTracks = [];
} finally {
@@ -2556,9 +2971,12 @@
async function preloadEditionArt(album) {
if (railArtAlbumId === album.id) return;
- const source = album.cover
- ? (coverUpgrades[album.cover] ?? album.cover)
- : null;
+ // railCover, matching what the rail actually renders — including the
+ // proxy hop for Coverart Archive. Preloading anything else warms a URL
+ // the panel does not ask for: the decode this awaits would finish
+ // against one file while the
fetched another, so the gate meant
+ // to hide the swap would be gating on the wrong picture.
+ const source = album.cover ? railCover(album.cover) : null;
if (!source || artBroken[album.cover]) {
if (openAlbumId === album.id) setRailArt(album.id);
return;
@@ -2884,9 +3302,43 @@
* must have it standing before its first frame or it eases at a target the
* range cannot hold, and measureCols must maintain it across resizes and
* hand it back when the expander closes. */
- function revealReserve(grid, row, pane) {
+ /** Height standing above `row` that is on its way out. Every expander other
+ * than `keep` is mid-outro and collapses to nothing over 460ms, so a row
+ * measured while they are still up is measured against a layout that will
+ * not exist by the time anything lands there. On a switch to a row BELOW
+ * the one being closed this is the whole defect: the reserve came out 826px
+ * too big, the scroll range ballooned with it, the ease set off for a point
+ * that then walked back toward it, and the page moved three times. */
+ function vanishingAbove(grid, row, keep) {
+ if (!grid) return 0;
+ const rowTop = row.getBoundingClientRect().top;
+ // The expander is a GRID ITEM on a row of its own, so the row-gap that
+ // separates it from its neighbour goes with it. Counting only its
+ // height left the estimate exactly one gap — 16px — too generous, and
+ // that was the bump at the end: the reveal held the reserve at 1541,
+ // retired, the recompute came back with the true 1525, and the pane,
+ // parked on the clamp, was dragged 10px about 120ms after the page had
+ // stopped. Small, late, and unattached to anything the user did, which
+ // is exactly what makes a 10px move read as a bump.
+ const gap = parseFloat(getComputedStyle(grid).rowGap) || 0;
+ let px = 0;
+ for (const other of grid.querySelectorAll(".album-expander")) {
+ if (other === keep) continue;
+ const r = other.getBoundingClientRect();
+ // Its whole outer contribution: height, its own collapse margin,
+ // and the row-gap only if the grid still uses one. expandSlide
+ // retires the margin in step with the height, so reading both keeps
+ // this exact at every point of the outro rather than drifting.
+ const mb = parseFloat(getComputedStyle(other).marginBottom) || 0;
+ if (r.top < rowTop) px += r.height + mb + gap;
+ }
+ return px;
+ }
+
+ function revealReserve(grid, row, pane, keep) {
const rowInGrid = row.getBoundingClientRect().top
- - grid.getBoundingClientRect().top;
+ - grid.getBoundingClientRect().top
+ - vanishingAbove(grid, row, keep);
return Math.round(rowInGrid + pane.clientHeight - revealMargin(grid));
}
@@ -2907,8 +3359,26 @@
// .scrollbody's height does not answer to padding at all — 0px, 200px
// and 600px of padding-bottom all measured the same 1787px box.
const grid = row.parentElement;
+ // Non-zero while this reveal owns the scroll — see setReserve, which
+ // must not glide a falling reserve underneath a running ease.
+ revealActive += 1;
+ let live = true;
+ const retire = () => {
+ if (!live) return;
+ live = false;
+ revealActive -= 1;
+ // The reserve was held against falling for the duration; nothing
+ // else will notice it can come down now, so ask. Deferred so the
+ // final scrollTop of the ease is committed first, and only when the
+ // LAST reveal retires — a switch overlaps two of them.
+ if (!revealActive) {
+ queueMicrotask(() => {
+ if (!revealActive) gridMeasures.forEach((m) => m());
+ });
+ }
+ };
if (grid?.classList.contains("grid")) {
- const need = revealReserve(grid, row, pane);
+ const need = revealReserve(grid, row, pane, node);
if ((parseFloat(grid.style.minHeight) || 0) < need) {
grid.style.minHeight = `${need}px`;
}
@@ -2920,8 +3390,33 @@
const elapsed = now - start;
const t = Math.min(1, elapsed / DURATION);
const ease = 1 - (1 - t) ** 3;
+ // Re-stand the reserve EVERY frame, raising only. The figure taken
+ // above is computed while the OUTGOING expander is still holding
+ // its 460ms of layout open, so on a switch it is short by exactly
+ // that height and the ease parks against a clamp the range cannot
+ // hold. It then sat there until the outro ended and the range grew,
+ // at which point the closer below moved the page a second time —
+ // measured 72px starting 120ms after it had visibly stopped, which
+ // is the "settles then jumps at the end". Recomputed per frame the
+ // range simply keeps up with the row, and there is nothing left to
+ // correct afterwards. Raising only: a fall here would clamp the
+ // pane mid-ease, and falls belong to measureCols on teardown.
+ if (grid?.classList.contains("grid")) {
+ const now2 = revealReserve(grid, row, pane, node);
+ if ((parseFloat(grid.style.minHeight) || 0) < now2) {
+ grid.style.minHeight = `${now2}px`;
+ }
+ }
+ // The row's FINAL position, not its current one. On a switch the
+ // outgoing panel above it is still collapsing, so the row is drifting
+ // upward under us for 460ms; easing at where it is now means chasing
+ // it, and the ease ends before the drift does — which is the second
+ // movement, 254px, that arrives after the page has visibly stopped.
+ // Discounting what is about to vanish makes the target the place the
+ // row is going, so the collapse and the scroll resolve together.
const want = row.getBoundingClientRect().top
- - pane.getBoundingClientRect().top + pane.scrollTop - MARGIN;
+ - pane.getBoundingClientRect().top + pane.scrollTop - MARGIN
+ - vanishingAbove(grid, row, node);
// Clamped regardless of the reserve: easing at a target the range
// cannot hold is exactly what made the open judder.
const target = Math.max(
@@ -2949,42 +3444,276 @@
if (Math.abs(want - pane.scrollTop) < 0.5 || elapsed > SETTLE) {
if (Math.abs(gap) < 1) pane.scrollTop = target;
raf = null;
+ retire();
return;
}
if (Math.abs(gap) > 0.5) pane.scrollTop += gap * 0.2;
raf = requestAnimationFrame(step);
});
- const cancel = () => raf && cancelAnimationFrame(raf);
+ const cancel = () => {
+ if (raf) cancelAnimationFrame(raf);
+ raf = null;
+ retire();
+ };
// The user's own scrolling wins immediately.
pane.addEventListener("wheel", cancel, { once: true, passive: true });
return {
destroy: () => {
cancel();
pane.removeEventListener("wheel", cancel);
- // Hand the reveal's reserve back. measureCols cannot notice on
- // its own: its ResizeObserver only fires when the grid's BOX
- // changes, and the raised min-height is exactly what stops it
- // changing when the panel leaves — so the reserve would stick
- // for the rest of the visit and leave a screenful of empty page
- // below the grid. Clearing it shrinks the grid, which is the
- // resize that makes measureCols recompute the plain tab-bar
- // reserve (or the next row's, on a cross-row switch).
- if (grid?.classList.contains("grid")) grid.style.minHeight = "";
+ // Hand the reveal's reserve back — by RECOMPUTING it, never by
+ // clearing it. measureCols still cannot notice on its own (its
+ // ResizeObserver only fires when the grid's BOX changes, and
+ // the raised min-height is exactly what stops it changing when
+ // the panel leaves), so it has to be driven from here. But
+ // `minHeight = ""` drops the plain tab-bar reserve as well as
+ // the reveal's, for the one frame before measureCols reinstates
+ // it — and the pane is parked ON the clamp by construction, so
+ // the browser clamps scrollTop into the gap and the page yanks.
+ // Measured on close: range 1608 -> 1093 -> 1528, scrollTop
+ // 1602 -> 1093. A 509px jump, 120ms after the collapse had
+ // visibly finished. measure() with no expander present computes
+ // the same plain reserve the clear was reaching for, so this
+ // gets there in ONE assignment with no hole in between.
+ // Deferred by a microtask so the outgoing expander is out of
+ // the DOM before measure() looks for it, and re-checked because
+ // selectTab closes the expander and then swaps the keyed grid —
+ // the outro can be racing that teardown.
+ if (grid?.isConnected && grid.classList.contains("grid")) {
+ queueMicrotask(() => {
+ if (grid.isConnected) gridMeasures.forEach((m) => m());
+ });
+ }
},
};
}
+ /* SAME-ROW ALBUM SWITCH — the panel morphs into the next album rather than
+ * closing and reopening. Only this path reaches it: expanderIndex keeps the
+ * same insertion point for a same-row switch, so .album-expander never
+ * remounts and only the keyed .xp-panel inside it changes. It used to be
+ * destroyed and rebuilt with a 150ms fly-in, which is the close-then-open
+ * the user was seeing.
+ * Settings are the ones approved from the mockup: staggered morph, 400ms,
+ * quintOut. `research/.../mockups/album-switch.html` is that mockup. */
+ const SWAP_MS = 400;
+ const SWAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; // quintOut
+ // Parts, in the order they arrive. The backdrop layers do NOT lift: sliding
+ // a 38px-blurred wash reads as a wobble rather than a reveal. Delays are
+ // quoted at the mockup's 360ms baseline and scaled to SWAP_MS.
+ // .rail-desc is deliberately absent: toggleExpander nulls openInfo
+ // synchronously, so the description does not exist yet when this runs and
+ // querying for it here found nothing — it popped in later, unanimated,
+ // which is what the user saw. It carries its own `in:partIn` instead, and
+ // so do the track rows, which can land hundreds of ms after the swap.
+ // .xp-tracks stays, and is the reason the rows are covered when they are
+ // already cached: Svelte plays an element's `in:` only when it is added by
+ // a later update, not when it is part of its block's first render, so a
+ // cached list has no intro of its own and rides the column's instead.
+ const SWAP_PARTS = [
+ { sel: ".rail-glow, .rail-shade", delay: 0, lift: false },
+ { sel: ".rail-art-stage", delay: 0, lift: true },
+ { sel: ".rail-title", delay: 60, lift: true },
+ { sel: ".rail-meta", delay: 90, lift: true },
+ { sel: ".rail-cta", delay: 120, lift: true },
+ { sel: ".rail-eds", delay: 175, lift: true },
+ { sel: ".xp-tracks", delay: 90, lift: true },
+ ];
+
+ const reducedMotion = () =>
+ typeof matchMedia === "function"
+ && matchMedia("(prefers-reduced-motion: reduce)").matches;
+
+ /** One part of the staggered morph, as a transition so it belongs to the
+ * element rather than to a snapshot of the DOM. The description resolves a
+ * microtask after the panel is built and the track rows hundreds of ms
+ * later; anything driven from panelEnter simply misses them. Fades to the
+ * element's OWN resting opacity, never to 1. */
+ function partIn(node, { delay = 0, duration = SWAP_MS * 0.7 } = {}) {
+ if (reducedMotion()) return { duration: 1 };
+ const rest = parseFloat(getComputedStyle(node).opacity) || 1;
+ return {
+ delay,
+ duration,
+ easing: quintOut,
+ css: (t, u) => `opacity: ${t * rest}; transform: translateY(${7 * u}px);`,
+ };
+ }
+
+ /** Hold the stage at an eased height that TRACKS the panel, rather than
+ * animating once to a height measured up front. The panel is at its
+ * smallest when the swap begins — no description, no track rows — so a
+ * snapshot target is the wrong number for any album whose list is longer
+ * than its rail: measured switching a 2-track album to a 16-track one, the
+ * stage eased to 441 and then jumped 117px in a single frame at 418ms when
+ * the rows landed. Following the panel means late content is eased to, not
+ * jumped to, and the clip stays on until the height has actually settled. */
+ function followStageHeight(stage, panel, fromH) {
+ const seq = String((+(stage.dataset.swapSeq || 0)) + 1);
+ stage.dataset.swapSeq = seq;
+ stage.style.overflow = "hidden";
+ stage.style.height = `${fromH}px`;
+ void stage.offsetHeight; // start value for the transition
+ stage.style.transition = `height ${SWAP_MS}ms ${SWAP_EASE}`;
+
+ let settleTimer = 0;
+ let capTimer = 0;
+ let ro = null;
+ const finish = () => {
+ if (stage.dataset.swapSeq !== seq) return;
+ ro?.disconnect();
+ clearTimeout(settleTimer);
+ clearTimeout(capTimer);
+ stage.style.transition = "";
+ stage.style.height = "";
+ stage.style.overflow = "";
+ delete stage.dataset.swapSeq;
+ };
+ const apply = () => {
+ if (stage.dataset.swapSeq !== seq) return;
+ const h = panel.getBoundingClientRect().height;
+ if (h > 0 && stage.style.height !== `${h}px`) stage.style.height = `${h}px`;
+ // Let go once the panel has stopped changing size — not on a timer
+ // tied to the swap, because the tracks can land after it.
+ clearTimeout(settleTimer);
+ settleTimer = setTimeout(finish, SWAP_MS + 60);
+ };
+ // No feedback loop: the panel is content-sized, so the height written
+ // to the stage cannot change the panel's own height.
+ ro = new ResizeObserver(apply);
+ ro.observe(panel);
+ apply();
+ capTimer = setTimeout(finish, 4000);
+ }
+
+ /** The outgoing panel: out of flow so the incoming one has the stage to
+ * itself, and faded early so the two are never both legible. */
+ function panelLeave(node) {
+ node.style.position = "absolute";
+ node.style.left = "0";
+ node.style.right = "0";
+ node.style.top = "0";
+ node.style.pointerEvents = "none";
+ return {
+ duration: reducedMotion() ? 1 : SWAP_MS * 0.4,
+ easing: quintOut,
+ css: (t) => `opacity: ${t}`,
+ };
+ }
+
+ /** The incoming panel. Svelte only times this; the visible motion is the
+ * per-part WAAPI stagger below, because a transition's css() applies to the
+ * node as a whole and this has to move its pieces separately. */
+ /** The waterfall itself, as an ACTION so it runs however the panel came to
+ * exist. It used to live inside panelEnter, which is an `in:` transition —
+ * and Svelte plays those only for elements added by a later update, not for
+ * elements that are part of a newly-created block's first render. A
+ * same-row switch updates the keyed block, so it ran there; opening from
+ * nothing and opening a different row both CREATE the whole expander, so
+ * the parts had no animations at all (measured: 0 on every one of them) and
+ * the panel simply appeared. An action has no such rule. */
+ function staggerIn(node) {
+ if (reducedMotion()) return;
+ for (const { sel, delay, lift } of SWAP_PARTS) {
+ for (const el of node.querySelectorAll(sel)) {
+ // To the element's OWN resting opacity, never to 1:
+ // .rail-glow rests at 0.32, and animating it to 1 ran the
+ // colour wash at three times its brightness for the whole
+ // transition, then dropped it the instant the animation was
+ // released. That is the bright-then-dark bleed.
+ const rest = getComputedStyle(el).opacity || "1";
+ el.animate(
+ [
+ { opacity: 0, transform: lift ? "translateY(7px)" : "none" },
+ { opacity: rest, transform: "none" },
+ ],
+ {
+ duration: SWAP_MS * 0.7,
+ delay: delay * (SWAP_MS / 360),
+ easing: SWAP_EASE,
+ fill: "backwards",
+ },
+ );
+ }
+ }
+ }
+
+ function panelEnter(node) {
+ const quick = reducedMotion();
+ // The stage only needs its height eased when there IS an outgoing panel
+ // to ease FROM. On a first open the expander itself is animating open
+ // (expandSlide) and a second height animation would fight it.
+ const stage = node.parentElement;
+ const leaving = stage?.querySelector(".xp-panel[style*='absolute']");
+ if (stage && leaving && !quick) {
+ // Clipped for as long as the height is being driven: the stage is
+ // held at an eased height while a panel of a different height sits
+ // inside it, and without this the taller one hangs out of the box
+ // and the album row below is drawn over it — measured up to 127px
+ // of spill between panels whose track lists differ. Released by
+ // followStageHeight once the height settles, so at rest the row's
+ // hover controls and tooltips can still escape.
+ followStageHeight(stage, node, leaving.getBoundingClientRect().height);
+ }
+ // The parts are staggered by use:staggerIn, which has already run by
+ // now — it fires on mount, for every way a panel can appear, where this
+ // transition only fires on a same-row switch.
+ return { duration: quick ? 1 : SWAP_MS, css: () => "" };
+ }
+
// Mock-exact open/close motion: the row's height collapses while the
// content lifts 8px and fades, on a fast-out/long-settle curve (quintOut
// tracks the mock's cubic-bezier(0.33, 1, 0.4, 1) within a percent).
function expandSlide(node, { duration = 460 } = {}) {
- const height = node.offsetHeight;
+ // The height is read LIVE, per frame, and never captured. Svelte turns
+ // a `css` transition into a keyframe list computed ONCE at the start
+ // and played with fill: forwards, so `const height = node.offsetHeight`
+ // measured the panel at click time — when it is still an empty
+ // skeleton, because showEdition sets openTracks = [] / openLoading =
+ // true and loadAlbumInfo sets openInfo = null synchronously, all before
+ // Svelte's flush. The panel therefore animated to the WRONG height, and
+ // when the animation was cancelled at the end it reverted to its real
+ // one in a single frame: measured 348px -> 465px at 476ms, on every
+ // open, warm or cold. That snap is what reads as "the instant it loads
+ // it expands the box bigger" — it is not the description arriving (on
+ // a warm open the description is in at 7ms and the tracks at 46ms), it
+ // is this animation finishing against a stale number.
+ // `tick` costs a style write per frame, but height was never a
+ // compositable property anyway: both forms relayout every frame, so
+ // this buys correctness for nothing.
+ let full = node.scrollHeight;
+ // The spacing below the box travels WITH it. The grid's row-gap is 0
+ // and this margin is the rhythm (see .grid / .album-expander), so
+ // retiring it in step with the height means the collapsed element
+ // contributes nothing at all and its removal moves no row. Read off the
+ // element so the CSS stays the single source of the number.
+ const skirt = parseFloat(getComputedStyle(node).marginBottom) || 0;
return {
duration,
easing: quintOut,
- css: (t, u) =>
- `overflow: hidden; height: ${t * height}px; min-height: 0;` +
- `opacity: ${Math.min(1, t / 0.74)}; transform: translateY(${-8 * u}px);`,
+ tick: (t, u) => {
+ // scrollHeight reports the CONTENT's height and ignores the one
+ // being imposed here, so it keeps telling the truth as tracks
+ // and description land underneath. Monotonic, so a row that
+ // arrives mid-flight cannot make the box lurch backwards.
+ full = Math.max(full, node.scrollHeight);
+ if (t === 1) {
+ // Hand every property back rather than leaving the final
+ // frame's inline values pinned on the element.
+ node.style.cssText = node.style.cssText.replace(
+ /(?:overflow|height|min-height|opacity|transform|margin-bottom):[^;]*;?/g,
+ "",
+ );
+ return;
+ }
+ node.style.overflow = "hidden";
+ node.style.minHeight = "0px";
+ node.style.height = `${t * full}px`;
+ node.style.opacity = `${Math.min(1, t / 0.74)}`;
+ node.style.transform = `translateY(${-8 * u}px)`;
+ // 0 at t=0, its full value at t=1.
+ node.style.marginBottom = `${skirt * t}px`;
+ },
};
}
@@ -3141,6 +3870,34 @@
.map(([disc, tracks]) => ({ disc, tracks }));
});
const multiDisc = $derived(openDiscs.length > 1);
+
+ // The album's own most-played track is the yardstick — a bar scaled to the
+ // artist's biggest hit would leave every track on a quiet record flat. Zero
+ // means Last.fm knew none of this tracklist, and the whole column (header
+ // included) stays out of the layout rather than printing a row of dashes.
+ const playsPeak = $derived(
+ openTracks.reduce((peak, t) => Math.max(peak, t.plays ?? 0), 0),
+ );
+ /** Share of the album's peak, as a bar width. */
+ const playShare = (track) =>
+ !track.plays || !playsPeak ? 0 : Math.max(2, Math.round((track.plays / playsPeak) * 100));
+ /** Same share as a 0..1 temperature, driving the fill's colour. */
+ const playHeat = (track) =>
+ (!track.plays || !playsPeak ? 0 : track.plays / playsPeak).toFixed(3);
+ /** Unknown is a dash, never a zero — same rule as the Popular column. */
+ const fmtPlays = (plays) => (plays == null ? "—" : plays.toLocaleString());
+
+ const openAlbumPlays = $derived(openTotals?.plays ?? null);
+ const openAlbumListeners = $derived(openTotals?.listeners ?? null);
+ const openTotalsSummed = $derived(Boolean(openTotals?.summed));
+ /** Album totals are headline figures, so they abbreviate. */
+ const fmtCompact = (n) => {
+ if (n == null) return "—";
+ if (n >= 1e6) return `${(n / 1e6).toFixed(n < 1e7 ? 1 : 0)}M`;
+ if (n >= 1e3) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}K`;
+ return String(n);
+ };
+
const openReleaseLocked = $derived(
isUpcomingAlbum(openAlbum ?? openFamily ?? {})
|| openTracks.some((track) => track.unreleased),
@@ -3194,6 +3951,16 @@
return `${n} fans`;
}
+ // Last.fm listeners, grouped in full rather than abbreviated like fmtFans:
+ // the column exists to show what the list is sorted BY, and 1.7M appears
+ // three times running on a big artist where the real figures differ by
+ // eighty thousand. A dash means Last.fm has no count for the track — either
+ // too new to have been scrobbled, or this artist had no usable anchor at
+ // all. Unknown is not zero, so it must not render as one.
+ function fmtListeners(n) {
+ return n == null ? "—" : n.toLocaleString();
+ }
+
function fmtBytes(n) {
if (!n) return "";
if (n >= 1e9) return `${(n / 1e9).toFixed(2)} GB`;
@@ -3395,6 +4162,7 @@
class="artist-page"
data-load-stage={pageLoadStage}
data-scroll={heroScrolled ? "1" : "0"}
+ data-park={extrasParked ? "1" : "0"}
>
{#if catalog.artistLoading && !page}
{#if page.socials?.length}
-
- {#each page.socials as link (link.kind)}
- {@const meta = SOCIAL_META[link.kind] ?? SOCIAL_META.website}
- { e.preventDefault(); api.openExternal(link.url); }}
- >
Loading tracks…
+ + {#if loadingVisible} +