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