From 388ad7d9102a4b0496f95495da9ab4b5836ac913 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:44:05 -0500 Subject: [PATCH] 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". --- src/lib/state.svelte.js | 48 +- src/views/CatalogArtist.svelte | 1492 +++++++++++++++++++++++++++++--- 2 files changed, 1407 insertions(+), 133 deletions(-) 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); }} - > - {/each} + + + + {#each page.socials as link (link.kind)} + {@const meta = SOCIAL_META[link.kind] ?? SOCIAL_META.website} + { e.preventDefault(); api.openExternal(link.url); }} + > + {/each} + {/if} {:else} + arrive. Same container, same chip metrics, so + the handover costs at most the line the real + chips need and the reserve below follows it — + not the free-for-all the old in-flow paragraph + above the actions caused. (It used to promise + the height NEVER changed. That was only ever + true while the row could not wrap; a status + line is one line and a full chip set may be + two.) -->
@@ -3840,30 +4619,35 @@

Popular

@@ -3972,26 +4756,38 @@ use:revealIntoView > + +
{#key openFamilyKey} -
+
{#if previousRailArtAlbum?.cover && !artBroken[previousRailArtAlbum.cover]} - + {/if} {#key railArtTransitionKey} {#if railArtAlbum?.cover && !artBroken[railArtAlbum.cover]} - + {/if} {/key}
{#if previousRailArtAlbum?.cover && !artBroken[previousRailArtAlbum.cover]} - + {/if} {#key railArtTransitionKey} {#if railArtAlbum?.cover && !artBroken[railArtAlbum.cover]} - handleCoverError(event.currentTarget, railArtAlbum.cover, railArtAlbum.title)} /> + + handleCoverError(event.currentTarget, railArtAlbum.cover, railArtAlbum.title)} /> {:else}
{/if} @@ -4005,6 +4801,22 @@ ? ` · ${openAlbum.track_count} tracks` : ""}{fmtRuntime(openTracks) ? ` · ${fmtRuntime(openTracks)}` : ""}{openFamily && familyInLibrary(openFamily) ? " · in your library" : ""}

+ {#if openAlbumPlays != null} +
+ {fmtCompact(openAlbumPlays)}plays + {#if openAlbumListeners != null} + + {fmtCompact(openAlbumListeners)}listeners + {/if} + {#if openTotalsSummed}summed{/if} +
+ {/if} {#if !openReleaseLocked}
{/if} @@ -4038,7 +4851,9 @@ onclick={() => showEdition(edition)} > {#if edition.cover && !artBroken[edition.cover]} - handleCoverError(event.currentTarget, edition.cover, edition.title)} /> + + handleCoverError(event.currentTarget, edition.cover, edition.title)} /> {:else} {/if} @@ -4054,13 +4869,39 @@
{#if openLoading} -

Loading tracks…

+ + {#if loadingVisible} +
+
+ Loading tracks +
+ {#each skeletonRows as w, i (i)} + + {/each} +
+ {/if} {:else} + {#if openTracks.length} +
0} in:partIn={{ delay: 30 }}> + # + Title + {#if playsPeak > 0}Plays{/if} + +
+ {/if} {#each openDiscs as d (d.disc)} {#if multiDisc} -
Disc {d.disc} · {d.tracks.length} tracks
+
Disc {d.disc} · {d.tracks.length} tracks
{/if} - {#each d.tracks as t (t.id)} + {#each d.tracks as t, ti (t.id)} {@const locked = t.unreleased} {@const playTrack = () => { if (locked) return; @@ -4074,6 +4915,9 @@ }}
0} + style={playsPeak > 0 ? `--t:${playHeat(t)}` : undefined} + in:partIn={{ delay: Math.min(ti * 16, 180) }} class:now={nowPlaying("trackId", t.id)} class:locked role="button" @@ -4088,6 +4932,12 @@ {t.title} {#if libraryTicks.has(norm(t.title || ""))}{/if} + {#if playsPeak > 0} + + + {fmtPlays(t.plays)} + + {/if} {#if locked} Unreleased {:else} @@ -4113,6 +4963,7 @@
{/key} +
{/if} {/each} @@ -4132,10 +4983,6 @@ text-align: center; } - .hint.small { - padding: 12px 0; - text-align: left; - } /* Back control for the loading / error states, shown before the hero exists so a back affordance is always present (the glass header below @@ -4170,7 +5017,12 @@ --p: 0; --hero-span: 0px; /* where the content's top edge rests */ --strip: 0px; /* where that edge parks */ - --row-h: 76px; /* the Play/chips row's resting height */ + --row-h: 76px; /* the Play/chips row's MEASURED resting height */ + --row-min: 76px; /* and its one-line floor, which must stay a constant */ + /* Where the row's FIRST line sits. Derived so a one-line row lands + exactly where centring used to put it at either breakpoint: + --row-min 76 gives 13px, --row-min 66 gives 8px. */ + --row-pad-top: calc((var(--row-min) - 66px) / 2 + 8px); --mark-rest: 16px; /* the wordmark's resting bottom, above --edge */ --mark-end: 1; /* the wordmark's parked scale */ @@ -4565,7 +5417,7 @@ transform: scale(calc(1 - var(--p) * (1 - var(--mark-end)))); } - .stat-chips > :nth-child(n + 4) { + .stat-chips > .social-set { opacity: calc(1 - var(--p) * 2.4); } @@ -4753,12 +5605,12 @@ animation-range: 0px var(--travel, 999999px); } - /* The shed chips' fade — over --fade-span (= travel / 2.4, its OWN + /* The social block's fade — over --fade-span (= travel / 2.4, its OWN sentinel from measure()), so opacity = 1 - 2.4p with fill-both holding 0 past p = 1/2.4 exactly where the calc clamps, and a degenerate page (999999px sentinel) stays at opacity 1 at any scrollTop, exactly as the pinned p keeps it today. */ - .stat-chips > :nth-child(n + 4) { + .stat-chips > .social-set { animation-name: extras-fade-ride; animation-duration: auto; animation-timing-function: linear; @@ -5771,36 +6623,71 @@ --play-shrink), shrinking about its own centre inside a static box: "attach it to the chips that are resizing and transforming", verbatim. - overflow-x is CLIPPED, not hidden: the row is nowrap (see .stat-chips) - so a very long chip set can overrun the pane, and clip stops that from - reaching the scroll pane as horizontal overflow while leaving the - chips' hover popovers free to escape vertically. */ + overflow-x is CLIPPED, not hidden: the row wraps, so it no longer + overruns on a normal chip set, but a single unbreakable chip (a long + top_genre — .chip is white-space: nowrap) can still be wider than the + pane, and clip stops that from reaching the scroll pane as horizontal + overflow while leaving the chips' hover popovers free to escape + vertically. `clip`/`visible` is also the only legal mixed pair. + + min-height is --row-h, and safely so: --row-h is derived from the CHIP + BLOCK's height plus constants (see measure()), never from this box, so + there is no path by which the box's own height feeds its floor. + + flex-START, not centre. Centring makes every item's position depend on + the block's height, so when the social line leaves at the park the + survivors re-centre — a measured 18.5px drop, at one instant, mid-scroll. + Anchored to the top, the row's height decides nothing and the collapse + moves not one pixel. Each item is instead centred against a 50px lead + row (Play's resting size) by its own margin below. */ .artist-actions { position: absolute; left: 0; right: 0; bottom: calc(-1 * var(--row-h)); - min-height: var(--row-h); - padding: 8px 0; + min-height: var(--row-h, 76px); + padding: var(--row-pad-top, 13px) 0 8px; display: flex; - align-items: center; + align-items: flex-start; gap: 16px; overflow-x: clip; overflow-y: visible; pointer-events: none; } + /* Each item centred against that 50px lead row. The --p terms are the + FALLBACK path only, where the buttons shrink by real width and height: + half of what they shed, added back as margin, holds each centre still. + On the scroll-driven path --p stays 0 and the shrink is a transform, + which does not move a layout box — so both terms vanish there, exactly + as the sibling calcs on .hero-play/.hero-preview do. */ + .hero-play { + margin-top: calc(var(--p) * var(--play-shrink) / 2); + } + + .hero-preview { + /* (50 - 40) / 2 at rest; 13px is this button's own 40 -> 27 shed. */ + margin-top: calc(5px + var(--p) * 6.5px); + } + + .stat-chips { + /* (50 - 27) / 2 — a chip line is 27px and never scales. */ + margin-top: 11.5px; + } + .hero-play, .hero-preview, .stat-chips { pointer-events: auto; } - /* The hole the row left behind in .popular. It carries the row's resting - height rules, and heroScroll() reads --row-h back off it, so the spacer - and the pinned row can never disagree about how tall the row is. */ + /* The hole the row left behind in .popular. It FOLLOWS the row now — + heroScroll() measures the row and writes --row-h, this reads it back — + so the spacer and the pinned row still cannot disagree about how tall + the row is, and a wrapped row pushes Popular down instead of climbing + into the wordmark. The literal is only the pre-measure first paint. */ .actions-gap { - min-height: 76px; + min-height: var(--row-h, 76px); } /* Floating stat chips beside Play — individually rounded translucent @@ -5812,33 +6699,53 @@ .stat-chips { display: flex; align-items: center; - /* nowrap by design: the row has to stay ONE line to fit a 124px - banner, and a second row would grow up into the wordmark once the - block is anchored to its bottom edge. Something therefore has to - give at narrow widths, and it should be the least important stat — - see the shedding rules below. */ - flex-wrap: nowrap; + /* Wraps. The old nowrap was defended as "the row has to stay ONE line + to fit a 124px banner, and a second row would grow up into the + wordmark once the block is anchored to its bottom edge" — true only + while --row-h was a constant. It is measured off the row now + (heroScroll's measure()), so the box's top stays pinned to --edge + and the extra line grows DOWN into the reserve. Nothing has to give + any more, which is why the shedding below now starts three tiers + lower than it did. */ + flex-wrap: wrap; gap: 8px; min-width: 0; } - /* Narrow panes SHED chips rather than letting the row overflow. */ - .idpin[data-w="s"] .stat-chips > :nth-child(n + 3), - .idpin[data-w="xs"] .stat-chips > :nth-child(n + 2) { + /* Only below the width where even a wrapped row cannot fit the parked + banner does the tail still shed. .social-set is named as well as + counted: the wrapper collapses eight sibling indices into one, so on a + sparse artist the socials can sit at index 2 and would otherwise + outlive every stat beside them. */ + .idpin[data-w="xs"] .stat-chips > :nth-child(n + 2), + .idpin[data-w="xs"] .stat-chips > .social-set { display: none; } - /* Only the first three stats survive into the banner; the rest fade out on - the way up. They keep their layout width — the row is left-aligned, so - nothing shifts — but they must stop taking clicks once invisible, which - opacity alone does not do. The opacity ramp lives per-branch (fallback: - the live calc; scroll-driven: extras-fade-ride); the hit gate stays - here in base for both — pointer-events is not animatable, so apply() - keeps writing --extra-hit (boundary-only on the supported path). */ - .stat-chips > :nth-child(n + 4) { + /* EVERY STAT CHIP survives into the banner now; only the social links go. + They used to be shed by count — `:nth-child(n + 4)` — which knows + nothing about where the row's lines break: on a three-line row it + deleted the genre chip and the heart from BESIDE years-active, leaving + it alone on a half-empty line. Cutting at the social block instead is a + structural boundary, so a line either survives whole or leaves whole. + The opacity ramp lives per-branch (fallback: the live calc; + scroll-driven: extras-fade-ride); the hit gate stays here in base for + both — pointer-events is not animatable, so apply() keeps writing + --extra-hit (boundary-only on the supported path). Redundant with the + layout collapse below, and kept: it needs no attribute to have landed. */ + .stat-chips > .social-set { pointer-events: var(--extra-hit, auto); } + /* ...and past that same boundary the block leaves LAYOUT too, so its line + goes with it. Fading alone kept the line, and the row stayed two lines + tall with nothing in the lower one. `display` is not animatable, but by + 0.42 the block is already at opacity 0, so nothing pops — and with the + row anchored flex-start, nothing above it moves either. */ + .artist-page[data-park="1"] .stat-chips > .social-set { + display: none; + } + .chip { display: inline-flex; align-items: center; @@ -5955,12 +6862,46 @@ /* Socials: ghost icons after the heart — no chip outline, just the pale brand-tinted glyph. Hover blooms a soft disc in the same brand colour - (--soc is an "r,g,b" triple so the wash can be alpha'd). */ + (--soc is an "r,g,b" triple so the wash can be alpha'd). + The whole block is one flex item so it wraps as a unit. No margin of its + own, deliberately: a leading margin here would indent the wrapped line + away from the chips above it. The clear space the divider needs is put + on the chip BEFORE the group instead (see .chip.follow below), where a + wrap leaves it harmlessly at the end of the previous line. */ + .social-set { + position: relative; + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + } + + /* The 25px the divider used to occupy in flow (8px gap + 4px margin + 1px + + 4px margin + 8px gap), rebuilt as 17px here plus the row's own 8px + gap. It is a CONSTANT and must stay one: make it depend on whether the + group has wrapped and hiding the divider would hand back the 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. */ + .stat-chips > .chip.follow:has(+ .social-set) { + margin-right: 17px; + } + + /* OUT OF FLOW for the same reason — hiding it has to be pure paint, never + a relayout. Centred in the 25px it no longer occupies. */ .chip-divider { + position: absolute; + left: -13px; + top: 50%; + margin-top: -9px; width: 1px; height: 18px; background: rgba(255, 255, 255, 0.14); - margin: 0 4px; + } + + /* Alone at the head of a wrapped line it separates nothing. The class is + bound in the markup (class:wrapped) from syncSocialDivider()'s state. */ + .social-set.wrapped .chip-divider { + display: none; } .social-ghost { @@ -6109,8 +7050,15 @@ margin-top: 0; } + /* Widened from 760px to make room for the listener count WITHOUT taking it + out of the title and album. Break-even was 833px (the column plus its + gap); the rest is air, so title and album each end up ~38px wider than + they were before the column existed. Still well under the ~2068px the + section has — a 54px row stretched that far puts the duration a very long + way from the title, which is why the discography grid gets the full width + and this list does not. */ .popular-list { - max-width: 760px; + max-width: 900px; } .popular-more { @@ -6130,7 +7078,7 @@ .popular-row { display: grid; - grid-template-columns: 26px 40px minmax(130px, 1fr) minmax(100px, 0.75fr) 40px 24px; + grid-template-columns: 26px 40px minmax(130px, 1fr) minmax(100px, 0.75fr) 62px 40px 24px; align-items: center; gap: 11px; min-height: 54px; @@ -6154,13 +7102,45 @@ text-align: center; } + /* Exactly the 40px grid column the bare used to occupy, so the row + geometry is unchanged — it only gains a positioning context for the + outgoing cover to fade out in. */ + .cover-slot { + position: relative; + display: block; + width: 40px; + height: 40px; + } + .track-cover { + display: block; width: 40px; height: 40px; border-radius: 3px; object-fit: cover; } + /* The ghost is built at runtime by `resolvedCover`, so the compiler cannot + see either class in the markup and prunes them as unused — :global keeps + them in the build, the same way `.is-loaded` is kept above. */ + .cover-slot :global(.cover-ghost) { + position: absolute; + inset: 0; + opacity: 1; + transition: opacity 260ms ease; + pointer-events: none; + } + + .cover-slot :global(.cover-ghost.faded) { + opacity: 0; + } + + @media (prefers-reduced-motion: reduce) { + .cover-slot :global(.cover-ghost) { + transition: opacity 90ms linear; + } + } + .track-name, .track-album { min-width: 0; @@ -6179,6 +7159,21 @@ font-size: 12px; } + /* All-time Last.fm listeners — what the list is ordered by. Muted, NOT + accent: this row already spends the accent on `.now`, and a blue column + on every row would drown the one that means "playing". Tabular figures so + the digits line up in a right-aligned column. */ + .lis { + color: var(--text-muted); + font-size: 11px; + text-align: right; + font-variant-numeric: tabular-nums; + } + + .lis.unknown { + color: var(--border); + } + h2 { font-size: 15px; font-weight: 700; @@ -6248,7 +7243,19 @@ 132px cover plus the card's new 9px padding on each side, so covers render at the same size they always did. */ grid-template-columns: repeat(auto-fill, 150px); - gap: 16px; + /* Column gap only. The vertical rhythm is carried by margins on the + items instead, because a GAP cannot be animated away with the item + it surrounds: a collapsing expander reaches height 0 with its two + 16px gaps still standing, and the instant Svelte removes it the grid + loses one of them and every row below steps up 16px in a single + frame. Measured at the removal instant, 577.9 -> 561.9, on both a + close and a switch — with scrollTop unchanged, which is why it never + showed up in a scroll trace. A margin belongs to the box, shrinks + with it, and leaves nothing behind. (Negative margins do NOT work + here: a grid row track floors at zero, so -16px on a 0-height item + moves nothing — verified by sweeping it.) */ + column-gap: 16px; + row-gap: 0; /* Cards hug their content — the ROW is as tall as its tallest title, but a short-titled neighbor must not stretch (its hover/active surface would wrap a box of empty space). */ @@ -6641,6 +7648,8 @@ .release { display: flex; flex-direction: column; + /* The grid's vertical rhythm — see .grid's row-gap: 0. */ + margin-bottom: 16px; padding: 9px; border-radius: var(--radius); cursor: pointer; @@ -6672,9 +7681,19 @@ /* Full grid row directly below the open card's row. The slide transition animates its height on open/close; same-row switches keep this element and only crossfade the panel content. */ + /* Containing block for the outgoing panel, which panelLeave takes out of + flow so the two overlap instead of stacking. */ + .xp-stage { + position: relative; + } + .album-expander { grid-column: 1 / -1; position: relative; + /* Its share of the rhythm, and expandSlide animates it to 0 alongside + the height so the box contributes exactly nothing by the time it is + removed. */ + margin-bottom: 16px; } /* Mock-exact notch: a rotated square whose BOTTOM half is hidden by the @@ -6814,6 +7833,58 @@ text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); } + /* Album totals, sitting between the meta line and the Play row. The + numerals carry the accent so the rail reads as this record's own. */ + .rail-stats { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-top: -4px; + } + + .rail-stat { + display: flex; + flex-direction: column; + line-height: 1.12; + } + + .rail-stat b { + font-size: 16px; + font-weight: 800; + color: var(--accent-hover); + font-variant-numeric: tabular-nums; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); + } + + .rail-stat i { + font-style: normal; + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + font-weight: 700; + margin-top: 2px; + } + + .rail-stat-div { + width: 1px; + height: 22px; + background: rgba(255, 255, 255, 0.12); + } + + /* An approximation is labelled rather than passed off as Last.fm's figure. */ + .rail-stat-note { + font-size: 9.5px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 5px; + font-weight: 700; + } + .rail-cta { display: flex; gap: 8px; @@ -7006,6 +8077,100 @@ } } + /* The loading state: the app's own ring over placeholder rows drawn in the + track grid's exact geometry, so the real list replaces them in place. + Only ever rendered once a load has outlasted LOADING_GRACE — the flash it + replaces was 7-31ms of grey text on a warm open. */ + .tracks-loading { + display: flex; + flex-direction: column; + } + + .tl-head { + display: flex; + align-items: center; + gap: 9px; + padding: 7px 10px 13px; + color: var(--text-secondary); + font-size: 12.5px; + } + + .tl-ring { + width: 13px; + height: 13px; + border-radius: 50%; + border: 1.6px solid rgba(255, 255, 255, 0.16); + border-top-color: rgba(255, 255, 255, 0.62); + animation: tl-spin 0.72s linear infinite; + } + + @keyframes tl-spin { + to { + transform: rotate(360deg); + } + } + + /* .trow's own columns, gap, padding and height — see the rule below. A + placeholder that does not match it moves the real rows when it goes. */ + .tl-row { + display: grid; + grid-template-columns: 26px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 7px 10px; + height: 33px; + box-sizing: border-box; + } + + .tl-bar { + position: relative; + height: 9px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.07); + overflow: hidden; + } + + .tl-num { + width: 12px; + justify-self: center; + } + + .tl-dur { + width: 30px; + } + + /* One sweep per bar on a shared clock, not a per-row pulse: independently + breathing bars read as noise rather than as one thing arriving. */ + .tl-bar::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.11), + transparent + ); + transform: translateX(-100%); + animation: tl-sweep 1.4s ease-in-out infinite; + } + + @keyframes tl-sweep { + to { + transform: translateX(100%); + } + } + + @media (prefers-reduced-motion: reduce) { + .tl-bar::after { + animation: none; + } + + .tl-ring { + animation-duration: 2.4s; + } + } + /* Mock-exact rows: number / title / duration, with the per-track play and save controls revealed on hover in place of the duration. */ .trow { @@ -7020,6 +8185,74 @@ cursor: pointer; } + /* The header and the rows share one template, so the columns line up + whether or not Last.fm knew this record. */ + .trow.has-plays, + .tl-cols.has-plays { + grid-template-columns: 26px minmax(0, 1fr) 168px 48px; + } + + .tl-cols { + display: grid; + grid-template-columns: 26px minmax(0, 1fr) 48px; + align-items: center; + gap: 12px; + padding: 0 10px 7px; + margin-bottom: 3px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--text-muted); + } + + .tdur-head { + display: flex; + justify-content: flex-end; + } + + /* Plays: length AND colour both carry the count, so a deep cut reads short + and cool while the record's biggest song burns the album's own accent. + The floor keeps the quietest track legible instead of letting it sink + into the row. */ + .tplays { + position: relative; + display: flex; + align-items: center; + height: 21px; + border-radius: 4px; + overflow: hidden; + font-size: 12.5px; + font-variant-numeric: tabular-nums; + } + + .tplays-fill { + position: absolute; + inset: 0 auto 0 0; + border-radius: 4px; + background: color-mix( + in srgb, + color-mix(in srgb, var(--accent-hover) calc(22% + var(--t) * 78%), var(--text-muted)) + calc(30% + var(--t) * 55%), + transparent + ); + } + + .tplays-val { + position: relative; + z-index: 1; + padding: 0 8px; + color: color-mix(in srgb, #fff calc(45% + var(--t) * 55%), var(--text-muted)); + } + + /* The hover reveal slides the play/save buttons over the right end of the + row, so the duration gets out of the way — the count stays put. */ + .trow:hover .tplays, + .trow:focus-within .tplays { + opacity: 1; + } + .trow:hover { background: var(--bg-highlight); } @@ -7144,17 +8377,22 @@ padding: 0 20px; } - /* The spacer is the source of truth for the row's resting height — - heroScroll() reads --row-h off it, and the pinned row follows. */ - .actions-gap { - height: 66px; + /* Tighter one-line floor here. It must be set as --row-min on the + page, NOT as a height on the spacer: the spacer mirrors the row's + measured height now, and a hard height would pin the reserve to one + line again — the exact failure a wrapped row cannot survive, and the + worst place for it, since 66px is 50px of button plus 16px of + padding with no slack at all. */ + .artist-page { + --row-min: 66px; } .popular-row { - grid-template-columns: 24px 36px minmax(0, 1fr) 38px 22px; + grid-template-columns: 24px 36px minmax(0, 1fr) 52px 38px 22px; gap: 8px; } + .cover-slot, .track-cover { width: 36px; height: 36px;