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;