From ea9a040d09af26694604a814b9d6a31cc3da8779 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:16:58 -0500 Subject: [PATCH] album page: the record gets a page of its own, under the same glass bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The album page was a back link, a 150px cover and a list of titles — a placeholder beside the artist page it is reached from. It is now built from the same parts: the frosted glass header, a hero carrying the record's own art and palette, the play-count bars, and the edition cards. **The glass bar is the artist page's, not a copy of it.** Same 46px, same rgba(20,20,24,0.28) over blur(12px) saturate(1.7), same sticky top:-24px with the -24/-28/-22 bleed that spans the pane and leaves zero flow height. It is meant to be the header every page navigates by, so it had to be the same object rather than something that resembles it. It gains one thing here: the record's cover, name and a play button fade into the bar once the title itself has gone under it, so the bar always says where you are. That watches the TITLE, not the identity block — watching the block meant waiting for the artwork to clear too, about 240px of scroll on a page that only has 199px of it, so it would never have fired on a ten-track album. **The hero is one proportional block.** `--cover-scale` sizes the artwork (282px) and everything beside it is `em` off that one root, so type, gaps, the artist portrait, the pills and the stat divider all move by the same factor. Two earlier attempts are worth naming because they both looked wrong: - damping the type (0.6 + 0.4·s) made the words visibly lag the artwork; - `space-between` filled the height but opened the gaps faster than the type grew, so the block came apart as it got bigger. The gap is 1.8em, which is what the five rows need to fill the cover exactly, and being an em it keeps that fit at any size. Measured live: the label's top and the buttons' bottom both sit 0px from the artwork's edges. The hero's own height derives from the artwork (`--cover + 68px`), so the 24px under the glass bar holds at every scale instead of being a second number that drifts. **Editions come from src/lib/editions.js — the artist page's own grouping.** `releaseFamilies`, `editionBaseTitle`, `isEdition` and `editionLabel` moved out of CatalogArtist.svelte (143 lines deleted there, not duplicated) so both pages fold a record's pressings the same way. Two implementations would have drifted invisibly: each page would show a plausible edition list and disagree about which pressings are one album. The album payload has no siblings, so the family is rebuilt from the artist's discography. It reads the FULL page rather than the overview — the overview is the artist page's first-paint subset and both dropped the anniversary edition and carried no track counts, so the rows read "1994" where the discography reads "1994 · 50 tracks". Cached per artist, and already warm whenever this page was reached from theirs. Live, the Blue Album now lists Original 10 / Deluxe 24 / Anniversary Super Deluxe 50, matching the discography exactly. One deliberate narrowing: the artist page buckets releases by category before grouping, and that categorisation leans on page state this view does not load (harvested description hints, MusicBrainz type hints). So a sibling is accepted only when the backend LINKED it (`edition_group`, a verified ordered core-track match) or it declares the same record type. That can only ever show fewer editions than the discography, never promote a separate live album or compilation into this record's edition list. **Switching edition is a swap, not a navigation.** `openCatalogAlbum` grew a `keepCurrent` option that holds the page already on screen instead of clearing to the loading state; the incoming cover is decoded before the swap so the artwork crossfades rather than blinking out; and the parts are re-dealt on the same clock as the expander — 400ms quintOut, 7px lift, banner → cover → kind → title → meta → stats → actions → tracklist → description → editions → credits. Polled at 16ms through a swap: 0 blank frames. The blurred banner fades without lifting, because sliding a 42px blur reads as a wobble, and every part animates to its OWN resting opacity — the banner rests at 0.62 and animating it to 1 would have run the colour wash half again too bright for the whole swap. A failed load under `keepCurrent` clears anyway, so a dead edition surfaces the error instead of looking like the click did nothing. **The byline shows the artist, not the album cover again.** The album payload carries the artist's NAME and nothing else, so `resolveArtistSeedByName` reads and fills the same cache `openArtistByName` uses: the click that navigates there has usually already paid for it, in-flight lookups are deduped so the page and its own back button cannot fire the same search twice, and a transport failure stays retryable rather than being cached as "no artist". A neutral dot holds the space until it lands, so the line never reflows. Shuffle and Preview join Play and Save. Shuffle is an action on this record, not a toggle — it always turns shuffle on rather than flipping a global and silently starting the album in order — and stays lit while it is on so that state is visible from here. Preview opens the same modal the artist page does; `buildAlbumManifest` already existed in the preview layer with no caller, which is what this surface was for. It reports partial availability the same way, and the manifest is a snapshot so an edition swap cannot rewrite an open session. Also: plays bars, the whole-track/listener totals, the editorial description and a released/label line, all off data `catalog_album` was already returning and the page was throwing away. The plays column hides below three tracks — a bar measured against itself is not a comparison, and on a single it read as a rating. Track counts are pluralised, so a single says "1 track". No add-to-playlist button. `add_to_playlist` requires a file path per track, so a catalog album can only be added once it is in the library; that is a playlist decision, not something to slip in behind an icon. 150 Rust tests and 85 JS tests pass. --- src/lib/editions.js | 164 ++++ src/lib/state.svelte.js | 69 +- src/views/CatalogAlbum.svelte | 1426 ++++++++++++++++++++++++++++---- src/views/CatalogArtist.svelte | 151 +--- 4 files changed, 1512 insertions(+), 298 deletions(-) create mode 100644 src/lib/editions.js diff --git a/src/lib/editions.js b/src/lib/editions.js new file mode 100644 index 0000000..ff7119f --- /dev/null +++ b/src/lib/editions.js @@ -0,0 +1,164 @@ +/** Edition families — the rule that folds a work's reissues, deluxes and + * remasters into ONE record with a list of editions. + * + * Lifted out of the artist page so the album page groups a record exactly the + * same way. Two implementations of this would drift, and the drift would be + * invisible: both pages would show a plausible edition list and disagree about + * which pressings belong to the same album. + * + * Every rule here was derived from a measured failure — the comments name the + * record that forced each one. Do not "simplify" a clause without a release + * that proves it is dead. + */ + +const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, ""); +const year = (album) => album.release_date?.slice(0, 4) ?? ""; + +const EDITION_MARKER = /\b(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|legacy\s+edition|special\s+edition|collector'?s\s+edition|bonus\s+tracks?|complete\s+edition|definitive\s+edition|reissue|redux|mono\s+edition|stereo\s+edition|\w+(?:['’]s)?\s+edition|half[-\s]speed|track\s+by\s+track)\b/i; +const EDITION_SUFFIX = /\s*(?:[-–—:]\s*)?(?:the\s+)?(?:(?:\d{4}|\d+(?:st|nd|rd|th))\s+)?(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|legacy\s+edition|special\s+edition|collector'?s\s+edition|bonus\s+tracks?|complete\s+edition|definitive\s+edition|reissue|redux|mono\s+edition|stereo\s+edition|\w+(?:['’]s)?\s+edition|half[-\s]speed|track\s+by\s+track)(?:\s+(?:edition|version))?.*$/i; + +// A bracketed tail naming an alternate cut of the same work — "(Acoustic +// Version)", "(2010 Mono Version)", "(Urban Mixes)", "(Sampler)". These +// fold into the base release's family. "'s Version" is exempt: a +// re-recorded catalogue (Taylor's Version) is its own work and must keep +// its own card. +const VARIANT_SEGMENT = /\b(?:versions?|mix(?:es)?|edits?|acoustic|instrumentals?|sampler|extras|mono|stereo|demos?|extended|live)\b/i; +const OWN_WORK_SEGMENT = /^\S+['’]s\s+version$/i; + +/** A pressing that presents itself as a variant of another release. */ +export const isEdition = (a) => EDITION_MARKER.test(a.title) + || /[\[(][^\])]*\bedition\b[^\])]*[\])]/i.test(a.title); + +/** The title with its edition/variant tail removed — the key that folds + * unlinked pressings of one work together. */ +export function editionBaseTitle(title) { + // Keep a useful qualifier that shares an edition bracket. For example, + // "Weezer (Deluxe Edition / Red Album)" belongs to "Weezer (Red + // Album)", not to every self-titled Weezer record. + const bracketSafe = title.replace(/\s*[\[(]([^\])]+)[\])]/g, (whole, inner) => { + const variantInner = VARIANT_SEGMENT.test(inner) && !OWN_WORK_SEGMENT.test(inner.trim()); + if (!EDITION_MARKER.test(inner) && !/\bedition\b/i.test(inner) && !variantInner) return whole; + const qualifiers = inner + .split(/\s*[/|;]\s*/) + // A segment can carry a real qualifier AND a marker tail — + // "1973-1976 2011 Remaster" must keep "1973-1976" so the + // remaster lands in the same family as "Deep Cuts + // (1973-1976)". Strip the marker tail first, then judge. + .map((part) => part + .replace(/\s*(?:\d{4}\s+)?(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|reissue|redux)(?:\s+(?:edition|version))?\s*$/i, "") + .trim()) + .filter((part) => part + && !EDITION_MARKER.test(part) + && !/\bedition\b/i.test(part) + && !(VARIANT_SEGMENT.test(part) && !OWN_WORK_SEGMENT.test(part))); + return qualifiers.length ? ` (${qualifiers.join(" / ")})` : ""; + }); + const stripped = bracketSafe + .replace(EDITION_SUFFIX, "") + .replace(/\s+/g, " ") + .trim() + .replace(/\s*[-–—:]\s*$/, ""); + return stripped || title; +} + +export function editionLabel(edition, family) { + // The expander rail header already names the album, so each edition + // row shows only what differs: "Original" for the canonical release, + // otherwise the qualifier ("Deluxe Edition", "2025 Remaster"). The + // full title stays available as the row tooltip. + if (edition.id === family.id) return "Original"; + const title = edition.title; + const base = family.baseTitle ?? ""; + // "Weezer (Deluxe Edition / Red Album)" shares its bracket with the + // family qualifier — drop segments the family already carries so the + // label is just "Deluxe Edition". Test against the canonical title, + // not baseTitle: edition_group families derive baseTitle from + // whichever release was processed first, so it can be bare "Weezer". + const known = `${family.title ?? ""} ${base}`.toLowerCase(); + const dropBaseParts = (raw) => { + const parts = raw.split(/\s*[/|;]\s*/).map((p) => p.trim()).filter(Boolean); + const kept = parts.filter((p) => !known.includes(p.toLowerCase())); + return (kept.length ? kept : parts).join(" / "); + }; + const trim = (raw) => raw + .replace(/^[\s\-–—:([]+/, "") + .replace(/[)\]]+$/, "") + .trim(); + if (base && title.toLowerCase().startsWith(base.toLowerCase())) { + const rest = trim(title.slice(base.length)); + if (rest) return dropBaseParts(rest); + } + const suffix = title.match(EDITION_SUFFIX)?.[0]; + return (suffix && dropBaseParts(trim(suffix))) || title; +} + +/** Group `items` (one category's releases) into families, newest first. + * Each family is its canonical release plus `{ familyKey, baseTitle, + * editions }`, where `editions[0]` is the canonical one. */ +export function releaseFamilies(items, category) { + // A work whose rows are only PARTLY edition_group-linked must not + // split into a work-keyed family and a title-keyed family showing two + // cards ("Nevermind" linked to its deluxe, "Nevermind (Remastered)" + // unlinked). Rows without a group adopt the group of a linked row + // that shares their base title. + const groupByBase = new Map(); + for (const album of items) { + if (album.edition_group == null) continue; + const base = norm(editionBaseTitle(album.title)); + if (base && !groupByBase.has(base)) groupByBase.set(base, album.edition_group); + } + const families = new Map(); + for (const album of items) { + const baseTitle = editionBaseTitle(album.title); + // 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}`; + if (!families.has(key)) { + families.set(key, { key, baseTitle, editions: [] }); + } + const family = families.get(key); + // Deezer can return the same regional/digital release under more + // than one catalog id, while MusicBrainz supplements can carry the + // same title/year without a track count. Those are duplicate source + // records, not editions. Different known track counts remain + // separate because they can represent a real expanded edition. + const releaseYear = year(album); + const duplicate = releaseYear && family.editions.some((edition) => + norm(edition.title) === norm(album.title) + && year(edition) === releaseYear + && (edition.track_count == null + || album.track_count == null + || edition.track_count === album.track_count), + ); + if (duplicate) continue; + family.editions.push(album); + } + + return [...families.values()] + .map((family) => { + const byDate = family.editions + .slice() + .sort((a, b) => (a.release_date ?? "9999").localeCompare(b.release_date ?? "9999")); + const canonical = byDate.find((album) => !isEdition(album)) ?? byDate[0]; + const editions = [canonical, ...byDate.filter((album) => album.id !== canonical.id)]; + return { ...canonical, familyKey: family.key, baseTitle: family.baseTitle, editions }; + }) + // Studio chronology should follow the original album, not the date + // of its latest anniversary reissue. + .sort((a, b) => (b.release_date ?? "").localeCompare(a.release_date ?? "")); +} diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index dba8889..d6bbfa0 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -2002,8 +2002,15 @@ export async function openCatalogAlbum(albumId, opts = {}) { albumResolveRequest += 1; const request = ++albumPageRequest; ui.view = "catalogAlbum"; - catalog.albumPage = null; - catalog.albumLoading = true; + // `keepCurrent` holds the page that is already on screen until the new one + // lands, instead of clearing to the loading state. Switching between two + // editions of the same record is a swap, not a navigation — blanking the + // whole page for it reads as a flash. The album view crossfades on top of + // this; every other caller still gets the plain loading state. + if (!opts.keepCurrent) { + catalog.albumPage = null; + catalog.albumLoading = true; + } catalog.pendingTrackId = opts.trackId ?? null; catalog.error = ""; const active = () => request === albumPageRequest && ui.view === "catalogAlbum"; @@ -2022,7 +2029,13 @@ export async function openCatalogAlbum(albumId, opts = {}) { if (t) catalog.pendingTrackId = t.id; } } catch (e) { - if (active()) catalog.error = String(e); + if (active()) { + catalog.error = String(e); + // A held page would swallow the failure — the old edition stays up + // and the click looks like it did nothing. Clear so the error is + // the thing on screen, exactly as a normal navigation would. + if (opts.keepCurrent) catalog.albumPage = null; + } } finally { if (request === albumPageRequest) catalog.albumLoading = false; } @@ -2032,6 +2045,56 @@ const catNorm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, ""); /** Resolve an artist by name (cached) and open their catalog page — used by * the now-playing artist link, which only knows the name. */ +/** Resolve an artist NAME to their catalog seed — `{ id, name, picture, fans, + * album_count }` — or null when no artist by that name exists. + * + * Shares the very caches `openArtistByName` fills, so a page that shows an + * artist's portrait beside a name has already paid for the click that + * navigates to them, and vice versa. In-flight lookups are deduped by name: + * an album page mounting can otherwise fire the same search its own back + * button is about to. + */ +const artistSeedRequests = new Map(); // normalized name → Promise +export function resolveArtistSeedByName(name) { + const norm = (name || "").trim().toLowerCase(); + if (!norm) return Promise.resolve(null); + const cached = artistSeedCache.has(norm) ? touchCache(artistSeedCache, norm) : null; + if (cached) return Promise.resolve(cached); + // A remembered miss stays a miss — the negative is cached as a null id. + if (artistIdCache.has(norm) && touchCache(artistIdCache, norm) == null) { + return Promise.resolve(null); + } + const inflight = artistSeedRequests.get(norm); + if (inflight) return inflight; + const promise = (async () => { + try { + const res = await api.catalogSearch(name); + const artists = res.artists ?? []; + // Same rule as openArtistByName: the most-followed exact match, so + // a shared name resolves to the canonical artist rather than an + // obscure namesake the search happened to list first. + const exact = artists.filter((a) => a.name.trim().toLowerCase() === norm); + const pool = exact.length ? exact : artists; + const best = pool.reduce( + (top, artist) => ((artist.fans ?? 0) > (top?.fans ?? -1) ? artist : top), + null, + ); + if (!best) { + setBoundedMap(artistIdCache, norm, null, ARTIST_IDENTITY_CACHE_MAX); + return null; + } + return rememberArtistSeed(best); + } catch { + // Transport failures stay retryable — never negative-cached. + return null; + } finally { + artistSeedRequests.delete(norm); + } + })(); + artistSeedRequests.set(norm, promise); + return promise; +} + export async function openArtistByName(name) { const norm = (name || "").trim().toLowerCase(); if (!norm) return; diff --git a/src/views/CatalogAlbum.svelte b/src/views/CatalogAlbum.svelte index 06b0147..e51a947 100644 --- a/src/views/CatalogAlbum.svelte +++ b/src/views/CatalogAlbum.svelte @@ -13,10 +13,20 @@ coverUpgrades, resolveCovers, coverLoadError, + getCatalogArt, + resolveArtistSeedByName, + getCatalogArtistPage, + openCatalogAlbum, + toggleShuffle, } from "../lib/state.svelte.js"; + import { openPreview } from "../lib/preview/previewSession.svelte.js"; + import { buildAlbumManifest } from "../lib/preview/manifest.js"; + import * as api from "../lib/api.js"; + import { accentFromImage } from "../lib/color.js"; + import { editionLabel, releaseFamilies } from "../lib/editions.js"; import Icon from "../components/Icon.svelte"; - const page = $derived(catalog.albumPage); // { album, tracks } + const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … } const album = $derived(page?.album ?? null); // Upgrade this album's cover to the highest verified-same-artwork source. @@ -28,6 +38,57 @@ } }); + const cover = $derived(album?.cover ? (coverUpgrades[album.cover] ?? album.cover) : null); + const coverOk = $derived(Boolean(cover) && !artBroken[album?.cover]); + + // The record's own palette, extracted from its cover exactly as the artist + // page does for the open expander — the page's accent is this album's, not + // the playing track's. + let accent = $state(null); // { accent, hover } | null + $effect(() => { + const source = cover; + if (!source || !coverOk) { + accent = null; + return; + } + let stale = false; + (async () => { + try { + const dataUrl = await getCatalogArt(source); + const colors = dataUrl ? await accentFromImage(dataUrl) : null; + if (!stale) accent = colors; + } catch { + if (!stale) accent = null; + } + })(); + return () => { + stale = true; + }; + }); + const accentStyle = $derived( + accent ? `--accent: ${accent.accent}; --accent-hover: ${accent.hover};` : undefined, + ); + + /** The artist's own portrait for the byline. The album payload carries the + * artist's NAME and nothing else, so this resolves the identity — through + * the same cache the back button uses, which usually makes it free. */ + let artistSeed = $state(null); + let seedFor = null; + $effect(() => { + const name = album?.artist ?? ""; + if (!name || name === seedFor) return; + seedFor = name; + artistSeed = null; + let stale = false; + resolveArtistSeedByName(name).then((seed) => { + if (!stale && seedFor === name) artistSeed = seed; + }); + return () => { + stale = true; + }; + }); + const artistPicture = $derived(artistSeed?.picture ?? null); + const nowPlaying = (kind, id) => player.status !== "idle" && player.catalogRef[kind] === id; @@ -43,11 +104,37 @@ const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, ""); - const inLibrary = $derived.by(() => { - if (!album) return false; + /** This record's row in the local library, when it is owned. */ + const localAlbum = $derived.by(() => { + if (!album) return null; const a = norm(album.artist); const t = norm(album.title); - return libraryStore.albums.some((x) => norm(x.artist) === a && norm(x.title) === t); + return libraryStore.albums.find((x) => norm(x.artist) === a && norm(x.title) === t) ?? null; + }); + const inLibrary = $derived(Boolean(localAlbum)); + + /** Tracks of this record already saved locally, ticked in the list. The + * library keeps albums, not songs, so the owned tracklist has to be read + * from the matching local album rather than filtered out of a song list. */ + let libraryTicks = $state(new Set()); + let ticksFor = null; + $effect(() => { + const id = localAlbum?.id ?? null; + if (id === ticksFor) return; + ticksFor = id; + libraryTicks = new Set(); + if (id == null) return; + let stale = false; + api.albumTracks(id) + .then((rows) => { + if (!stale && ticksFor === id) { + libraryTicks = new Set(rows.map((r) => norm(r.title || ""))); + } + }) + .catch(() => {}); + return () => { + stale = true; + }; }); // An upcoming album that still has unreleased songs can't be streamed or @@ -60,6 +147,257 @@ new Set((page?.tracks ?? []).map((t) => t.disk_number ?? 1)).size > 1, ); + // ---- headline figures ------------------------------------------------- + const tracks = $derived(page?.tracks ?? []); + const runtime = $derived.by(() => { + const secs = tracks.reduce((sum, t) => sum + (t.duration_secs ?? 0), 0); + if (!secs) return ""; + const mins = Math.round(secs / 60); + return mins >= 60 + ? `${Math.floor(mins / 60)} hr ${mins % 60} min` + : `${mins} min`; + }); + /** Headline totals abbreviate; same rule as the artist page's rail. */ + const fmtCompact = (n) => { + if (n == null) return null; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`; + return String(n); + }; + const albumPlays = $derived(fmtCompact(page?.album_plays ?? null)); + const albumListeners = $derived(fmtCompact(page?.album_listeners ?? null)); + const totalsSummed = $derived(Boolean(page?.totals_summed)); + + // The album's own most-played track is the yardstick. A bar is a + // comparison, so a one- or two-track release gets the plain list instead: + // the longest bar there is only "the bigger of these two", which reads as + // a rating it is not. + const playsPeak = $derived(tracks.reduce((peak, t) => Math.max(peak, t.plays ?? 0), 0)); + const showPlays = $derived(playsPeak > 0 && tracks.length >= 3); + const playShare = (t) => + !t.plays || !playsPeak ? 0 : Math.max(2, Math.round((t.plays / playsPeak) * 100)); + const playHeat = (t) => (!t.plays || !playsPeak ? 0 : t.plays / playsPeak).toFixed(3); + /** Unknown is a dash, never a zero — same rule as the artist page. */ + const fmtPlays = (plays) => (plays == null ? "—" : plays.toLocaleString()); + + // ---- editions --------------------------------------------------------- + // The album payload has no siblings, so the family is rebuilt from the + // artist's own release list with the SAME grouping the artist page uses + // (src/lib/editions.js) — a second implementation here would quietly + // disagree with the discography about which pressings are one record. + // + // One deliberate narrowing: the artist page buckets releases by category + // before grouping, and that categorisation depends on page state this view + // does not load (harvested description hints, MusicBrainz type hints). So a + // sibling is accepted only when the backend LINKED it (`edition_group`, a + // verified ordered core-track match) or it declares the same record type. + // That can only ever show fewer editions than the discography does, never + // an edition that is really a separate live album or compilation. + let siblings = $state([]); + let siblingsFor = null; + $effect(() => { + const artistId = artistSeed?.id ?? null; + const albumId = album?.id ?? null; + const key = artistId != null && albumId != null ? `${artistId}:${albumId}` : null; + if (!key || key === siblingsFor) return; + siblingsFor = key; + siblings = []; + let stale = false; + // The FULL discography, not the overview: the overview is the artist + // page's first-paint subset and both omits later pressings and carries + // no track counts, so an anniversary edition went missing and the rows + // read "1994" where the artist page reads "1994 · 50 tracks". Cached + // per artist, and already warm whenever this page was reached from + // theirs. + getCatalogArtistPage(artistId) + .then((full) => { + if (stale || siblingsFor !== key) return; + siblings = full?.albums ?? []; + }) + .catch(() => {}); + return () => { + stale = true; + }; + }); + + const editions = $derived.by(() => { + if (!album || !siblings.length) return []; + const type = (album.record_type ?? "album").toLowerCase(); + const linked = (other) => + album.edition_group != null && other.edition_group === album.edition_group; + const pool = siblings.filter( + (other) => + other.id === album.id + || linked(other) + || (other.record_type ?? "album").toLowerCase() === type, + ); + // The album itself may be absent from the overview (a deluxe reached by + // a direct link); seed it so it is always in its own family. + if (!pool.some((other) => other.id === album.id)) pool.push(album); + const family = releaseFamilies(pool, type).find((f) => + f.editions.some((e) => e.id === album.id), + ); + if (!family || family.editions.length < 2) return []; + return family.editions.map((edition) => ({ + ...edition, + label: editionLabel(edition, family), + on: edition.id === album.id, + })); + }); + + const editionSub = (edition) => { + const y = year(edition); + const n = edition.track_count; + return [y, n ? `${n} ${n === 1 ? "track" : "tracks"}` : null].filter(Boolean).join(" · "); + }; + + // ---- shuffle ----------------------------------------------------------- + /** Play the record with shuffle on. Not a toggle: the button is an action + * on THIS album, so it always turns shuffle on rather than flipping a + * global that would silently start the record in order. */ + function shufflePlay() { + if (hasUnreleased) return; + if (!player.shuffle) toggleShuffle(); + streamCatalogAlbum(album); + } + + // ---- previews ---------------------------------------------------------- + // 30-second clips for the tracklist, through the same modal the artist + // page opens — `buildAlbumManifest` already existed for this surface. + const previewable = $derived(tracks.filter((t) => t.preview).length); + const previewLabel = $derived( + previewable === 0 + ? "No previews available for this record" + : previewable < tracks.length + ? `Preview tracks — ${previewable} of ${tracks.length} available` + : "Preview tracks", + ); + function openPreviews() { + if (!previewable || !album) return; + // A snapshot: an edition swap underneath cannot rewrite an open + // session, so the modal keeps showing the record it was opened on. + const snapshot = tracks; + const byId = new Map(snapshot.map((t) => [String(t.id), t])); + const manifest = buildAlbumManifest({ + album, + tracks: snapshot, + createdAtMs: Date.now(), + }); + void openPreview(manifest, { + playFull: (item) => { + const track = byId.get(item.sources[0]?.providerTrackId); + if (track) playTrack(track); + }, + }); + } + + // ---- edition swap ------------------------------------------------------ + // Switching edition is a swap of one record's pressing, not a navigation, + // so the page is held (openCatalogAlbum `keepCurrent`) and the parts are + // re-dealt on top of it. Same clock, easing and lift as the artist page's + // expander so the two read as one gesture. + const SWAP_MS = 400; + const SWAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; // quintOut + // In the order they arrive. The blurred banner does NOT lift — sliding a + // 42px-blurred wash reads as a wobble rather than a reveal. + const SWAP_PARTS = [ + { sel: ".banner", delay: 0, lift: false }, + { sel: ".cover", delay: 0, lift: true }, + { sel: ".kind", delay: 40, lift: true }, + { sel: "h1", delay: 60, lift: true }, + { sel: ".meta", delay: 85, lift: true }, + { sel: ".stats", delay: 105, lift: true }, + { sel: ".actions", delay: 125, lift: true }, + { sel: ".tracklist", delay: 110, lift: true }, + { sel: ".about, .more", delay: 145, lift: true }, + { sel: ".eds", delay: 165, lift: true }, + { sel: ".credits", delay: 180, lift: true }, + ]; + const reducedMotion = () => + typeof matchMedia === "function" + && matchMedia("(prefers-reduced-motion: reduce)").matches; + + let pageEl = $state(null); + function dealParts() { + if (!pageEl || reducedMotion()) return; + for (const { sel, delay, lift } of SWAP_PARTS) { + for (const el of pageEl.querySelectorAll(sel)) { + // To the element's OWN resting opacity, never to 1: the banner + // rests at 0.62, and animating it to 1 would run the colour + // wash at half again its brightness for the whole swap and then + // drop it the instant the animation released. + 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, easing: SWAP_EASE, fill: "backwards" }, + ); + } + } + } + + /** Preload the incoming cover before the swap, so the artwork crossfades + * instead of blinking out while the next file decodes. Bounded: a slow CDN + * must not hold the click. */ + function preloadCover(url) { + if (!url) return Promise.resolve(); + return Promise.race([ + new Promise((resolve) => { + const img = new Image(); + img.onload = () => (img.decode ? img.decode().then(resolve, resolve) : resolve()); + img.onerror = resolve; + img.src = url; + }), + new Promise((resolve) => setTimeout(resolve, 400)), + ]); + } + + let swapping = $state(false); + async function switchEdition(edition) { + if (edition.on || swapping) return; + swapping = true; + try { + await preloadCover(coverUpgrades[edition.cover] ?? edition.cover); + await openCatalogAlbum(edition.id, { keepCurrent: true }); + } finally { + swapping = false; + } + } + + // Re-deal whenever the record on screen changes — an edition swap, or an + // arrival from anywhere else. + let dealtFor = null; + $effect(() => { + const id = album?.id ?? null; + if (id == null || id === dealtFor) return; + dealtFor = id; + requestAnimationFrame(dealParts); + }); + + // ---- editorial description ------------------------------------------- + let info = $state(null); + let descExpanded = $state(false); + let infoKey = null; + $effect(() => { + const key = album ? `${album.artist}:${album.title}` : null; + if (!key || key === infoKey) return; + infoKey = key; + info = null; + descExpanded = false; + const { artist, title } = album; + let stale = false; + api.catalogAlbumInfo(null, artist, title) + .then((got) => { + if (!stale && infoKey === key) info = got; + }) + .catch(() => {}); + return () => { + stale = true; + }; + }); + let saved = $state({}); function flagSaveError(key) { saved[key] = "err"; @@ -90,10 +428,43 @@ function year(a) { return a?.release_date?.slice(0, 4) ?? ""; } + function releaseDate(a) { + if (!a?.release_date) return ""; + const d = new Date(`${a.release_date}T00:00:00`); + return Number.isNaN(d.getTime()) + ? a.release_date + : d.toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" }); + } function fmtDuration(s) { if (s == null) return ""; return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; } + const nTracks = $derived( + tracks.length ? `${tracks.length} ${tracks.length === 1 ? "track" : "tracks"}` : "", + ); + + // ---- the glass bar's title takeover ----------------------------------- + // The record's name rides in the bar once its own title has gone under it, + // so the bar always says where you are. An observer against the scroll + // pane — no per-frame work, and nothing to keep in sync with a scroll + // position. + // + // It watches the TITLE, not the whole identity block: the bar is standing + // in for the title, so it should arrive as the title leaves. Watching the + // block meant waiting for the artwork to clear too, which on a ten-track + // record is further than the page can even scroll. + let titlePassed = $state(false); + function watchTitle(node) { + const root = node.closest(".content"); + if (!root) return; + const io = new IntersectionObserver( + ([entry]) => (titlePassed = !entry.isIntersecting), + // The bar's stuck bottom sits 22px into the pane's content box. + { root, rootMargin: "-22px 0px 0px 0px", threshold: 0 }, + ); + io.observe(node); + return { destroy: () => io.disconnect() }; + } // Scroll to (and briefly flash) the track the now-playing song link targets. let flashId = $state(null); @@ -105,91 +476,281 @@ if (el) el.scrollIntoView({ block: "center", behavior: "smooth" }); }); flashId = tid; - const t = setTimeout(() => (flashId === tid) && (flashId = null), 1800); + const t = setTimeout(() => flashId === tid && (flashId = null), 1800); return () => clearTimeout(t); }); -
- - - {#if resolver.active} -

● {resolver.label} — finding the best source…

- {:else if resolver.error} -

{resolver.error}

- {/if} +
+ +
+ + {#if album} +
+ {#if coverOk} + + {/if} + {album.title} + +
+ {/if} + {#if resolver.active} + ● {resolver.label} — finding the best source… + {:else if resolver.error} + {resolver.error} + {/if} +
{#if catalog.albumLoading}

Loading album…

{:else if !page}

{catalog.error || "Album not found."}

{:else} +
- {#if album.cover && !artBroken[album.cover]} - handleCoverError(event.currentTarget)} /> - {:else} -
- {/if} -
-

{(album.record_type ?? "album").toUpperCase()}

-

{album.title}

-

- - {#if year(album)}· {year(album)}{/if} - {#if album.track_count}· {album.track_count} tracks{/if} - {#if inLibrary}· in your library{/if} -

-
- - + +
+ {#if coverOk} + handleCoverError(event.currentTarget)} + /> + {:else} +
+ {/if} +
+ {(album.record_type ?? "album").toUpperCase()} +

22} use:watchTitle>{album.title}

+

+ + {#if year(album)}· {year(album)}{/if} + {#if nTracks}· {nTracks}{/if} + {#if runtime}· {runtime}{/if} + {#if inLibrary}· in your library{/if} +

+ {#if albumPlays || albumListeners} +
+ {#if albumPlays} + {albumPlays}plays + {/if} + {#if albumPlays && albumListeners}{/if} + {#if albumListeners} + {albumListeners}listeners + {/if} + {#if totalsSummed}summed{/if} +
+ {/if} +
+ + + + +
+ {#if editions.length} +
+ Editions +
+ {#each editions as edition (edition.id)} + + {/each} +
+
+ {/if} + + {#if info?.description} +

{info.description}

+ + {/if} +
- {#each page.tracks as t, i (t.id)} - {#if multiDisc && t.disk_number !== (page.tracks[i - 1]?.disk_number ?? (i === 0 ? null : 1))} + {#if tracks.length} +
+ # + Title + {#if showPlays}Plays{/if} + +
+ {/if} + {#each tracks as t, i (t.id)} + {#if multiDisc && t.disk_number !== (tracks[i - 1]?.disk_number ?? (i === 0 ? null : 1))}
Disc {t.disk_number ?? 1}
{/if} {@const locked = t.unreleased} - {@const play = () => { if (!locked) playTrack(t); }} + {@const play = () => { + if (!locked) playTrack(t); + }}
e.key === "Enter" && play()} > {nowPlaying("trackId", t.id) ? "♫" : (t.track_no ?? "·")} - {t.title} + + {t.title} + {#if libraryTicks.has(norm(t.title))} + + {/if} + + {#if showPlays} + + + {fmtPlays(t.plays)} + + {/if} {#if locked} Unreleased {:else} {fmtDuration(t.duration_secs)} {/if} - - + + + +
{/each}
+ + {#if releaseDate(album) || info?.label || info?.genre} +
+ Released + {#if releaseDate(album)}{releaseDate(album)}{/if} + {#if nTracks}· {nTracks}{/if} + {#if runtime}· {runtime}{/if} + {#if info?.label || info?.genre} +
{info.label ?? ""}{info.label && info.genre ? " · " : ""}{info.genre ?? ""} + {/if} +
+ {/if} {/if}
@@ -200,181 +761,740 @@ text-align: center; } - .back { + /* ------------------------------------------------------ glass bar ---- */ + /* The artist page's .results-head, unchanged: sticky at the top of the + pane, bled -24/-28 so it spans the pane's full width and takes NO flow + height (-24 + 46 - 22 = 0), a translucent tint over a 12px backdrop + blur with a hairline light edge. The two headers must read as one + object, so any change here belongs in both. */ + .glassbar { + position: sticky; + top: -24px; + margin: -24px -28px -22px; + z-index: 40; + height: 46px; + display: flex; + align-items: center; + gap: 12px; + padding: 0 28px; + background: rgba(20, 20, 24, 0.28); + backdrop-filter: blur(12px) saturate(1.7); + -webkit-backdrop-filter: blur(12px) saturate(1.7); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05); + } + + .glassbar .back { display: inline-flex; align-items: center; - gap: 4px; - color: var(--text-secondary); + gap: 5px; + color: #fff; + font-size: 13px; font-weight: 600; - margin-bottom: 16px; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); + flex: none; + } + .glassbar .back:hover { + color: var(--text-secondary); } - .back:hover { color: var(--text-primary); } + /* The record's name takes over the bar once its own title has gone under + it. Hidden — not just transparent — while the title is on screen, so it + never eats clicks meant for the back button. */ + .where { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + opacity: 0; + transform: translateY(6px); + visibility: hidden; + transition: + opacity 180ms ease, + transform 180ms ease, + visibility 0s linear 180ms; + } + .glassbar.passed .where { + opacity: 1; + transform: none; + visibility: visible; + transition-delay: 0s; + } + .where img { + width: 22px; + height: 22px; + border-radius: 3px; + object-fit: cover; + flex: none; + } + .where span { + font-size: 13px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); + } + .mini-play { + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 50%; + background: var(--accent); + color: #08080a; + flex: none; + } + .mini-play:hover { + background: var(--accent-hover); + } + .mini-play:disabled { + background: var(--bg-highlight); + color: var(--text-muted); + cursor: default; + } + + /* Resolver status rides the right side, as on the artist page. */ + .rstatus { + margin-left: auto; + max-width: 45%; + font-size: 12px; + color: rgba(255, 255, 255, 0.85); + text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .rstatus .pulse { + color: var(--accent); + } + .rstatus.error { + color: #ff8a94; + } + + /* ---------------------------------------------------- hero banner ---- */ .hero { + /* The one number to tune. 1 = a 240px cover; everything in the hero is + sized against it, so the block scales as one piece. */ + --cover-scale: 1.175; /* 282px */ + --cover: calc(240px * var(--cover-scale)); + position: relative; + margin: 0 -28px; + padding: 0 28px; + /* The identity block is bottom-anchored, so the hero's height IS the + gap above it: 22px of it is under the glass bar's stuck bottom, the + block itself is --cover + 22px of foot, and the remaining 24px is + the breathing room approved on the mock. Deriving it keeps that 24px + exact at any artwork size — the two used to be independent numbers + that drifted the moment either moved. */ + min-height: calc(var(--cover) + 68px); display: flex; align-items: flex-end; - gap: 20px; - background: var(--bg-elevated); - border-radius: 10px; - padding: 20px; + isolation: isolate; + } + + /* A blurred copy of the cover, bled up behind the glass bar so the bar has + something to frost — the same ambience recipe as the expander's rail, + dissolving into the page background at its foot. */ + .banner { + position: absolute; + inset: -70px 0 0 0; + z-index: -2; + overflow: hidden; + } + .banner img { + width: 100%; + height: 100%; + object-fit: cover; + filter: blur(42px) saturate(1.45); + transform: scale(1.25); + opacity: 0.62; + } + .banner::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 180deg, + rgba(10, 10, 12, 0.5) 0%, + rgba(14, 14, 16, 0.42) 42%, + var(--bg-base) 100% + ); + } + + /* `stretch`, not `flex-end`: the copy column is sized BY the artwork, so + its first line starts at the cover's top edge and its last row ends at + the bottom one — at every scale. If a long title wraps past the cover's + height the block grows rather than clipping. */ + .identity { + display: flex; + align-items: stretch; + gap: calc(22px * var(--cover-scale)); + /* Fixed: the hero's derived height counts on this foot. */ + padding-bottom: 22px; + width: 100%; } .cover { - width: 150px; - height: 150px; - border-radius: 8px; + width: var(--cover); + height: var(--cover); + border-radius: 10px; object-fit: cover; - flex-shrink: 0; - box-shadow: 0 12px 28px -12px rgba(0, 0, 0, 0.7); + flex: none; + box-shadow: 0 22px 46px -18px rgba(0, 0, 0, 0.8); } - .placeholder { + .cover.placeholder { display: grid; place-items: center; background: linear-gradient(135deg, #2a2a2a, #3a3a3a); color: var(--text-muted); } - .hero-info { min-width: 0; } + /* The scaling root. Every length below is `em`, so type, gaps, avatar, + pills and dividers all scale by the SAME factor — no rule moves at its + own rate. 1.686em is what the five rows need to fill the cover exactly, + and being an em it keeps that fit at any scale. */ + .copy { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + font-size: calc(14px * var(--cover-scale)); + gap: 1.8em; + } + .kind { - font-size: 10px; - font-weight: 700; - letter-spacing: 0.08em; - color: var(--text-secondary); - } - h1 { - font-size: 30px; + font-size: 0.714em; font-weight: 800; - margin: 4px 0 6px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.72); + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45); } + + h1 { + font-size: 3em; + font-weight: 800; + line-height: 1.06; + letter-spacing: -0.015em; + text-shadow: 0 2px 14px rgba(0, 0, 0, 0.45); + } + h1.long { + font-size: 2.286em; + } + .meta { - color: var(--text-secondary); - font-size: 12px; + display: flex; + align-items: center; + gap: 0.5em; + flex-wrap: wrap; + font-size: 0.893em; + color: rgba(255, 255, 255, 0.82); + text-shadow: 0 1px 5px rgba(0, 0, 0, 0.5); } - .artist-link { - color: var(--text-primary); - font-weight: 600; + .meta .dot { + opacity: 0.5; + } + .who { + display: inline-flex; + align-items: center; + gap: 7px; + font-weight: 800; + color: #fff; + } + .who img, + .who-dot { + width: 1.76em; + height: 1.76em; + border-radius: 50%; + object-fit: cover; + flex: none; + } + /* Held until the portrait resolves, so the byline never reflows and never + stands in a different picture for the artist. */ + .who-dot { + display: grid; + place-items: center; + background: rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.5); + } + .who:hover { + text-decoration: underline; + } + .owned { + color: var(--accent-hover); + } + + /* Album totals — the artist page's .rail-stats, numerals in the accent. */ + .stats { + display: flex; + align-items: center; + gap: 0.857em; + flex-wrap: wrap; + } + .stat { + display: flex; + flex-direction: column; + line-height: 1.12; + } + .stat b { + font-size: 1.143em; + font-weight: 800; + color: var(--accent-hover); + font-variant-numeric: tabular-nums; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); + } + .stat i { + font-style: normal; + font-size: 0.714em; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + font-weight: 700; + margin-top: 0.2em; + } + .stat-div { + width: 1px; + height: 1.571em; + background: rgba(255, 255, 255, 0.12); + } + /* An approximation is labelled rather than passed off as Last.fm's figure. */ + .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; } - .artist-link:hover { text-decoration: underline; } - .owned { color: var(--accent-hover); } .actions { display: flex; - gap: 8px; - margin-top: 14px; + align-items: center; + gap: 0.714em; + } + .iconbtn { + position: relative; + display: grid; + place-items: center; + width: 2.429em; + height: 2.429em; + border-radius: 50%; + color: rgba(255, 255, 255, 0.85); + } + .iconbtn:hover:not(:disabled) { + color: #fff; + background: rgba(255, 255, 255, 0.1); + } + /* Shuffle stays lit while it is on, so the state it left behind on the + player is visible from here rather than being a silent global. */ + .iconbtn.on { + color: var(--accent-hover); + } + .iconbtn:disabled { + color: rgba(255, 255, 255, 0.28); + cursor: default; + } + /* Count for a record only some of whose tracks have a clip — the same + partial-availability report the artist page makes. */ + .badge { + position: absolute; + right: -0.1em; + bottom: -0.1em; + min-width: 1.15em; + padding: 0 0.25em; + border-radius: 999px; + background: var(--accent); + color: #08080a; + font-size: 0.6em; + font-weight: 800; + line-height: 1.35; + text-align: center; } .pill { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; background: var(--accent); - color: #000; - font-size: 11px; - font-weight: 700; - padding: 7px 16px; - border-radius: 14px; + color: #08080a; + font-size: 0.786em; + font-weight: 800; + padding: 0.571em 1.286em; + border-radius: 999px; white-space: nowrap; } - .pill:hover { background: var(--accent-hover); } + .pill:hover { + background: var(--accent-hover); + } .pill.ghost { - background: var(--bg-highlight); + background: rgba(255, 255, 255, 0.1); color: var(--text-primary); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + } + .pill.ghost:hover { + background: rgba(255, 255, 255, 0.16); } - .pill.ghost:hover { background: var(--bg-press, #333); } .pill:disabled { background: var(--bg-highlight); color: var(--text-muted); cursor: default; opacity: 0.6; } - .pill:disabled:hover { background: var(--bg-highlight); } - - .tracklist { - margin-top: 18px; - display: flex; - flex-direction: column; + .pill:disabled:hover { + background: var(--bg-highlight); } - .disc-head { + /* ----------------------------------------------------- editions ---- */ + /* The artist page's rail cards, unchanged: 216px wide, 32px art, name over + "year · n tracks", accent tint and accent border on the current one. + Only the flow direction differs — the rail stacks them because it is + 250px wide, and this page has the width to lay them side by side. */ + .eds { + margin: 20px 0 16px; + } + .eds .lab { + display: block; font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; - color: var(--text-secondary); - padding: 14px 8px 6px; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - margin-bottom: 4px; + color: var(--text-muted); + margin-bottom: 6px; } - .disc-head:first-child { padding-top: 4px; } - - .trow { + .edrow { + display: flex; + flex-wrap: wrap; + gap: 4px; + } + .ed { display: flex; align-items: center; - gap: 12px; - padding: 8px 8px; - border-radius: 5px; - cursor: pointer; + gap: 10px; + width: 216px; + padding: 7px 8px; + border-radius: 6px; + border: 1px solid transparent; + text-align: left; } - .trow:hover { background: var(--bg-highlight); } - .trow.now .ttitle, - .trow.now .tno { color: var(--accent-hover); } - .trow.locked { cursor: default; } - .trow.locked:hover { background: transparent; } - .trow.locked .ttitle { color: var(--text-muted); } - .tag-unreleased { - font-size: 10px; - font-weight: 600; - letter-spacing: 0.02em; - color: var(--text-muted); - border: 1px solid var(--border, #3a3a3a); - border-radius: 4px; - padding: 1px 6px; - flex: 0 0 auto; + .ed:hover { + background: rgba(255, 255, 255, 0.06); } - .mini-btn:disabled, - .mini-get:disabled { - color: var(--text-muted); - opacity: 0.4; + .ed.on { + background: color-mix(in srgb, var(--accent) 15%, transparent); + border-color: var(--accent); cursor: default; } - .mini-btn:disabled:hover, - .mini-get:disabled:hover { color: var(--text-muted); } - .trow.flash { animation: flash 1.8s ease; } + .ed-art { + width: 32px; + height: 32px; + border-radius: 4px; + object-fit: cover; + flex: none; + } + .ed-art.placeholder { + display: grid; + place-items: center; + background: var(--bg-highlight); + color: var(--text-muted); + } + .ed-copy { + min-width: 0; + flex: 1; + } + .ed-copy .n { + display: block; + font-size: 12.5px; + font-weight: 700; + line-height: 1.2; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .ed-copy .y { + display: block; + margin-top: 1px; + font-size: 11.5px; + color: var(--text-muted); + } + + /* --------------------------------------------------- description ---- */ + .about { + margin: 20px 0 0; + max-width: 820px; + font-size: 12.5px; + line-height: 1.62; + color: var(--text-secondary); + } + .about.clamped { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + .more { + display: inline-flex; + align-items: center; + gap: 5px; + margin-top: 4px; + font-size: 12px; + font-weight: 700; + color: var(--text-secondary); + } + .more:hover { + color: var(--text-primary); + } + .more :global(svg) { + transition: transform 200ms ease; + } + .more.open :global(svg) { + transform: rotate(180deg); + } + + /* ---------------------------------------------------- track list ---- */ + .tracklist { + margin-top: 20px; + display: flex; + flex-direction: column; + } + + /* Header and rows share one template, so the columns line up whether or + not Last.fm knew this record. */ + .tcols, + .trow { + display: grid; + grid-template-columns: 26px minmax(0, 1fr) 48px; + align-items: center; + gap: 12px; + } + .tcols.has-plays, + .trow.has-plays { + grid-template-columns: 26px minmax(0, 1fr) 168px 48px; + } + + .tcols { + 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; + } + + .disc-head { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 10px 6px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); + } + .disc-head::after { + content: ""; + flex: 1; + height: 1px; + background: rgba(255, 255, 255, 0.07); + } + + .trow { + position: relative; + padding: 7px 10px; + border-radius: 6px; + font-size: 13.5px; + cursor: pointer; + } + .trow:hover { + background: var(--bg-highlight); + } + .trow.now .ttitle, + .trow.now .tno { + color: var(--accent-hover); + } + .trow.locked { + cursor: default; + } + .trow.locked:hover { + background: transparent; + } + .trow.locked .ttitle { + color: var(--text-muted); + } + .trow.flash { + animation: flash 1.8s ease; + } @keyframes flash { - 0%, 30% { background: color-mix(in srgb, var(--accent) 22%, transparent); } - 100% { background: transparent; } + 0%, + 30% { + background: color-mix(in srgb, var(--accent) 22%, transparent); + } + 100% { + background: transparent; + } } @media (prefers-reduced-motion: reduce) { - .trow.flash { animation: none; } + .trow.flash { + animation: none; + } + .more :global(svg) { + transition-duration: 1ms; + } } .tno { - width: 22px; - text-align: right; + width: 26px; + text-align: center; color: var(--text-muted); font-size: 12px; - flex: 0 0 auto; + font-variant-numeric: tabular-nums; } .ttitle { - flex: 1; min-width: 0; - font-size: 13.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .tdur { - color: var(--text-muted); - font-size: 11px; - flex: 0 0 auto; + .tick { + display: inline-block; + vertical-align: -1px; + color: var(--accent); + margin-left: 6px; } - .mini-btn.play { color: var(--accent-hover); } - .mini-btn, - .mini-get { color: var(--text-secondary); flex: 0 0 auto; } - .mini-btn:hover, - .mini-get:hover { color: var(--accent-hover); } - .resolving { + /* 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. */ + .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)); + } + + .tdur { + text-align: right; + color: var(--text-muted); + font-size: 11.5px; + font-variant-numeric: tabular-nums; + } + .trow:hover .tdur, + .trow:focus-within .tdur { + opacity: 0; + } + .trow.locked:hover .tdur { + opacity: 1; + } + + .tag-unreleased { + justify-self: end; + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + border: 1px solid var(--border, #3a3a3a); + border-radius: 4px; + padding: 1px 6px; + white-space: nowrap; + } + + /* The hover reveal slides the play/save buttons over the right end of the + row, so the duration gets out of the way — the play count stays put. */ + .rowbtns { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: none; + gap: 2px; + } + .trow:hover .rowbtns, + .trow:focus-within .rowbtns { + display: flex; + } + .trow.locked:hover .rowbtns { + display: none; + } + .rowbtns button { + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 50%; color: var(--text-secondary); - font-size: 12px; - margin-bottom: 12px; + } + .rowbtns button:hover { + color: var(--accent-hover); + background: rgba(255, 255, 255, 0.08); + } + .rowbtns button:disabled { + color: var(--text-muted); + opacity: 0.4; + cursor: default; + } + .rowbtns .play { + color: var(--accent-hover); + } + + /* -------------------------------------------------------- credits ---- */ + .credits { + margin-top: 26px; + padding: 0 10px; + color: var(--text-muted); + font-size: 11.5px; + line-height: 1.7; + } + .credits .lab { + display: block; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #5e5e5e; + margin-bottom: 4px; + } + + @media (max-width: 820px) { + .hero { + --cover-scale: 0.62; + } + .identity { + gap: 16px; + } + h1 { + font-size: 32px; + } + h1.long { + font-size: 26px; + } } - .resolving .pulse { color: var(--accent); } - .resolving.error { color: #f15e6c; } diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index 0fdbcb9..81e3f97 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -31,6 +31,12 @@ } from "../lib/state.svelte.js"; import * as api from "../lib/api.js"; import { accentFromImage } from "../lib/color.js"; + import { + editionBaseTitle, + editionLabel, + isEdition, + releaseFamilies, + } from "../lib/editions.js"; import { buildArtistManifest } from "../lib/preview/manifest.js"; import { openPreview } from "../lib/preview/previewSession.svelte.js"; import { traceToSvg, svgDataUrl } from "../lib/trace.js"; @@ -2071,80 +2077,9 @@ const isUpcomingAlbum = (a) => a.record_type?.toLowerCase() === "album" && isFutureOrUnresolvedDate(a.release_date); - const EDITION_MARKER = /\b(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|legacy\s+edition|special\s+edition|collector'?s\s+edition|bonus\s+tracks?|complete\s+edition|definitive\s+edition|reissue|redux|mono\s+edition|stereo\s+edition|\w+(?:['’]s)?\s+edition|half[-\s]speed|track\s+by\s+track)\b/i; - const EDITION_SUFFIX = /\s*(?:[-–—:]\s*)?(?:the\s+)?(?:(?:\d{4}|\d+(?:st|nd|rd|th))\s+)?(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|legacy\s+edition|special\s+edition|collector'?s\s+edition|bonus\s+tracks?|complete\s+edition|definitive\s+edition|reissue|redux|mono\s+edition|stereo\s+edition|\w+(?:['’]s)?\s+edition|half[-\s]speed|track\s+by\s+track)(?:\s+(?:edition|version))?.*$/i; - - // A bracketed tail naming an alternate cut of the same work — "(Acoustic - // Version)", "(2010 Mono Version)", "(Urban Mixes)", "(Sampler)". These - // fold into the base release's family. "'s Version" is exempt: a - // re-recorded catalogue (Taylor's Version) is its own work and must keep - // its own card. - const VARIANT_SEGMENT = /\b(?:versions?|mix(?:es)?|edits?|acoustic|instrumentals?|sampler|extras|mono|stereo|demos?|extended|live)\b/i; - const OWN_WORK_SEGMENT = /^\S+['’]s\s+version$/i; - - const isEdition = (a) => EDITION_MARKER.test(a.title) - || /[\[(][^\])]*\bedition\b[^\])]*[\])]/i.test(a.title); - - function editionBaseTitle(title) { - // Keep a useful qualifier that shares an edition bracket. For example, - // "Weezer (Deluxe Edition / Red Album)" belongs to "Weezer (Red - // Album)", not to every self-titled Weezer record. - const bracketSafe = title.replace(/\s*[\[(]([^\])]+)[\])]/g, (whole, inner) => { - const variantInner = VARIANT_SEGMENT.test(inner) && !OWN_WORK_SEGMENT.test(inner.trim()); - if (!EDITION_MARKER.test(inner) && !/\bedition\b/i.test(inner) && !variantInner) return whole; - const qualifiers = inner - .split(/\s*[/|;]\s*/) - // A segment can carry a real qualifier AND a marker tail — - // "1973-1976 2011 Remaster" must keep "1973-1976" so the - // remaster lands in the same family as "Deep Cuts - // (1973-1976)". Strip the marker tail first, then judge. - .map((part) => part - .replace(/\s*(?:\d{4}\s+)?(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|reissue|redux)(?:\s+(?:edition|version))?\s*$/i, "") - .trim()) - .filter((part) => part - && !EDITION_MARKER.test(part) - && !/\bedition\b/i.test(part) - && !(VARIANT_SEGMENT.test(part) && !OWN_WORK_SEGMENT.test(part))); - return qualifiers.length ? ` (${qualifiers.join(" / ")})` : ""; - }); - const stripped = bracketSafe - .replace(EDITION_SUFFIX, "") - .replace(/\s+/g, " ") - .trim() - .replace(/\s*[-–—:]\s*$/, ""); - return stripped || title; - } - - function editionLabel(edition, family) { - // The expander rail header already names the album, so each edition - // row shows only what differs: "Original" for the canonical release, - // otherwise the qualifier ("Deluxe Edition", "2025 Remaster"). The - // full title stays available as the row tooltip. - if (edition.id === family.id) return "Original"; - const title = edition.title; - const base = family.baseTitle ?? ""; - // "Weezer (Deluxe Edition / Red Album)" shares its bracket with the - // family qualifier — drop segments the family already carries so the - // label is just "Deluxe Edition". Test against the canonical title, - // not baseTitle: edition_group families derive baseTitle from - // whichever release was processed first, so it can be bare "Weezer". - const known = `${family.title ?? ""} ${base}`.toLowerCase(); - const dropBaseParts = (raw) => { - const parts = raw.split(/\s*[/|;]\s*/).map((p) => p.trim()).filter(Boolean); - const kept = parts.filter((p) => !known.includes(p.toLowerCase())); - return (kept.length ? kept : parts).join(" / "); - }; - const trim = (raw) => raw - .replace(/^[\s\-–—:([]+/, "") - .replace(/[)\]]+$/, "") - .trim(); - if (base && title.toLowerCase().startsWith(base.toLowerCase())) { - const rest = trim(title.slice(base.length)); - if (rest) return dropBaseParts(rest); - } - const suffix = title.match(EDITION_SUFFIX)?.[0]; - return (suffix && dropBaseParts(trim(suffix))) || title; - } + // Edition identity (isEdition / editionBaseTitle / editionLabel / + // releaseFamilies) lives in src/lib/editions.js so the album page folds a + // record's pressings exactly the way this page does. function categoryFor(album) { // A backend-linked edition files WITH its base release: "The Life of a @@ -2213,74 +2148,6 @@ return ["album", "ep", "single"].includes(type) ? type : "other"; } - function releaseFamilies(items, category) { - // A work whose rows are only PARTLY edition_group-linked must not - // split into a work-keyed family and a title-keyed family showing two - // cards ("Nevermind" linked to its deluxe, "Nevermind (Remastered)" - // unlinked). Rows without a group adopt the group of a linked row - // that shares their base title. - const groupByBase = new Map(); - for (const album of items) { - if (album.edition_group == null) continue; - const base = norm(editionBaseTitle(album.title)); - if (base && !groupByBase.has(base)) groupByBase.set(base, album.edition_group); - } - const families = new Map(); - for (const album of items) { - const baseTitle = editionBaseTitle(album.title); - // 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}`; - if (!families.has(key)) { - families.set(key, { key, baseTitle, editions: [] }); - } - const family = families.get(key); - // Deezer can return the same regional/digital release under more - // than one catalog id, while MusicBrainz supplements can carry the - // same title/year without a track count. Those are duplicate source - // records, not editions. Different known track counts remain - // separate because they can represent a real expanded edition. - const releaseYear = year(album); - const duplicate = releaseYear && family.editions.some((edition) => - norm(edition.title) === norm(album.title) - && year(edition) === releaseYear - && (edition.track_count == null - || album.track_count == null - || edition.track_count === album.track_count), - ); - if (duplicate) continue; - family.editions.push(album); - } - - return [...families.values()] - .map((family) => { - const byDate = family.editions - .slice() - .sort((a, b) => (a.release_date ?? "9999").localeCompare(b.release_date ?? "9999")); - const canonical = byDate.find((album) => !isEdition(album)) ?? byDate[0]; - const editions = [canonical, ...byDate.filter((album) => album.id !== canonical.id)]; - return { ...canonical, familyKey: family.key, baseTitle: family.baseTitle, editions }; - }) - // Studio chronology should follow the original album, not the date - // of its latest anniversary reissue. - .sort((a, b) => (b.release_date ?? "").localeCompare(a.release_date ?? "")); - } - // Discography tabs (Spotify-style). Each tab merges one or more of the // nine fine-grained categories into a single date-sorted grid, so every // release stays reachable and the per-grid expander machinery works