artist page: open an album in place, and show what the numbers are worth

The discography grid gains an expander: click a record and it opens between the
rows, with a rail carrying the art, the totals and the editions, and a track
list beside it. Three strands landed on this file across as many sessions and
they share the same DOM -- the row template the plays column widens is the one
the expander draws, and the keying that attribution needs is what makes the
cover crossfade possible -- so they arrive together.

**Keeping the grid still while a panel grows inside it.** An expander opening
mid-grid moves every row below it, and the naive version fights its own
animation: the reserve is measured against a layout that is still collapsing.
So each expander other than the one being kept is treated as mid-outro and
discounted from the measurement, the grid's `min-height` glides to the new
reserve rather than snapping, and a same-row switch keeps BOTH panels alive --
`panelLeave` takes the outgoing one out of flow so the two overlap instead of
stacking, and the stage eases between their heights so the box never collapses
to nothing on the way. `.xp-stage` exists to be the containing block for that.

**The loading state only exists if the load is slow.** A warm open resolves in
tens of milliseconds and used to flash a line of text on its way past, so
nothing renders until the load has outlasted a 250ms grace -- and when it does
render it is placeholder rows drawn in the track grid's exact geometry, so the
real list replaces them in place rather than reflowing.

**Cover tier follows DPR, not window size.** The rail art is a FIXED 209px --
the rail column is 250px regardless of monitor -- so a 4K display does not make
the box bigger; only device pixel ratio does. 209px needs 418 device pixels at
DPR 2, which the 500 rendition covers, and 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 and 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; its next step is 1200.

**Popular rows are keyed on `origin_track_id ?? id`.** This is what lets the
backend hand a row its original album without destroying it: the corrected row
is REUSED rather than torn down and rebuilt, which keeps its save state and is
the whole reason the cover can crossfade instead of popping. `nowPlayingTrack`
matches either id, so a track that is playing stays marked as playing across the
correction. The crossfade itself needs the proxied art SYNCHRONOUSLY -- one
microtask is still a frame with the raw CDN url in `src`, which is a network
request for a file already held -- hence `peekCatalogArt`, which returns the
resolved data URL or null and never a promise.

The replay recipe goes to v4 for the same reason it went to v3: a v3 replay
carries Deezer's licensed attribution on every popular row -- Bohemian Rhapsody
on the 2018 movie soundtrack, wearing that poster -- and painting a known-wrong
album name instantly is worse than one ordinary load.

**Plays, as a heat block rather than a bare number.** Bar length and colour both
scale with the count, tinted with the OPEN album's accent so the column reads as
this record's own. The header and the rows share one grid template, so the
columns line up whether or not Last.fm knew the record -- and when it knew
nothing the whole column, header included, stays out of the layout entirely. An
unknown track is a dash, never a zero. Album totals sit between the meta line
and the Play row, badged when they were summed from the tracklist because the
provider's own figure could not cover its biggest track.

The listener count in the Popular list needed the row wider: break-even was
833px for the column plus its gap, up from 760px, and the rest is air, so title
and album each end up ~38px wider than before rather than narrower. That column
is muted and deliberately NOT accent -- the row already spends the accent on
`.now`, and a coloured column on every row drowns the one that means "playing".
This commit is contained in:
KeelrLeek committed 2026-07-28 09:44:05 -05:00
1 parent c0fa892eee
commit 388ad7d910
2 files changed
+1407 -133

No files matched your search

+42 -6
View File
@@ -158,6 +158,20 @@ const catalogArtCache = new Map();
const CATALOG_ART_CACHE_MAX = 160; const CATALOG_ART_CACHE_MAX = 160;
const CATALOG_ART_CACHE_MAX_CHARS = 32 * MIB; 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) { export async function getCatalogArt(url) {
if (!url) return null; if (!url) return null;
// Already-inline art (e.g. a cover-derived logo) has nothing to proxy. // 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. // an instant first paint, and a miss is one ordinary load.
// v3: same argument for release ownership. A v2 replay carries a fused // v3: same argument for release ownership. A v2 replay carries a fused
// record's co-tenants and its wrong portrait, both stamped as merged. // 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; const PAGE_REPLAY_LIMIT = 16;
// Matches the backend's provider disk-cache TTL on purpose: a replay must // 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 // 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 /** Responsive card-sized variant of a canonical cover URL. The resolver keeps
* its original/high-resolution winner for the player and album expander; the * its original/high-resolution winner for the player; neither the 150px grid
* 150px grid should not decode 9-million-pixel Apple masters into GPU memory. */ * nor the expander's 209px rail should decode 9-million-pixel Apple masters
* into GPU memory to do it. */
export function gridCover(url) { export function gridCover(url) {
if (!url || url.startsWith("data:")) return url; if (!url || url.startsWith("data:")) return url;
const { square, caa } = coverTier();
try { try {
const parsed = new URL(url); const parsed = new URL(url);
if (parsed.hostname.endsWith("dzcdn.net")) { if (parsed.hostname.endsWith("dzcdn.net")) {
return url.replace( return url.replace(
/\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i, /\/\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")) { 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(); return parsed.toString();
} }
if (parsed.hostname.endsWith("coverartarchive.org")) { 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(); return parsed.toString();
} }
} catch { } catch {
File diff suppressed because it is too large. Load diff