Files
SoulStream/src/views/CatalogAlbum.svelte
T
KeelrLeek 547cee6649 dark glass gets a surface ladder, and the 200-750ms stalls are dead - two sessions of uncommitted work landing together
SURFACES: panes add black, surfaces INSIDE panes add white. Every pane stands
on 0.7075 black over the acrylic (.content 0.35 over .app 0.55), so a
translucent black card nets 0.81 - darker than its own pane and inside the
~0.85 band already rejected once on the artist page. Solid mode's ladder is
measured UPWARD from #121212 (+6/+18/+24), so glass elevates with WHITE: new
--glass-surface/-hover/-press/-hairline/-well/-ring under :root.glass
(0.03/0.08/0.11/0.10/0.18), which is independently the vocabulary
.btn-ghost:hover and .ax-ghost:hover already shipped. The four solid tokens are
NEVER redefined - they are consumed inside color-mix() and gradient stops where
an alpha multiplies through and kills the call site (0.74 x 0.03 = an invisible
chip; the album row's text-overflow fade works by BEING opaque), and they paint
modal chrome that has to stay solid. 129 additive :global(:root.glass)
declarations across 18 files, all 129 verified inside a glass block by walking
the diff - solid mode byte-identical, confirmed structurally, by computed style
with the class off, and visually.

Three of them are not colour swaps: the album row's .rowbtns shim was OCCLUDING
the last ~19px of the listeners bar and nothing translucent can occlude, so
.tcount masks out just before the buttons instead; Playlist's banner scrim and
the discography's pinned ambience both landed on opaque --bg-base and now
dissolve like the album banner; and the 0.9 black hero tint on
Home/CatalogSearch/Discography paints straight onto the pane when there is no
artwork, so :first-child (the {#if} renders no element) flips it to white.
Menus, dialogs, toasts and the import surfaces stay opaque deliberately, and
the Settings copy for the mode now says so instead of claiming cards do.
Measured with a known ground substituted behind the transparent window, since
CDP never sees the acrylic: library toolbar chroma +2 as shipped, +16 with a
white fill AND its backdrop-filter dropped, +30 for the bare pane beside it.

RESIZE: Windows drops the legacy accent-policy acrylic for the whole of a live
resize and the window stays transparent through it, so the desktop punches
through sharp - measured by toggling window_glass alone, desktop icon labels
readable through the titlebar. The blur cannot be kept (documented since Win10
2004, and the modern DWM backdrop was already rejected for the
overlay-promotion blink), so armGlassResizeGuard grounds the canvas to
--bg-sidebar for the length of the gesture.

STALLS: "choppy but the FPS says 144" was 200-750ms MAIN-THREAD TASKS, not
frame pacing - idle-scheduled work fires at scroll pauses by definition. Twin
bake moved to display resolution and off the ready task (was a same-task 4K
WebGL readback plus software blur, 100-170ms per rotation AND per artist
entry); gl.finish() -> gl.flush() in buildMaster, which had been draining the
whole 4K EASU/RCAS/grade chain on the main thread; copyCanvas realloc guarded;
one app-wide scroll clock in scrollbar.js (markAppScroll/appScrollIdleFor) so
all three periscopes stop cloning mid-gesture on ANY pane rather than only
their own; bounded reclone with the querySelectorAll census deleted, the gate
widened 250->1000ms, and the build/attach split across an idle callback and a
rAF behind a generation guard; the hero rotation tick gated on scroll recency
and its payload cache sized to the working set (2->4 entries, 24->32MiB,
32->64MiB); sessionTick no longer force-rewrites every 12s while merely PAUSED.
Scroll storm went from 109-616ms long tasks to zero. Hover recalc and GC were
measured innocent - do not re-chase them.

98/98 tests throughout.
2026-08-27 17:24:20 -05:00

1984 lines
76 KiB
Svelte

<script>
import {
catalog,
libraryStore,
streamSong,
saveSong,
streamCatalogAlbum,
saveCatalogAlbum,
resolver,
player,
openArtistByName,
artBroken,
coverUpgrades,
resolveCovers,
coverLoadError,
getCatalogArt,
resolveArtistSeedByName,
getCatalogArtistPage,
openCatalogAlbum,
openAlbumFromPreviewItem,
toggleShuffle,
playAlbum,
ui,
} 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, splitDescription } from "../lib/editions.js";
import { listenerHeat, listenerPeak, listenerShare } from "../lib/listenerBars.js";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … }
const album = $derived(page?.album ?? null);
/** A LOCAL library record on this same page. Everything remote is simply
* absent — no listeners, no editions, no prose, no cover upgrade to chase
* — and playback goes to the files on disk instead of the resolver. */
const isLocal = $derived(Boolean(page?.local));
// Upgrade this album's cover to the highest verified-same-artwork source.
let coveredId = null;
$effect(() => {
// A local cover is already the file on disk — there is no better
// source to go looking for, and the upgrade path expects a URL.
if (isLocal) return;
if (album?.cover && album.id !== coveredId) {
coveredId = album.id;
resolveCovers([album], album.artist);
}
});
/** Lede + body, from the shared splitter — see `splitDescription`. */
const descParts = $derived(splitDescription(info?.description));
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;
function handleCoverError(node) {
const fallback = coverLoadError(
album?.cover,
node?.currentSrc || node?.src || null,
album?.artist ?? "",
album?.title ?? "",
);
if (fallback && node?.src !== fallback) node.src = fallback;
}
const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
/** 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.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());
/** The same rows with their paths kept, so a playlist add can point at the
* owned file instead of a stream placeholder. */
let libraryRows = $state([]);
let ticksFor = null;
$effect(() => {
const id = localAlbum?.id ?? null;
if (id === ticksFor) return;
ticksFor = id;
libraryTicks = new Set();
libraryRows = [];
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 || "")));
libraryRows = rows;
}
})
.catch(() => {});
return () => {
stale = true;
};
});
// ---- add to playlist ----------------------------------------------------
/** Local files for this record by normalized title — a local page carries
* them in `page.libraryTracks`; an owned catalog page gets them from the
* ticks fetch above. */
const localByTitle = $derived.by(() => {
const m = new Map();
for (const lt of page?.libraryTracks ?? []) {
const k = norm(lt.title || "");
if (k && !m.has(k)) m.set(k, lt);
}
for (const lt of libraryRows) {
const k = norm(lt.title || "");
if (k && !m.has(k)) m.set(k, lt);
}
return m;
});
/** A playlist row for a track on this record. Owned tracks go in as the
* file on disk; the rest are placeholders that stream on click. */
function playlistRow(t) {
const local = localByTitle.get(norm(t.title || ""));
return {
title: t.title,
artist: album?.artist ?? null,
album: album?.title ?? null,
duration_secs: t.duration_secs ?? local?.duration_secs ?? null,
path: local?.path ?? null,
art_path: local ? (page?.artPath ?? localAlbum?.art_path ?? null) : null,
// Stream anchors, mirroring what playTrack hands the resolver. A
// local record's cover is a file path, not a URL — omit it there.
trackId: t.id ?? null,
artUrl: isLocal ? null : (t.cover ?? album?.cover ?? null),
};
}
// An upcoming album that still has unreleased songs can't be streamed or
// saved as a whole — only its already-released singles play (per track).
const hasUnreleased = $derived((page?.tracks ?? []).some((t) => t.unreleased));
// Multi-disc releases (e.g. anniversary box sets) get "Disc N" headers, and
// each disc's tracks are numbered from 1 (Deezer's per-disc track_position).
const multiDisc = $derived(
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 most-heard 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.
//
// LISTENERS, not scrobbles. Both come from the same Last.fm table, but the
// play counts are the noisier of the two — distinct listeners is the figure
// the Popular list is already ordered by, so the album and the artist page
// now rank a tracklist the same way.
//
// The scale itself lives in src/lib/listenerBars.js — it is curved rather
// than linear so one runaway hit cannot squash the rest of the record into
// a sliver, and the artist page's album panel draws the identical column.
const countPeak = $derived(listenerPeak(tracks));
const showCount = $derived(countPeak > 0 && tracks.length >= 3);
const countShare = (t) => listenerShare(t.listeners, countPeak);
const countHeat = (t) => listenerHeat(t.listeners, countPeak);
/** Unknown is a dash, never a zero — same rule as the artist page. */
const fmtCount = (n) => (n == null ? "—" : n.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(" · ");
};
/** This record's artwork in the shared full-window viewer — the same one
* the Now Playing cover and the artist page's expander open. The UPGRADED
* cover, not the sized variant the hero paints: the point of the viewer is
* the master resolution. */
function openArtwork() {
if (!coverOk || !album) return;
ui.artwork = {
src: cover,
local: false,
title: album.title ?? "",
subtitle: album.artist ?? "",
};
}
// ---- 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);
},
// Every row here belongs to the record already on screen, so the
// overlay greys this out — supplied anyway so a row that ever
// declares a DIFFERENT album (a compilation pressing) still goes
// somewhere rather than being silently dead.
openAlbum: (item) => void openAlbumFromPreviewItem(item),
});
}
// ---- 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;
/* Uneven on purpose: a column of identical bars reads as a table that
failed to load, while a ragged one reads as track titles. */
const SKELETON_WIDTHS = [88, 72, 94, 61, 80, 68, 90, 57, 84, 65, 92, 74];
/** One bar per track where the seed knew the count — the card that was
* clicked carries `track_count` — so the tracklist is close to its real
* height and whatever sits under it barely moves. Measured at 296px of
* skeleton against 377px of record before this. Clamped: a 40-track
* anthology should not paint 40 bars into a page nobody has scrolled. */
const SKELETON_ROWS = $derived.by(() => {
const n = catalog.albumSeed?.tracks;
const count = Math.min(14, Math.max(5, Number.isFinite(n) && n > 0 ? n : 8));
return Array.from({ length: count }, (_, i) => SKELETON_WIDTHS[i % SKELETON_WIDTHS.length]);
});
/** True when the skeleton already painted THIS record's artwork from the
* seed. The deal must then leave `.banner` and `.cover` alone: fading in
* the very picture that is already on screen is not an entrance, it is a
* blink. Everything else still deals normally, so the words arrive over
* artwork that never flickered. */
let seedShown = false;
/** The seed's artwork, kept after `catalog.albumSeed` clears. The loaded
* page asks for the MASTER cover — a different URL from the seed's
* grid-sized one — so on a cold cache its `<img>` has nothing to paint for
* a moment and the artwork blinks out. This stays underneath as a
* background until the master decodes, which turns a blink into an
* upgrade. On `.banner img` the element's own `filter: blur(42px)` applies
* to its background too, so the wash survives the same gap. */
let seedCover = $state(null);
$effect(() => {
if (!catalog.albumLoading) return;
seedShown = !!catalog.albumSeed?.cover;
seedCover = catalog.albumSeed?.cover ?? null;
});
const seedBg = $derived(seedCover ? `background-image: url("${seedCover}")` : "");
let pageEl = $state(null);
// Bumped once per record. Every element remembers the generation it was
// dealt in, so a second pass over the same record can tell "already
// animated" from "only just arrived" without re-animating the page.
let dealGeneration = 0;
const dealtEls = new WeakMap();
function dealParts() {
if (!pageEl || reducedMotion()) return;
for (const { sel, delay, lift } of SWAP_PARTS) {
// Marked as dealt rather than skipped, so a later pass picking up
// the network-gated parts cannot animate the artwork after all.
if (seedShown && (sel === ".banner" || sel === ".cover")) {
for (const el of pageEl.querySelectorAll(sel)) {
dealtEls.set(el, dealGeneration);
}
continue;
}
for (const el of pageEl.querySelectorAll(sel)) {
if (dealtEls.get(el) === dealGeneration) continue;
dealtEls.set(el, dealGeneration);
// 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";
// Read `rest` first, then hide inline. A WAAPI animation is
// PENDING until the next frame resolves its start time, and a
// pending animation applies nothing — so `fill: backwards`
// never covered the very first paint. Every part rendered once
// at full opacity and then snapped to 0: the flash. An inline
// style takes effect in this same recalc, which closes it.
el.style.opacity = "0";
const anim = el.animate(
[
{ opacity: 0, transform: lift ? "translateY(7px)" : "none" },
{ opacity: rest, transform: "none" },
],
// `both`, not `backwards`: the animation must keep holding
// the resting value at the end, or clearing the inline
// style below would race the last frame and flash again.
{ duration: SWAP_MS * 0.7, delay, easing: SWAP_EASE, fill: "both" },
);
// Hand the element back to the stylesheet: drop the inline
// hide first, and only then release the animation that is
// still holding the value it settles on.
const release = () => {
el.style.opacity = "";
anim.cancel();
};
anim.finished.then(release, release);
}
}
}
/** 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;
// A swap holds the page rather than showing a skeleton, so no seed is
// painted and this pressing's artwork is genuinely new. Clearing the
// flag lets the cover deal in, as it did before any of this existed.
seedShown = false;
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;
// `albumLoading` is part of the condition, not incidental to it.
// `openCatalogAlbum` assigns `albumPage` and only clears the loading
// flag in its `finally`, so for one flush `album.id` is the new record
// while the SKELETON is still the branch on screen — and the skeleton
// has a `.tracklist` of its own. Dealing then animated markup about to
// be thrown away, marked it done, and left the real page to the rAF a
// frame later: the blink this was supposed to remove.
if (catalog.albumLoading || id == null || id === dealtFor) return;
dealtFor = id;
dealGeneration++;
// Synchronously, NOT in a rAF. An effect already runs after the DOM is
// updated, so waiting a frame let the browser paint the new page at
// full opacity once before the animation's `fill: backwards` pulled it
// to zero — content appeared, blinked out, then faded in. Measured at
// one frame of `.tracklist` at opacity 1 followed by 110ms at 0.
// The rAF behind it is insurance for the case where a part is not
// mounted yet; anything the synchronous pass dealt is skipped.
dealParts();
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;
};
});
/** Four parts are gated on data that arrives over the network: `.eds` on
* the sibling pressings, `.about`/`.more` on the editorial description,
* `.stats` on Last.fm, `.credits` partly on that same description. None of
* them are in the DOM one frame after the record changes, so the deal's
* `querySelectorAll` never saw them and they appeared at full opacity with
* no entrance at all. The description was the certain case rather than the
* unlucky one — `catalogAlbumInfo` is always a round trip, so it could
* never beat a single rAF.
*
* Deal again whenever one of those lands. `dealParts` skips anything this
* generation already animated, so a later pass can only ever pick up the
* parts that were missing from the first — expanding the description or
* re-rendering a row cannot replay the page. */
$effect(() => {
// Read the gates, so this re-runs as each one resolves.
void info;
void editions.length;
void albumPlays;
void albumListeners;
// Same skeleton guard as above: nothing here is worth animating while
// the placeholder is still the thing on screen.
if (dealtFor == null || catalog.albumLoading) return;
// Same reason as the deal above: a frame's wait is a frame of the part
// painted at full opacity before it is pulled to zero.
dealParts();
requestAnimationFrame(dealParts);
});
let saved = $state({});
function flagSaveError(key) {
saved[key] = "err";
setTimeout(() => saved[key] === "err" && (saved[key] = ""), 3000);
}
async function saveAlbumAction() {
if (!album || saved.album) return;
saved.album = "…";
if (await saveCatalogAlbum(album)) saved.album = "ok";
else flagSaveError("album");
}
async function saveTrackAction(t) {
if (saved[`t${t.id}`]) return;
saved[`t${t.id}`] = "…";
if (await saveSong(album.artist, t.title)) saved[`t${t.id}`] = "ok";
else flagSaveError(`t${t.id}`);
}
const playTrack = (t) => {
// A local record plays the file you already have — queueing the whole
// album from the clicked row, the way the library always did — rather
// than sending a track you own back through the resolver.
if (isLocal) {
const at = page.libraryTracks.findIndex((lt) => lt.path === t.path);
playAlbum(
page.libraryTracks,
`${album.artist} — ${album.title}`,
at < 0 ? 0 : at,
page.artPath,
);
return;
}
streamSong(album.artist, t.title, {
cover: t.cover ?? album.cover,
trackId: t.id,
albumId: album.id,
album: album.title || null,
expectSecs: t.duration_secs ?? null,
});
};
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"}` : "",
);
// A new record starts at its top; coming BACK to one returns the reader to
// where they were. The pane outlives the view swap, so without this a
// record opened from half-way down an artist page began half-way down.
let scrolledFor = null;
$effect(() => {
const id = album?.id ?? null;
if (id == null || id === scrolledFor) return;
scrolledFor = id;
const pane = document.querySelector(".content");
if (!pane) return;
const target = consumeScroll();
if (target == null) {
pane.scrollTop = 0;
} else {
restoreScroll(pane, target);
}
});
// Scroll to (and briefly flash) the track the now-playing song link targets.
let flashId = $state(null);
$effect(() => {
const tid = catalog.pendingTrackId;
if (tid == null || !page) return;
requestAnimationFrame(() => {
const el = document.getElementById(`catrack-${tid}`);
if (el) el.scrollIntoView({ block: "center", behavior: "smooth" });
});
flashId = tid;
const t = setTimeout(() => flashId === tid && (flashId = null), 1800);
return () => clearTimeout(t);
});
</script>
<div class="album-page" style={accentStyle} bind:this={pageEl}>
{#if catalog.albumLoading}
<!-- The record's own shape, not a word about loading. It is built from
the SAME `.hero`/`.identity`/`.cover`/`.copy` elements the loaded
page uses, so the artwork does not move by a pixel when the fetch
lands — the skeleton is the page with its words removed.
`albumSeed` is what the card you clicked already knew, so the
cover and its blurred banner are on screen from the first frame
and the page has its colour before the request returns. Without a
seed (a history restore that never stored art) the artwork is a
shimmer block like everything else. -->
{@const seed = catalog.albumSeed}
<header class="hero" aria-busy="true" aria-label="Loading album">
<div class="banner" aria-hidden="true">
{#if seed?.cover}
<img src={seed.cover} alt="" draggable="false" />
{/if}
</div>
<div class="identity">
{#if seed?.cover}
<div class="cover"><img src={seed.cover} alt="" draggable="false" /></div>
{:else}
<div class="cover sk"></div>
{/if}
<div class="copy">
<span class="sk sk-kind"></span>
<span class="sk sk-title"></span>
<span class="sk sk-meta"></span>
<span class="sk sk-stat"></span>
<div class="actions">
<span class="sk sk-play"></span>
<span class="sk sk-round"></span>
<span class="sk sk-round"></span>
</div>
</div>
</div>
</header>
<div class="tracklist">
{#each SKELETON_ROWS as width, i (i)}
<span class="sk sk-trow" style="width: {width}%"></span>
{/each}
</div>
{:else if !page}
<p class="hint">{catalog.error || "Album not found."}</p>
{:else}
<!-- The identity block is bottom-anchored, so the hero's height IS the
gap between the glass bar and the top of the artwork: the bar's
stuck bottom sits 22px into this box and the block is ~218px tall.
264px leaves ~24px of banner above it. -->
<header class="hero">
<div class="banner" aria-hidden="true">
{#if coverOk}
<img src={cover} alt="" draggable="false" style={seedBg} />
{/if}
</div>
<div class="identity">
{#if coverOk}
<button
class="cover"
title="View artwork"
aria-label="View artwork full screen"
onclick={openArtwork}
style={seedBg}
>
<img
src={cover}
alt=""
draggable="false"
onerror={(event) => handleCoverError(event.currentTarget)}
/>
<span class="mark" aria-hidden="true"><Icon name="expand" size={22} /></span>
</button>
{:else}
<div class="cover placeholder"><Icon name="disc" size={44} /></div>
{/if}
<div class="copy">
<span class="kind">{(album.record_type ?? "album").toUpperCase()}</span>
<h1 class:long={album.title.length > 22}>{album.title}</h1>
<p class="meta">
<button class="who" onclick={() => openArtistByName(album.artist)}>
{#if artistPicture}
<img src={artistPicture} alt="" draggable="false" />
{:else}
<span class="who-dot" aria-hidden="true"><Icon name="users" size={11} /></span>
{/if}
{album.artist}
</button>
{#if year(album)}<span class="dot">·</span> {year(album)}{/if}
{#if nTracks}<span class="dot">·</span> {nTracks}{/if}
{#if runtime}<span class="dot">·</span> {runtime}{/if}
{#if inLibrary}<span class="dot">·</span> <span class="owned">in your library</span>{/if}
</p>
{#if albumPlays || albumListeners}
<div class="stats" title={totalsSummed ? "Summed from this tracklist — Last.fm has no album entry of its own" : "Last.fm totals for this record"}>
{#if albumPlays}
<span class="stat"><b>{albumPlays}</b><i>plays</i></span>
{/if}
{#if albumPlays && albumListeners}<span class="stat-div"></span>{/if}
{#if albumListeners}
<span class="stat"><b>{albumListeners}</b><i>listeners</i></span>
{/if}
{#if totalsSummed}<span class="stat-note">summed</span>{/if}
</div>
{/if}
<div class="actions">
<button
class="pill"
disabled={hasUnreleased}
title={hasUnreleased ? "Not released yet" : ""}
onclick={() =>
isLocal
? playAlbum(
page.libraryTracks,
`${album.artist} — ${album.title}`,
0,
page.artPath,
)
: streamCatalogAlbum(album)}
>
<Icon name="play" size={12} /> Play
</button>
<!-- Save is "fetch this from the network into your
library". A local record is already there, so the
control has nothing to do and is not offered. -->
{#if !isLocal}
<button class="pill ghost" disabled={hasUnreleased} onclick={saveAlbumAction}>
<Icon
name={saved.album === "ok" ? "check" : saved.album === "err" ? "x" : "download"}
size={13}
/>
{saved.album === "ok" ? "Saved" : saved.album === "err" ? "No sources" : "Save"}
</button>
{/if}
<button
class="iconbtn"
class:on={player.shuffle}
disabled={hasUnreleased}
title={hasUnreleased ? "Not released yet" : "Shuffle this record"}
aria-label="Shuffle this record"
onclick={shufflePlay}
>
<Icon name="shuffle" size={17} />
</button>
<button
class="iconbtn"
disabled={previewable === 0}
title={previewLabel}
aria-label={previewLabel}
onclick={openPreviews}
>
<Icon name="preview-reel" size={17} strokeWidth={1.8} />
{#if previewable > 0 && previewable < tracks.length}
<span class="badge" aria-hidden="true">{previewable}</span>
{/if}
</button>
<PlaylistMenu
compact
iconSize={17}
title="Add this record to a playlist"
tracks={() => tracks.filter((t) => !t.unreleased).map(playlistRow)}
/>
</div>
</div>
</div>
</header>
{#if editions.length}
<div class="eds">
<span class="lab">Editions</span>
<div class="edrow">
{#each editions as edition (edition.id)}
<button
class="ed"
class:on={edition.on}
title={edition.title}
onclick={() => switchEdition(edition)}
>
{#if edition.cover && !artBroken[edition.cover]}
<img
class="ed-art"
src={coverUpgrades[edition.cover] ?? edition.cover}
alt=""
loading="lazy"
decoding="async"
draggable="false"
/>
{:else}
<span class="ed-art placeholder"><Icon name="disc" size={14} /></span>
{/if}
<span class="ed-copy">
<span class="n">{edition.label}</span>
<span class="y">{editionSub(edition)}</span>
</span>
</button>
{/each}
</div>
</div>
{/if}
<div class="tracklist">
{#if tracks.length}
<div class="tcols" class:has-count={showCount}>
<span class="tno">#</span>
<span>Title</span>
{#if showCount}<span>Listeners</span>{/if}
<span class="tdur-head"><Icon name="clock" size={13} /></span>
</div>
{/if}
{#each tracks as t, i (t.id)}
{#if multiDisc && t.disk_number !== (tracks[i - 1]?.disk_number ?? (i === 0 ? null : 1))}
<div class="disc-head">Disc {t.disk_number ?? 1}</div>
{/if}
{@const locked = t.unreleased}
{@const play = () => {
if (!locked) playTrack(t);
}}
<div
id="catrack-{t.id}"
class="trow"
class:has-count={showCount}
class:now={nowPlaying("trackId", t.id)}
class:flash={flashId === t.id}
class:locked
style={showCount ? `--t:${countHeat(t)}` : undefined}
role="button"
aria-disabled={locked}
tabindex={locked ? -1 : 0}
title={locked ? "Not released yet" : "Play — streams from the best source"}
onclick={play}
onkeydown={(e) => e.key === "Enter" && play()}
>
<span class="tno">{nowPlaying("trackId", t.id) ? "♫" : (t.track_no ?? "·")}</span>
<span class="ttitle">
{t.title}
{#if libraryTicks.has(norm(t.title))}
<span class="tick" title="Saved in your library"><Icon name="check" size={11} /></span>
{/if}
</span>
{#if showCount}
<span
class="tcount"
class:empty={!t.listeners}
title={t.listeners == null
? "No Last.fm listener count for this track"
: `${t.listeners.toLocaleString()} Last.fm listeners`}
>
<span class="tcount-fill" style={`width:${countShare(t)}%`}>
<span class="tcount-val">{fmtCount(t.listeners)}</span>
</span>
</span>
{/if}
{#if locked}
<span class="tag-unreleased">Unreleased</span>
{:else}
<span class="tdur">{fmtDuration(t.duration_secs)}</span>
{/if}
<span class="rowbtns">
{#if !locked}
<PlaylistMenu
compact
iconSize={13}
align="right"
tracks={() => [playlistRow(t)]}
/>
{/if}
<button
class="mini-btn play"
disabled={locked}
title={locked ? "Not released yet" : "Play"}
onclick={(e) => {
e.stopPropagation();
play();
}}
>
<Icon name="play" size={13} />
</button>
<button
class="mini-get"
disabled={locked}
title={locked ? "Not released yet" : "Save to library"}
onclick={(e) => {
e.stopPropagation();
saveTrackAction(t);
}}
>
<Icon
name={saved[`t${t.id}`] === "ok" ? "check" : saved[`t${t.id}`] === "err" ? "x" : "download"}
size={13}
/>
</button>
</span>
</div>
{/each}
</div>
<!-- BELOW the tracklist, deliberately. Above it, the About block pushed
the tracks ~180px down: you landed on a record and read prose before
you could see what was on it. Down here the tracklist starts
immediately and the prose is there for whoever scrolls.
It also removes a real bug rather than only a preference. The
description is a network round trip, so arriving ABOVE the tracks
re-flowed them on landing — measured at 81.5px. Nothing above this
point depends on `info`, so the tracklist can no longer move.
Headed in this page's OWN `.disc-head` idiom, and the lede is the
description's first sentence promoted by the shared splitter — the
same one the artist page's expander and the Now Playing panel use.
Nothing here is written. -->
{#if info?.description}
<div class="about-head"><span>About this record</span></div>
{#if descParts.lede}
<p class="lede">{descParts.lede}</p>
{/if}
{#if descParts.rest}
<p class="about" class:clamped={!descExpanded}>{descParts.rest}</p>
<button class="more" class:open={descExpanded} onclick={() => (descExpanded = !descExpanded)}>
{descExpanded ? "Show less" : "Show more"}
<Icon name="chevron-down" size={13} />
</button>
{/if}
{/if}
{#if releaseDate(album) || info?.label || info?.genre}
<!-- The grey label/value block becomes the same pills the panel and
the expander use, plus who said so — the page asserts a date, a
label and counts from three different providers and used to
name none of them. -->
<div class="about-head"><span>Release</span></div>
<div class="pills">
{#if info?.genre}<span class="gpill accent">{info.genre}</span>{/if}
{#if info?.label}<span class="gpill">{info.label}</span>{/if}
{#if releaseDate(album)}<span class="gpill">{releaseDate(album)}</span>{/if}
{#if nTracks}<span class="gpill">{nTracks}</span>{/if}
{#if runtime}<span class="gpill">{runtime}</span>{/if}
</div>
<!-- Attribution has to be true of THIS page: a local record's
facts were read off the files, not fetched from a catalogue. -->
<p class="srcs">
{#if info?.description}<b>Words</b> Wikipedia · {/if}
{#if albumPlays || albumListeners}<b>Counts</b> Last.fm · {/if}
{#if isLocal}
<b>Catalogue</b> your library
{:else}
<b>Catalogue</b> Deezer + MusicBrainz
{/if}
</p>
{/if}
{/if}
</div>
<style>
/* The glass bar cancels the pane's top padding so a page's box starts
flush with the top of the pane and its artwork can run under the bar.
This page's hero is in normal flow rather than positioned against the
root, so it only needs that inset handed back. */
.album-page {
padding-top: var(--pane-pad);
}
.hint {
color: var(--text-secondary);
padding-top: 10vh;
text-align: center;
}
/* ------------------------------------------------------- skeleton ---- */
/* Sized in `em` inside `.copy`, whose font-size is 14px * --cover-scale,
so every bar tracks the artwork the way the real type does. */
.sk {
display: block;
/* Translucent, not the flat token: in the hero these blocks sit over
`.banner`, so the record tints them and the placeholder belongs to
the album rather than sitting on top of it. Expressed against
--bg-highlight rather than a literal rgba so it still tracks the
theme. Below the hero there is nothing behind them but --bg-base,
where the same alpha simply reads as a quieter block. */
background: color-mix(in srgb, var(--bg-highlight) 45%, transparent);
border-radius: 6px;
position: relative;
overflow: hidden;
}
.sk::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.07),
transparent
);
animation: skeleton-sweep 1649ms infinite;
}
@keyframes skeleton-sweep {
100% { transform: translateX(100%); }
}
/* A sweep on a dozen blocks is motion for its own sake when the reason to
reduce it is motion sensitivity. The blocks stay; only the sweep goes. */
@media (prefers-reduced-motion: reduce) {
.sk::after { animation: none; }
}
/* Only the artwork placeholder — a seeded cover is a real `.cover`. */
.cover.sk { border-radius: 10px; }
/* These heights are not taste, they are the measured heights of the rows
they stand in for. `.identity` is bottom-anchored and its height is set
by whichever is taller, the artwork or this column — and the column wins
by a fraction. So any error here moves the ENTIRE identity block when the
words replace the bars. The bars summed 164.46 against the real 163.58
and the hero dropped 0.87px on every load; `.sk-play` alone was 14px too
tall, against a pill the stylesheet already documents as 2em.
If a row's real height changes, re-measure rather than eyeball. */
.sk-kind { width: 5.4em; height: 0.973em; border-radius: 3px; } /* 16.00px */
.sk-title { width: min(62%, 20em); height: 3.18em; border-radius: 8px; } /* 52.30 */
.sk-meta { width: min(48%, 15em); height: 1.571em; } /* 25.84px */
.sk-stat { width: 9.2em; height: 2.222em; } /* 36.55px */
.sk-play { width: 6.4em; height: 2em; border-radius: 999px; } /* 32.89px */
.sk-round { width: 2em; height: 2em; border-radius: 50%; }
.sk-trow { height: 33px; border-radius: 6px; margin-bottom: 4px; }
/* ---------------------------------------------------- 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;
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;
/* The seed's copy, showing only until the master decodes — see
`seedBg`. The filter below applies to it as well, so the wash never
drops out mid-swap. */
background-size: cover;
background-position: center;
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%
);
}
/* Dark glass: the banner DISSOLVES at its foot instead of landing on a
repainted tail — a translucent tail color can never match the frost
beneath and drew a hard line at the box edge (Kyle, 2026-08-26). The
mask fades the whole layer (img + scrim together) to nothing, so it
ends on frost at frost's own value — the np wash's move. Solid mode
keeps the gradient's opaque landing against --bg-base. */
:global(:root.glass) .banner {
mask-image: linear-gradient(to bottom, #000 55%, transparent 98%);
-webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent 98%);
}
/* `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%;
}
/* Hovering the artwork offers the full-window viewer: the whole element
lifts (see --art-grow in global.css) under a light wash, with the expand
mark in the middle. `transform` does not reflow, so the copy column
beside it — which is sized to end level with this artwork — cannot be
nudged by the hover. */
.cover {
position: relative;
display: block;
width: var(--cover);
height: var(--cover);
padding: 0;
border-radius: 10px;
overflow: hidden;
/* The seed's copy underneath, so swapping to the master resolution is
an upgrade rather than a gap. Painted over the moment it decodes. */
background-size: cover;
background-position: center;
flex: none;
cursor: pointer;
box-shadow: 0 22px 46px -18px rgba(0, 0, 0, 0.8);
transition:
transform var(--art-motion),
box-shadow var(--art-motion);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cover:hover,
.cover:focus-visible {
transform: scale(var(--art-grow));
box-shadow: 0 30px 62px -20px rgba(0, 0, 0, 0.86);
}
.cover::after {
content: "";
position: absolute;
inset: 0;
background: var(--art-wash);
opacity: 0;
transition: opacity var(--art-motion);
pointer-events: none;
}
.cover:hover::after,
.cover:focus-visible::after {
opacity: 1;
}
.mark {
position: absolute;
inset: 0;
margin: auto;
display: grid;
place-items: center;
width: calc(54px * var(--cover-scale));
height: calc(54px * var(--cover-scale));
border-radius: 50%;
background: rgba(12, 12, 14, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
color: #fff;
opacity: 0;
transform: scale(0.82);
transition:
opacity var(--art-motion),
transform var(--art-motion);
}
.cover:hover .mark,
.cover:focus-visible .mark {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.cover,
.mark {
transition-duration: 1ms;
}
.cover:hover,
.cover:focus-visible {
transform: none;
}
}
.cover.placeholder {
display: grid;
place-items: center;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
color: var(--text-muted);
}
/* 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: 0.714em;
font-weight: 800;
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 {
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);
}
.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;
}
.actions {
display: flex;
align-items: center;
gap: 0.714em;
}
/* 2em is the Play/Save pill's own height (0.571em of padding either side
of its line box). Matching it keeps the action row one height — a taller
icon button both sat proud of the pills and pushed the copy column past
the artwork it is supposed to end level with. */
.iconbtn {
position: relative;
display: grid;
place-items: center;
width: 2em;
height: 2em;
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;
}
/* The add-to-playlist trigger wears the iconbtn look — the component
itself stays unstyled chrome (the Library rail set the precedent). */
.actions :global(.plm-trigger) {
display: grid;
place-items: center;
width: 2em;
height: 2em;
border-radius: 50%;
color: rgba(255, 255, 255, 0.85);
}
.actions :global(.plm-trigger:hover) {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
/* 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: #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.ghost {
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:disabled {
background: var(--bg-highlight);
color: var(--text-muted);
cursor: default;
opacity: 0.6;
}
.pill:disabled:hover {
background: var(--bg-highlight);
}
/* ----------------------------------------------------- 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-muted);
margin-bottom: 6px;
}
.edrow {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.ed {
display: flex;
align-items: center;
gap: 10px;
width: 216px;
padding: 7px 8px;
border-radius: 6px;
border: 1px solid transparent;
text-align: left;
}
.ed:hover {
background: rgba(255, 255, 255, 0.06);
}
.ed.on {
background: color-mix(in srgb, var(--accent) 15%, transparent);
border-color: var(--accent);
cursor: default;
}
.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 ---- */
/* `.disc-head` verbatim, minus its left padding: that one is indented to
line up with the tracklist's rows, and these blocks are full-bleed. */
.about-head {
display: flex;
align-items: center;
gap: 8px;
padding: 20px 0 6px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.about-head::after {
content: "";
flex: 1;
height: 1px;
background: rgba(255, 255, 255, 0.07);
}
/* The description's own first sentence, promoted — see `splitDescription`. */
.lede {
margin: 4px 0 0;
max-width: 820px;
font-size: 15px;
font-weight: 600;
line-height: 1.5;
color: rgba(255, 255, 255, 0.9);
}
.pills {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 4px;
}
.gpill {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 3px 9px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
color: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(255, 255, 255, 0.07);
}
.gpill.accent {
background: color-mix(in srgb, var(--accent) 30%, rgba(10, 10, 12, 0.4));
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
color: #fff;
}
.srcs {
margin-top: 14px;
font-size: 9.5px;
line-height: 1.6;
color: #5e5e5e;
}
.srcs b { color: #7a7a7a; font-weight: 700; }
.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-count,
.trow.has-count {
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;
}
}
@media (prefers-reduced-motion: reduce) {
.trow.flash {
animation: none;
}
.more :global(svg) {
transition-duration: 1ms;
}
}
.tno {
width: 26px;
text-align: center;
color: var(--text-muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.ttitle {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tick {
display: inline-block;
vertical-align: -1px;
color: var(--accent);
margin-left: 6px;
}
/* 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. */
.tcount {
position: relative;
display: flex;
align-items: center;
height: 21px;
border-radius: 4px;
overflow: hidden;
font-size: 12.5px;
font-variant-numeric: tabular-nums;
}
/* The number lives INSIDE the fill rather than floating over it, so
`min-width: max-content` makes every bar at least as wide as the digits
standing on it. The floor is per-row and measured by the browser in the
real font — the width JS hands over is only ever the lower bound. */
.tcount-fill {
display: flex;
align-items: center;
height: 100%;
min-width: max-content;
max-width: 100%;
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
);
}
/* A track Last.fm has no count for gets the dash and no paint at all — a
floor-width pill behind an em dash would read as a measured tiny value. */
.tcount.empty .tcount-fill {
background: none;
}
.tcount-val {
padding: 0 8px;
white-space: nowrap;
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 {
/* pointer-events too: the faded duration is a stacking context and
must never eat clicks meant for the buttons over it. */
opacity: 0;
pointer-events: none;
}
.trow.locked:hover .tdur {
opacity: 1;
pointer-events: auto;
}
.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);
}
.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);
}
/* Three buttons now reach past the duration column into the listeners
bar on counted records, so the cluster carries its own fade-in shim —
on the hover highlight it reads as the buttons simply taking over. */
.rowbtns {
padding-left: 14px;
background: linear-gradient(to right, transparent, var(--bg-highlight) 14px);
}
.trow:focus-within {
background: var(--bg-highlight);
}
.rowbtns :global(.plm-trigger) {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
color: var(--text-secondary);
}
.rowbtns :global(.plm-trigger:hover) {
color: var(--accent-hover);
background: rgba(255, 255, 255, 0.08);
}
/* While its menu is open the pointer is off in the portaled dropdown, so
hover alone would drop the cluster mid-interaction. */
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .rowbtns {
display: flex;
}
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .tdur {
opacity: 0;
pointer-events: none;
}
.trow:has(:global(.plm-trigger[aria-expanded="true"])) {
background: var(--bg-highlight);
}
@media (max-width: 820px) {
.hero {
--cover-scale: 0.62;
}
.identity {
gap: 16px;
}
h1 {
font-size: 32px;
}
h1.long {
font-size: 26px;
}
}
/* ── dark glass ────────────────────────────────────────────────────────
Most of this page already speaks the white-veil vocabulary (.iconbtn,
.pill.ghost, .gpill). The tracklist did not: hovering a row dropped an
opaque #242424 band across the pane — the third of Kyle's screenshots.
Ladder in global.css; solid mode untouched. */
:global(:root.glass) .trow:hover,
:global(:root.glass) .trow:focus-within,
:global(:root.glass) .trow:has(:global(.plm-trigger[aria-expanded="true"])) {
background: var(--glass-hover);
}
/* The row-button shim is the subtle half of that change. In solid mode
.rowbtns fades to the SAME --bg-highlight as the hover, so the cluster
reads as "the buttons take over" while quietly occluding the last ~19px
of the listeners bar behind them (measured: the bar's fill ends 15px
inside .tcount, the buttons start 34px inside it). Nothing translucent
can occlude, so under glass the bar gets out of the way instead: the
shim stops painting and .tcount fades out just before the buttons
begin. Offsets are from .tcount's RIGHT edge so they hold at any pane
width — the button cluster is a fixed 96px and .tcount's right edge is
70px from the row's, leaving the 34px start point. */
:global(:root.glass) .rowbtns {
background: none;
}
:global(:root.glass) .trow:hover .tcount,
:global(:root.glass) .trow:focus-within .tcount {
mask-image: linear-gradient(to right, #000 calc(100% - 48px), transparent calc(100% - 34px));
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 48px), transparent calc(100% - 34px));
}
:global(:root.glass) .pill:disabled {
background: var(--glass-surface);
}
/* The artwork well, when a record has no cover at all. */
:global(:root.glass) .cover.placeholder {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.11));
}
:global(:root.glass) .ed-art.placeholder {
background: var(--glass-surface);
}
/* Loading skeletons, and the two hairline-bordered tags. --border is
opaque #2a2a2a and reads as a drawn line on frost. */
:global(:root.glass) .sk {
background: rgba(255, 255, 255, 0.06);
}
:global(:root.glass) .tag-unreleased,
:global(:root.glass) .stat-note {
border-color: var(--glass-hairline);
}
</style>