Files
SoulStream/src/views/CatalogAlbum.svelte
T
KeelrLeek 122157e061 listener bars: a bar is never narrower than the number standing on it
Two faults in the tracklist's listener column, both visible on any record
with one runaway hit.

The bar was a LINEAR share of the album's biggest song, so a single
outlier took the whole column and pressed everything else into a sliver.
Weezer's Green Album is the case: Island In The Sun has 11.9x the
listeners of that record's quietest track, so nine of the ten bars lived
inside the bottom 8% of a 168px column and stopped being tellable apart.

And the fill was absolutely positioned with the number floating on top of
it, so a short bar left its own digits hanging off the end onto the row
background. Six of the ten rows on the Blue Album did this.

The share is now raised to a power below 1 (0.42). That is strictly
monotonic and still anchored to the PEAK, so the ordering survives and a
genuinely flat record still reads flat -- a min/max normalisation against
the album's own quietest track was rejected for exactly that reason, since
it would stretch Maladroit to look as varied as Green Album. Measured in
the running app, shortest bar of a 168px column:

  album                  top:bottom     before      after
  Weezer (Green Album)        11.9x        8%        38%
  Make Believe                 6.7x       15%        45%
  Weezer (Blue Album)          3.9x       26%        56%
  Weezer (White Album)         2.3x       43%        70%
  Maladroit                    2.2x       46%        72%

The flat records move least, which is the point: the curve spends its lift
on the albums that were squashed and barely touches the ones that were not.

For the second fault the number moves INSIDE the fill, and the fill gets
`min-width: max-content`. The floor is then per-row and measured by the
browser in the real font -- nothing in JS could do that honestly -- and the
width handed over is only ever a lower bound. Across the four albums above,
numbers hanging off their bar went from 6 to 0 and every bar stayed
monotonic in its count. Green Album lands at 38% rather than the 35% the
curve alone gives, because the text floor takes over on its shortest row.

A track Last.fm has no count for keeps its dash and now gets no paint at
all; a floor-width pill behind an em dash would read as a measured tiny
value rather than an absent one.

The scale moves to src/lib/listenerBars.js because the album page and the
artist page's open-in-place album panel draw this identical column, and a
second copy would quietly disagree with the first about how big a record's
hit was -- the same reason editions.js was lifted out of the artist page.
Both call sites verified over CDP; 85 JS tests pass.
2026-07-28 17:00:18 -05:00

1476 lines
51 KiB
Svelte

<script>
import {
catalog,
libraryStore,
streamSong,
saveSong,
streamCatalogAlbum,
saveCatalogAlbum,
resolver,
player,
openArtistByName,
artBroken,
coverUpgrades,
resolveCovers,
coverLoadError,
getCatalogArt,
resolveArtistSeedByName,
getCatalogArtistPage,
openCatalogAlbum,
toggleShuffle,
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 } 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 GlassBar from "../components/GlassBar.svelte";
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.
let coveredId = null;
$effect(() => {
if (album?.cover && album.id !== coveredId) {
coveredId = album.id;
resolveCovers([album], album.artist);
}
});
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());
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
// 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);
},
});
}
// ---- 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";
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) =>
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}>
<GlassBar />
{#if catalog.albumLoading}
<p class="hint">Loading album…</p>
{: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" />
{/if}
</div>
<div class="identity">
{#if coverOk}
<button
class="cover"
title="View artwork"
aria-label="View artwork full screen"
onclick={openArtwork}
>
<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={() => streamCatalogAlbum(album)}
>
<Icon name="play" size={12} /> Play
</button>
<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>
<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>
</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}
{#if info?.description}
<p class="about" class:clamped={!descExpanded}>{info.description}</p>
<button class="more" class:open={descExpanded} onclick={() => (descExpanded = !descExpanded)}>
{descExpanded ? "Show less" : "Show more"}
<Icon name="chevron-down" size={13} />
</button>
{/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">
<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>
{#if releaseDate(album) || info?.label || info?.genre}
<div class="credits">
<span class="lab">Released</span>
{#if releaseDate(album)}{releaseDate(album)}{/if}
{#if nTracks}· {nTracks}{/if}
{#if runtime}· {runtime}{/if}
{#if info?.label || info?.genre}
<br />{info.label ?? ""}{info.label && info.genre ? " · " : ""}{info.genre ?? ""}
{/if}
</div>
{/if}
{/if}
</div>
<style>
.hint {
color: var(--text-secondary);
padding-top: 10vh;
text-align: center;
}
/* ---------------------------------------------------- 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;
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%;
}
/* 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;
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;
}
/* 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 ---- */
.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 {
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);
}
.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;
}
}
</style>