5816 lines
253 KiB
Svelte
5816 lines
253 KiB
Svelte
<script>
|
||
import { onDestroy, tick, untrack } from "svelte";
|
||
import { fade, fly } from "svelte/transition";
|
||
import { quintOut } from "svelte/easing";
|
||
import {
|
||
catalog,
|
||
libraryStore,
|
||
ui,
|
||
streamSong,
|
||
saveSong,
|
||
streamCatalogAlbum,
|
||
saveCatalogAlbum,
|
||
resolver,
|
||
player,
|
||
getCatalogArt,
|
||
getCatalogHeroPreview,
|
||
getCachedCatalogHeroArt,
|
||
getCatalogHeroArt,
|
||
clearCatalogHeroArtCache,
|
||
invalidateCatalogArtist,
|
||
openCatalogArtist,
|
||
artBroken,
|
||
markArtBroken,
|
||
coverUpgrades,
|
||
resolveCovers,
|
||
coverLoadError,
|
||
leaveCoverUpgradeScope,
|
||
libraryHasAlbum,
|
||
libraryLocalAlbum,
|
||
gridCover,
|
||
tinyCover,
|
||
albumDisambiguator,
|
||
} from "../lib/state.svelte.js";
|
||
import * as api from "../lib/api.js";
|
||
import { accentFromImage } from "../lib/color.js";
|
||
import { buildArtistManifest } from "../lib/preview/manifest.js";
|
||
import { openPreview } from "../lib/preview/previewSession.svelte.js";
|
||
import { traceToSvg, svgDataUrl } from "../lib/trace.js";
|
||
import Icon from "../components/Icon.svelte";
|
||
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
|
||
|
||
const nowPlaying = (kind, id) =>
|
||
player.status !== "idle" && player.catalogRef[kind] === id;
|
||
|
||
let saved = $state({});
|
||
|
||
// A failed save flashes ✗ on the button (the resolver banner up top is
|
||
// easy to miss) before returning to the idle icon.
|
||
function flagSaveError(key) {
|
||
saved[key] = "err";
|
||
setTimeout(() => {
|
||
if (saved[key] === "err") saved[key] = "";
|
||
}, 3000);
|
||
}
|
||
|
||
// The self-titled colour ("Red Album") that identifies which record a
|
||
// release belongs to. A folded edition can carry a colour-less title
|
||
// ("Weezer (Deluxe Edition)"), so borrow the colour from any sibling in its
|
||
// family — otherwise streaming can't reject a wrong-colour peer folder and
|
||
// pulls, say, a Green Album track for a Red Album deluxe.
|
||
function familyVariant(family) {
|
||
if (!family) return "";
|
||
for (const rel of [family, ...(family.editions ?? [])]) {
|
||
const d = albumDisambiguator(rel?.title ?? "");
|
||
if (d) return d;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
// Stream/save a release using its own fields plus the family colour, so a
|
||
// colour-less edition title still resolves to the right album.
|
||
function streamRelease(release, family) {
|
||
streamCatalogAlbum({ ...release, artist: page.artist.name, variant: familyVariant(family) });
|
||
}
|
||
|
||
async function saveAlbumAction(al, family = null) {
|
||
if (saved[`a${al.id}`]) return;
|
||
saved[`a${al.id}`] = "…";
|
||
if (await saveCatalogAlbum({ ...al, variant: familyVariant(family) })) saved[`a${al.id}`] = "ok";
|
||
else flagSaveError(`a${al.id}`);
|
||
}
|
||
|
||
async function saveTrackAction(artist, t) {
|
||
if (saved[`t${t.id}`]) return;
|
||
saved[`t${t.id}`] = "…";
|
||
if (await saveSong(artist, t.title)) saved[`t${t.id}`] = "ok";
|
||
else flagSaveError(`t${t.id}`);
|
||
}
|
||
|
||
const page = $derived(catalog.artistPage);
|
||
const pageLoadStage = $derived.by(() => {
|
||
if (!page) return "empty";
|
||
if (!page.art_preview) return "complete";
|
||
if (page.albums?.length || page.popular_tracks?.length) return "overview";
|
||
if (page.hero_seed_banner || page.banners?.length || page.logos?.length) return "art";
|
||
return "shell";
|
||
});
|
||
// The catalog lands in two stages: the Deezer overview paints the grid at
|
||
// ~150ms, then the exhaustive/MusicBrainz pass replaces the release list
|
||
// ~1.8-3.6s later — usually a one or two card correction (merged editions,
|
||
// a newly classified live/compilation tab). Card motion is armed only
|
||
// AFTER that first paint, so the initial grid still appears instantly and
|
||
// without animation, and only the late correction animates: cards FLIP to
|
||
// their new positions and genuinely new ones fade in, instead of the whole
|
||
// grid snapping. Re-armed per artist.
|
||
const artistIdentity = $derived(page?.artist?.id ?? null);
|
||
let coverScopeArtistId = null;
|
||
$effect(() => {
|
||
if (artistIdentity != null) coverScopeArtistId = artistIdentity;
|
||
});
|
||
let gridAnimated = $state(false);
|
||
$effect(() => {
|
||
void artistIdentity;
|
||
gridAnimated = false;
|
||
const t = setTimeout(() => { gridAnimated = true; }, 400);
|
||
return () => clearTimeout(t);
|
||
});
|
||
|
||
const HERO_ROTATE_MS = 20_000;
|
||
const HERO_FADE_MS = 1_400;
|
||
// The preview→finished 4K swap gets its own, shorter dissolve: long enough
|
||
// to hide the LUT/vintage-fade/grain landing, short enough to feel quiet.
|
||
const HERO_FINISH_FADE_MS = 650;
|
||
let heroSelection = $state({ artistId: null, fingerprint: "", index: 0 });
|
||
let failedBannerSources = $state([]);
|
||
let displayedBanner = $state({ source: "", url: null, key: "" });
|
||
let previousBanner = $state(null);
|
||
let initialBannerPaint = $state(true);
|
||
let finishBannerPaint = $state(false);
|
||
let bannerTransitionTimer;
|
||
let heroLoadGeneration = 0;
|
||
let heroLoadInFlight = false;
|
||
let heroUpscaler = $state(null);
|
||
let gpuFallbackSources = $state([]);
|
||
api.getSettings().then(
|
||
(settings) => (heroUpscaler = settings?.artist_hero_upscaler ?? "live_gpu"),
|
||
() => (heroUpscaler = "live_gpu"),
|
||
);
|
||
|
||
// Recreating the interval on visibility return gives the current photo a
|
||
// fresh full dwell instead of rotating immediately.
|
||
const documentVisible = $derived(ui.windowActive);
|
||
|
||
// Metadata supplies only the highest-quality provider tier that produced
|
||
// art (Fanart, else TheAudioDB, else hardened Bing). De-duplicate defensively so
|
||
// a provider repeating a URL never makes the carousel appear to stall.
|
||
const heroSources = $derived.by(() => {
|
||
const finalCandidates = page?.banners?.length
|
||
? page.banners
|
||
: page?.banner
|
||
? [page.banner]
|
||
: [];
|
||
const candidates = finalCandidates.length
|
||
? finalCandidates
|
||
: page?.hero_seed_banner
|
||
? [page.hero_seed_banner]
|
||
: [];
|
||
return [...new Set(candidates.filter(Boolean))];
|
||
});
|
||
const heroSetProvisional = $derived(
|
||
Boolean(page?.hero_seed_banner && !page?.banners?.length && !page?.banner_custom),
|
||
);
|
||
function heroSourceKey(source) {
|
||
if (!source?.startsWith("data:")) return source ?? "";
|
||
// Custom heroes can be tens of MiB as base64. Never concatenate that
|
||
// payload into multiple fingerprint/transition strings. A fixed sample
|
||
// across the full value distinguishes replacements without rescanning
|
||
// or copying the entire source on every staged page update.
|
||
let hash = 2166136261;
|
||
const stride = Math.max(1, Math.floor(source.length / 128));
|
||
for (let i = 0; i < source.length; i += stride) {
|
||
hash ^= source.charCodeAt(i);
|
||
hash = Math.imul(hash, 16777619);
|
||
}
|
||
return `data:${source.length}:${(hash >>> 0).toString(36)}`;
|
||
}
|
||
const heroFingerprint = $derived(
|
||
`${page?.artist.id ?? ""}:${heroSources.map(heroSourceKey).join("|")}`,
|
||
);
|
||
const selectedHeroSource = $derived(heroSources[heroSelection.index] ?? "");
|
||
const selectedUsesGpu = $derived(
|
||
heroUpscaler === "live_gpu"
|
||
&& selectedHeroSource.startsWith("https://")
|
||
&& !gpuFallbackSources.includes(selectedHeroSource),
|
||
);
|
||
const gpuRendererMounted = $derived(
|
||
heroUpscaler === "live_gpu"
|
||
&& !page?.banner_custom
|
||
// Compile during the staged art search so Bing latency still overlaps
|
||
// shader setup, then release the context if the completed artist truly
|
||
// has no hero.
|
||
&& (Boolean(page?.art_preview) || heroSources.length > 0),
|
||
);
|
||
|
||
|
||
// A fresh artist visit starts on a random photo. Rotation then walks the
|
||
// quality-ranked list from that point, without repeating the current one.
|
||
$effect(() => {
|
||
const artistId = page?.artist.id ?? null;
|
||
const fingerprint = heroFingerprint;
|
||
if (
|
||
heroSelection.artistId !== artistId
|
||
|| heroSelection.fingerprint !== fingerprint
|
||
) {
|
||
const artistChanged = heroSelection.artistId !== artistId;
|
||
const displayedIndex = artistChanged
|
||
? -1
|
||
: heroSources.indexOf(untrack(() => displayedBanner.source));
|
||
failedBannerSources = [];
|
||
gpuFallbackSources = [];
|
||
heroSelection = {
|
||
artistId,
|
||
fingerprint,
|
||
// A provisional Bing seed frequently belongs to the final set.
|
||
// Keep that already-painted source instead of randomizing and
|
||
// decoding another image when exhaustive discovery completes.
|
||
index: displayedIndex >= 0
|
||
? displayedIndex
|
||
: heroSources.length
|
||
? Math.floor(Math.random() * heroSources.length)
|
||
: 0,
|
||
};
|
||
if (artistChanged) {
|
||
clearTimeout(bannerTransitionTimer);
|
||
displayedBanner = { source: "", url: null, key: "" };
|
||
previousBanner = null;
|
||
initialBannerPaint = true;
|
||
finishBannerPaint = false;
|
||
}
|
||
}
|
||
});
|
||
|
||
function advanceHero(fromSource = displayedBanner.source) {
|
||
if (heroSources.length < 2 || heroLoadInFlight) return;
|
||
const displayedIndex = heroSources.indexOf(fromSource);
|
||
const currentIndex = displayedIndex >= 0 ? displayedIndex : heroSelection.index;
|
||
for (let offset = 1; offset <= heroSources.length; offset++) {
|
||
const index = (currentIndex + offset) % heroSources.length;
|
||
if (!failedBannerSources.includes(heroSources[index])) {
|
||
heroSelection = { ...heroSelection, index };
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
$effect(() => {
|
||
const fingerprint = heroFingerprint;
|
||
const count = heroSources.length;
|
||
const custom = page?.banner_custom;
|
||
const visible = documentVisible;
|
||
if (!visible || count < 2 || custom) return;
|
||
const timer = setInterval(advanceHero, HERO_ROTATE_MS);
|
||
return () => clearInterval(timer);
|
||
});
|
||
|
||
function gpuHeroLoading(source) {
|
||
if (source === selectedHeroSource) heroLoadInFlight = true;
|
||
}
|
||
|
||
|
||
function gpuHeroReady(source, result) {
|
||
if (source !== selectedHeroSource || !selectedUsesGpu) return;
|
||
const prior = displayedBanner;
|
||
previousBanner = prior.url && prior.kind !== "gpu" ? prior : null;
|
||
displayedBanner = {
|
||
source,
|
||
url: `gpu:${heroSourceKey(source)}`,
|
||
key: `${heroSourceKey(source)}#gpu`,
|
||
kind: "gpu",
|
||
// Keep the preview's checksum-bound crop plan stable. Dropping it
|
||
// changed the child prop to null and caused a second render (and,
|
||
// for large payloads, a second backend decode/raw IPC) of the same
|
||
// hero immediately after the first one completed.
|
||
plan: prior.source === source ? prior.plan ?? null : null,
|
||
};
|
||
heroLoadInFlight = false;
|
||
initialBannerPaint = !prior.url;
|
||
finishBannerPaint = false;
|
||
clearTimeout(bannerTransitionTimer);
|
||
if (previousBanner) {
|
||
bannerTransitionTimer = setTimeout(() => {
|
||
previousBanner = null;
|
||
}, 650);
|
||
}
|
||
document.documentElement.dataset.heroGpuMs = result.renderMs.toFixed(2);
|
||
}
|
||
|
||
function gpuHeroError(source) {
|
||
if (source !== selectedHeroSource) return;
|
||
if (!gpuFallbackSources.includes(source)) {
|
||
gpuFallbackSources = [...gpuFallbackSources, source];
|
||
}
|
||
heroLoadInFlight = false;
|
||
}
|
||
|
||
// Remote heroes use a dedicated Rust proxy: it avoids Fanart CDN stalls in
|
||
// WebView2 and finishes accepted 720p fallbacks at 4K through the selected
|
||
// local upscaler. The old photo remains visible until the next is decoded.
|
||
// User-imported data URLs remain direct and incur no copy.
|
||
$effect(() => {
|
||
// Depend on the stable primitive index/fingerprint, not the derived
|
||
// array object. The fast art preview and full page contain the same URLs
|
||
// but replace the page object; tracking the array used to restart this
|
||
// download despite an identical selected source.
|
||
const selectedIndex = heroSelection.index;
|
||
const fingerprint = heroFingerprint;
|
||
const availableSources = untrack(() => heroSources);
|
||
const source = availableSources[selectedIndex] ?? "";
|
||
const sourceKey = heroSourceKey(source);
|
||
const provisionalSet = heroSetProvisional;
|
||
const upscaler = heroUpscaler;
|
||
const gpuFallbackOnly =
|
||
upscaler === "live_gpu"
|
||
&& source.startsWith("https://")
|
||
&& gpuFallbackSources.includes(source);
|
||
if (!upscaler) return;
|
||
if (
|
||
upscaler === "live_gpu"
|
||
&& source.startsWith("https://")
|
||
&& !gpuFallbackOnly
|
||
) {
|
||
// Live GPU owns the first visible sharp frame, including a fast
|
||
// Bing seed. The blurred colour field remains underneath while the
|
||
// payload is prepared, but never paint the ungraded HD preview.
|
||
heroLoadInFlight = true;
|
||
return;
|
||
}
|
||
const loadGeneration = ++heroLoadGeneration;
|
||
heroLoadInFlight = true;
|
||
let active = true;
|
||
if (!source) {
|
||
heroLoadInFlight = false;
|
||
displayedBanner = { source: "", url: null, key: "" };
|
||
previousBanner = null;
|
||
return;
|
||
}
|
||
(async () => {
|
||
try {
|
||
const readyImage = async (promise, kind, imageSource = source) => {
|
||
const url = await promise;
|
||
if (!url) throw new Error("hero image unavailable");
|
||
await loadImage(url);
|
||
return { kind, url, source: imageSource };
|
||
};
|
||
// Keep the expensive 4K finisher lazy on first paint. Once the
|
||
// shared raw transfer has produced and decoded the HD preview,
|
||
// begin finishing on the next frame; this avoids CPU/base64
|
||
// pressure during the exact frame the user is waiting to see.
|
||
// A provisional preview may be promoted after the final tier
|
||
// confirms the same source. Reuse its checksum-bound crop plan
|
||
// so preview and 4K never reframe or rerun detection.
|
||
let cropPlan = untrack(() =>
|
||
displayedBanner.source === source ? displayedBanner.plan ?? null : null,
|
||
);
|
||
let previewGenerated = false;
|
||
let finishedReady;
|
||
const finish = () =>
|
||
(finishedReady ??= readyImage(
|
||
source.startsWith("data:")
|
||
? Promise.resolve(source)
|
||
: getCatalogHeroArt(source, cropPlan),
|
||
"finished",
|
||
));
|
||
|
||
let ready;
|
||
// On first paint, show the fast pre-cropped provider preview.
|
||
// Rotations keep the old finished hero visible and can go
|
||
// straight to the final render without a preview.
|
||
// Reading displayedBanner reactively here used to invalidate
|
||
// this effect the instant the preview arrived, canceling the
|
||
// original finisher and launching the same hero work again.
|
||
const firstPaint = !untrack(() => displayedBanner.url);
|
||
if (gpuFallbackOnly) {
|
||
const preview = await getCatalogHeroPreview(source);
|
||
if (!preview?.data_url) {
|
||
throw new Error("hero preview unavailable");
|
||
}
|
||
cropPlan = preview.plan ?? null;
|
||
ready = await readyImage(
|
||
Promise.resolve(preview.data_url),
|
||
"preview",
|
||
);
|
||
} else if (!source.startsWith("data:") && firstPaint) {
|
||
const previewReady = (async () => {
|
||
const preview = await getCatalogHeroPreview(source);
|
||
if (!preview?.data_url) {
|
||
throw new Error("hero preview unavailable");
|
||
}
|
||
previewGenerated = true;
|
||
cropPlan = preview.plan ?? null;
|
||
return readyImage(
|
||
Promise.resolve(preview.data_url),
|
||
"preview",
|
||
);
|
||
})();
|
||
const cachedReady = (async () => {
|
||
// Start from the randomized selection, then scan this
|
||
// same provider tier sequentially. Only one large data
|
||
// URL is read/decoded, while any already-finished image
|
||
// can make a repeat visit independent of CDN latency.
|
||
for (let offset = 0; offset < availableSources.length; offset++) {
|
||
const index = (selectedIndex + offset) % availableSources.length;
|
||
const candidate = availableSources[index];
|
||
const cached = await getCachedCatalogHeroArt(candidate);
|
||
if (cached) {
|
||
return readyImage(
|
||
Promise.resolve(cached),
|
||
"finished",
|
||
candidate,
|
||
);
|
||
}
|
||
}
|
||
throw new Error("no cached hero");
|
||
})();
|
||
try {
|
||
// A repeat visit can paint the finished 4K disk cache
|
||
// before the provider CDN returns. A miss fails this
|
||
// branch immediately and leaves the preview untouched.
|
||
ready = await new Promise((resolve, reject) => {
|
||
let failures = 0;
|
||
const failed = () => {
|
||
failures += 1;
|
||
if (failures === 2) {
|
||
reject(new Error("hero image unavailable"));
|
||
}
|
||
};
|
||
previewReady.then(resolve, failed);
|
||
cachedReady.then(resolve, failed);
|
||
});
|
||
} catch {
|
||
if (!previewGenerated) {
|
||
// The backend could not fetch/decode the source.
|
||
// Retrying the same dead URL through the expensive
|
||
// 4K command only adds another transfer timeout.
|
||
throw new Error("hero preview source unavailable");
|
||
}
|
||
// The finished path is still a useful fallback if the
|
||
// browser alone could not decode a generated preview.
|
||
ready = await finish();
|
||
}
|
||
} else {
|
||
ready = await finish();
|
||
}
|
||
if (!active || heroFingerprint !== fingerprint) return;
|
||
|
||
if (ready.kind === "preview") {
|
||
initialBannerPaint = firstPaint;
|
||
finishBannerPaint = false;
|
||
displayedBanner = {
|
||
source,
|
||
url: ready.url,
|
||
key: `${sourceKey}#preview`,
|
||
plan: cropPlan,
|
||
};
|
||
if (gpuFallbackOnly || provisionalSet) {
|
||
// This source won the speed race, not the final provider
|
||
// tier, or its GPU render genuinely failed. Keep its HD
|
||
// fallback visible without starting a second finisher.
|
||
return;
|
||
}
|
||
// Preserve the quick preview if finishing fails. The proxy
|
||
// is already a native HD image, so a processing problem
|
||
// should not blank an otherwise valid artist hero.
|
||
try {
|
||
await new Promise(requestAnimationFrame);
|
||
if (!active || heroFingerprint !== fingerprint) return;
|
||
const finished = await finish();
|
||
if (!active || heroFingerprint !== fingerprint) return;
|
||
// Crossfade the graded 4K hero OVER the preview rather
|
||
// than hard-swapping the src: the Ruby Chrome LUT,
|
||
// vintage fade and grain then dissolve in over a short
|
||
// window instead of popping in a single frame. The
|
||
// preview is held underneath until the fade completes.
|
||
previousBanner = displayedBanner.url ? displayedBanner : null;
|
||
initialBannerPaint = false;
|
||
finishBannerPaint = true;
|
||
displayedBanner = {
|
||
source,
|
||
url: finished.url,
|
||
key: `${sourceKey}#finished`,
|
||
};
|
||
clearTimeout(bannerTransitionTimer);
|
||
bannerTransitionTimer = setTimeout(() => {
|
||
previousBanner = null;
|
||
}, HERO_FINISH_FADE_MS);
|
||
} catch {
|
||
// Pre-cropped preview remains visible.
|
||
}
|
||
return;
|
||
}
|
||
|
||
previousBanner = displayedBanner.url ? displayedBanner : null;
|
||
initialBannerPaint = firstPaint;
|
||
finishBannerPaint = false;
|
||
displayedBanner = {
|
||
source: ready.source,
|
||
url: ready.url,
|
||
key: `${heroSourceKey(ready.source)}#${loadGeneration}`,
|
||
};
|
||
clearTimeout(bannerTransitionTimer);
|
||
bannerTransitionTimer = setTimeout(() => {
|
||
previousBanner = null;
|
||
}, HERO_FADE_MS);
|
||
} catch {
|
||
if (!active || heroFingerprint !== fingerprint) return;
|
||
if (!failedBannerSources.includes(source)) {
|
||
failedBannerSources = [...failedBannerSources, source];
|
||
}
|
||
if (heroLoadGeneration === loadGeneration) {
|
||
heroLoadInFlight = false;
|
||
}
|
||
advanceHero(source);
|
||
} finally {
|
||
if (heroLoadGeneration === loadGeneration) {
|
||
heroLoadInFlight = false;
|
||
}
|
||
}
|
||
})();
|
||
return () => (active = false);
|
||
});
|
||
|
||
const banner = $derived(displayedBanner.kind === "gpu" ? null : displayedBanner.url);
|
||
const heroPainted = $derived(Boolean(displayedBanner.url));
|
||
// Popular songs from Deezer's play-ranked /top. Show five, Spotify-style,
|
||
// with a "Show more" that reveals the rest (up to ten). Collapse again when
|
||
// the artist changes so a new page never opens pre-expanded.
|
||
let showAllPopular = $state(false);
|
||
const allPopular = $derived(page?.popular_tracks?.slice(0, 10) ?? []);
|
||
const popularTracks = $derived(showAllPopular ? allPopular : allPopular.slice(0, 5));
|
||
$effect(() => {
|
||
void artistIdentity;
|
||
showAllPopular = false;
|
||
});
|
||
|
||
// Preview-tracks launcher. Capability is read straight off the ranked set
|
||
// the page already holds — Deezer ships each track's 30-second clip URL in
|
||
// the same payload as the track itself, so there is no separate probe and
|
||
// no window where the count is knowable but wrong. The doc's `unknown`
|
||
// state cannot occur here: the whole action row is gated on
|
||
// popularTracks.length, so by the time this control exists the tracks (and
|
||
// therefore their preview fields) have landed.
|
||
const previewableCount = $derived(allPopular.filter((track) => track.preview).length);
|
||
const previewState = $derived(
|
||
previewableCount === 0
|
||
? "none"
|
||
: previewableCount < allPopular.length
|
||
? "partial"
|
||
: "available",
|
||
);
|
||
const previewLabel = $derived(
|
||
previewState === "none"
|
||
? "No previews available for this artist"
|
||
: previewState === "partial"
|
||
? `Preview tracks — ${previewableCount} of ${allPopular.length} available`
|
||
: "Preview tracks",
|
||
);
|
||
/** Open the preview modal over the COMPLETE ranked top-track payload —
|
||
* never just the rendered rows (design §13, artist adapter). The manifest
|
||
* is a snapshot: later staged-page rebuilds cannot rewrite an open
|
||
* session. Play-full hands the active item to the ordinary resolver
|
||
* pipeline via the same path the Popular rows use. */
|
||
function openPreviews() {
|
||
if (!page?.popular_tracks?.length) return;
|
||
const snapshotTracks = page.popular_tracks.slice(0, 10);
|
||
const byProviderId = new Map(snapshotTracks.map((t) => [String(t.id), t]));
|
||
const manifest = buildArtistManifest({
|
||
artist: {
|
||
id: page.artist.id,
|
||
name: page.artist.name,
|
||
picture: page.artist.picture ?? null,
|
||
},
|
||
popularTracks: snapshotTracks,
|
||
createdAtMs: Date.now(),
|
||
});
|
||
void openPreview(manifest, {
|
||
playFull: (item) => {
|
||
const track = byProviderId.get(item.sources[0]?.providerTrackId);
|
||
if (track) playPopular(track);
|
||
},
|
||
});
|
||
}
|
||
|
||
// Instant-shell seed: the search portrait the user clicked, shown as a
|
||
// heavily-blurred colour field behind the hero until the real photo
|
||
// decodes, then faded out (never blanked). It is presentation-only — never
|
||
// a hero source. When there's no portrait, a deterministic muted gradient
|
||
// (hue from the artist) fills the same geometry as a no-photo fallback.
|
||
const seedUrl = $derived(page?.placeholder_picture ?? null);
|
||
const seedHue = $derived(hueFromKey(page?.artist?.id ?? page?.artist?.name ?? ""));
|
||
|
||
// ---- artist logo: pick the cleanest "monochrome wordmark" and tint it ----
|
||
// Fanart gives several logo candidates; we score each by how single-coloured
|
||
// it is (the share of opaque pixels in one dominant colour). A clean wordmark
|
||
// masks perfectly to the accent; a multi-colour "art" logo would flatten to a
|
||
// blob, so if none score well we show the most-liked one in full colour.
|
||
// (Both the mask and the canvas sampling need same-origin pixels, so the PNGs
|
||
// are proxied through Rust to data: URLs — Fanart sends no CORS headers.)
|
||
//
|
||
// The winner is then TRACED to vector (see lib/trace.js). Fanart's logos are
|
||
// a fixed 800x310 canvas, so a short wordmark (Weezer's ink is 131px tall)
|
||
// used to be pinned at its native height to avoid upscaling blur — which
|
||
// made it read far smaller than a tall logo. The trace removes that ceiling:
|
||
// the silhouette is crisp at any size, so every logo can reach one height.
|
||
// The tracer recovers smooth sub-pixel edges FROM a source's anti-aliasing.
|
||
// Some providers (TheAudioDB) deliver a hard 1-bit alpha logo — no AA at all
|
||
// — so every edge lands on a pixel boundary and the trace staircases. When a
|
||
// logo has almost no partial-alpha pixels, synthesise the missing AA:
|
||
// supersample to LOGO_TRACE_EDGE and blur, turning hard steps into gradients
|
||
// the trace reads as smooth curves. Logos that already carry AA (Fanart)
|
||
// fail this test and trace natively, exactly as before.
|
||
const LOGO_AA_MIN = 0.02; // partial-alpha share below which a source is "hard"
|
||
const LOGO_TRACE_EDGE = 2400; // supersample target long edge for hard sources
|
||
const LOGO_TRACE_MAX_SCALE = 4;
|
||
const LOGO_TRACE_BLUR = 1.0; // blur px per unit of supersample scale
|
||
const LOGO_MONO_MIN = 0.55; // dominant-colour share to accept for tinting
|
||
// A logo can trade this much cleanliness for a wider, more title-like shape.
|
||
// It must still pass LOGO_MONO_MIN; this only ranks already-good candidates.
|
||
const LOGO_MONO_TOLERANCE = 0.08;
|
||
const LOGO_MAX = 8; // candidates to consider across all providers
|
||
|
||
// Sizing: match logos by AREA, not by height. Wordmarks differ wildly in
|
||
// proportion (Radiohead is 7.9:1, Aphex Twin is 1:1), so a common height
|
||
// makes the wide ones enormous — same letter height, five times the ink on
|
||
// screen. Holding width*height constant instead gives every artist the same
|
||
// visual weight; height falls out as sqrt(AREA / aspect), clamped so a very
|
||
// wide logo can't get spindly nor a square one tower over the hero.
|
||
const LOGO_AREA = 120_000; // px^2 of box the logo should occupy
|
||
const LOGO_MIN_H = 110;
|
||
const LOGO_MAX_H = 200;
|
||
// The equal-area values above describe the approved full-size hero. Scale
|
||
// both logo dimensions with the hero width so its area shrinks at the same
|
||
// rate as the banner's visible area when the content pane gets narrower.
|
||
const LOGO_REFERENCE_W = 2000;
|
||
const LOGO_MIN_SCALE = 0.22;
|
||
const COMPACT_LOGO_MAX_ASPECT = 2.25;
|
||
const COMPACT_LOGO_MIN_LONG_EDGE = 160;
|
||
let heroWidth = $state(LOGO_REFERENCE_W);
|
||
const logoScale = $derived(
|
||
Math.min(1, Math.max(LOGO_MIN_SCALE, heroWidth / LOGO_REFERENCE_W)),
|
||
);
|
||
|
||
const logoHeight = (aspect) =>
|
||
Math.round(
|
||
Math.min(LOGO_MAX_H, Math.max(LOGO_MIN_H, Math.sqrt(LOGO_AREA / aspect))),
|
||
);
|
||
|
||
// logo.mode: "none" (text title) | "mask" (traced, accent-tinted) | "color"
|
||
// (the most-liked PNG, full colour). `aspect` is the ink's true aspect ratio,
|
||
// which drives the box so nothing is distorted or padded.
|
||
let logo = $state({ mode: "none", url: null, aspect: 3, height: LOGO_MAX_H });
|
||
// Internal request key only; making this reactive would invalidate and
|
||
// cancel the very async logo pass it is meant to de-duplicate.
|
||
let logoFingerprint = "";
|
||
let logoRequest = 0;
|
||
onDestroy(() => {
|
||
// Stop an off-screen candidate pass from launching more downloads or
|
||
// tracing work after the artist view has been left.
|
||
logoRequest += 1;
|
||
clearTimeout(bannerTransitionTimer);
|
||
});
|
||
const logoRenderHeight = $derived.by(() => {
|
||
const responsiveHeight = logo.height * logoScale;
|
||
// Compact marks become visually tiny much sooner than wordmarks. Give
|
||
// only <=2.25:1 logos a minimum long edge; wide marks never hit this
|
||
// branch and continue to follow the hero-width scale unchanged.
|
||
const compactFloor =
|
||
logo.aspect <= COMPACT_LOGO_MAX_ASPECT
|
||
? COMPACT_LOGO_MIN_LONG_EDGE / logo.aspect
|
||
: 0;
|
||
return Math.round(Math.max(responsiveHeight, compactFloor));
|
||
});
|
||
|
||
function measureHero(node) {
|
||
const update = () => {
|
||
if (node.clientWidth > 0) heroWidth = node.clientWidth;
|
||
};
|
||
update();
|
||
const observer = new ResizeObserver(update);
|
||
observer.observe(node);
|
||
return { destroy: () => observer.disconnect() };
|
||
}
|
||
|
||
/* ── Hero → banner ─────────────────────────────────────────────────────
|
||
The hero does not scroll away and is not covered: it transitions the
|
||
whole way down and what is left of it at full scroll IS the banner.
|
||
There is no separate banner element.
|
||
|
||
ONE number drives all of it — `--p`, the ride-up progress, written to
|
||
.artist-page on scroll and rAF-throttled. Every offset, scale, fade and
|
||
gradient stop in the CSS below is a calc() off it, so there are no
|
||
keyframes to fall out of sync and no second source of truth. `--p` is 0
|
||
at the top of the page and every layer scroll adds is switched off
|
||
there, so the resting page is provably today's page rather than an
|
||
identity transform of it. */
|
||
|
||
// Where the glass bar's bottom edge sits once stuck, in .artist-page's own
|
||
// coordinates — the SAME frame BAND_TOP is expressed in, which is the only
|
||
// thing that makes the subtraction below meaningful. The bar pins at
|
||
// top:-24px and is 46px tall, but a sticky offset is measured from the
|
||
// pane's PADDING box, and that padding is the 24px the -24 cancels: the
|
||
// bar therefore lands flush at the top of .artist-page and its bottom edge
|
||
// is simply its own height. Measured at every scroll position to confirm.
|
||
//
|
||
// Using the scrollport figure (22) instead reads as a plausible constant
|
||
// and is wrong by exactly that 24px: it shortens --strip, lifts the whole
|
||
// banner, and parks the wordmark 14px BEHIND the glass bar.
|
||
const BAR_BOTTOM = 46;
|
||
// .hero-bg's own offset inside the pin — byte-for-byte the offset it has
|
||
// today, which is what makes --p: 0 pixel-identical to the current page.
|
||
// Keep in sync with .hero-bg's `top` and --band-top.
|
||
const BAND_TOP = -24;
|
||
// ROUND 9, option 1 "Softer". Nothing here is a fixed pixel count any more:
|
||
// the banner is a fraction of the hero (which itself tracks the pane), the
|
||
// wordmark a fraction of the banner, and the three gaps fractions of the
|
||
// banner. A fixed 124px banner and 26px wordmark turned into a strip with a
|
||
// speck of a logo on a full-size window, because they were the only things
|
||
// in the design that did not scale.
|
||
// The banner fraction now sizes only the parked wordmark INK (via
|
||
// MARK_OF_BANNER) — the parked layout itself is derived top-down from the
|
||
// glass bar in measure() (round 12 glue).
|
||
const BANNER_OF_HERO = 0.28, BANNER_MIN = 176, BANNER_MAX = 240;
|
||
const MARK_OF_BANNER = 0.28, MARK_MIN = 34, MARK_MAX = 62;
|
||
// The parked wordmark's clearance below the glass bar — the user asked
|
||
// for the same 28px the artist page keeps at its sides.
|
||
const PAD_TOP_PARKED = 28;
|
||
const clampPx = (lo, v, hi) => Math.round(Math.max(lo, Math.min(hi, v)));
|
||
|
||
// Narrow panes SHED chips rather than letting the parked row overflow: the
|
||
// row has to stay one line to fit a 124px banner, so something has to
|
||
// give and it should be the least important stat. Thresholds are the
|
||
// mock's window presets measured at the content width.
|
||
const chipTier = $derived(heroWidth < 460 ? "xs" : heroWidth < 640 ? "s" : "l");
|
||
|
||
// Re-measure hook, published by the action below so the reactive graph can
|
||
// poke it when the wordmark changes identity or size.
|
||
let remeasureHero = () => {};
|
||
// Off at rest, on the moment the page moves. Reactive rather than a raw
|
||
// dataset write so Svelte can see the [data-scroll] selectors are live and
|
||
// keeps them; it flips at most twice over the whole travel.
|
||
let heroScrolled = $state(false);
|
||
|
||
function heroScroll(pin) {
|
||
const page = pin.parentElement;
|
||
const band = pin.querySelector(".hero-bg");
|
||
let scroller = page?.parentElement ?? null;
|
||
while (scroller) {
|
||
const overflowY = getComputedStyle(scroller).overflowY;
|
||
if (overflowY === "auto" || overflowY === "scroll") break;
|
||
scroller = scroller.parentElement;
|
||
}
|
||
if (!page || !band || !scroller) return;
|
||
|
||
// Compositor-driven fade line where supported (Chromium 115+). When
|
||
// it is, apply() never touches --flow-cut: the mask is a CONSTANT on
|
||
// the sticky .scrollbody and a composited scroll-driven transform on
|
||
// .fade-flow carries the counter-motion — so a fast scroll can't
|
||
// outrun the mask. Must match the CSS @supports gate exactly.
|
||
const scrollDriven =
|
||
typeof CSS !== "undefined" && CSS.supports("animation-timeline", "scroll()");
|
||
|
||
// Infinity until measured, and again if there is somehow no room to
|
||
// ride up in: --p then stays 0 and the page is simply the page it is
|
||
// today, which is the right way for this to fail.
|
||
let travel = Infinity;
|
||
let previous = -1;
|
||
let frame = 0;
|
||
// The constant part of the content's flow-local fade line — written
|
||
// by measure(), consumed every frame by apply().
|
||
let stripCut = -99999;
|
||
|
||
function measure() {
|
||
const zone = page.querySelector(".hero-zone");
|
||
if (!zone) return;
|
||
// NEVER measure the pins themselves. They are `position: sticky`,
|
||
// and Chromium reports a stuck element's shifted position through
|
||
// offsetTop as well as through its rect — so deriving a pin's own
|
||
// sticky `top` from its own offsetTop is a feedback loop. An
|
||
// earlier cut of this did exactly that and the pins walked down the
|
||
// page a few hundred pixels per reflow (measured: top: 548px,
|
||
// --strip: -94px, mask stops going negative and blanking the hero).
|
||
//
|
||
// Nothing here needs it. The pins are the first children of
|
||
// .artist-page, so their flow offset is 0 and their sticky offset
|
||
// is the constant 0 in CSS; the band therefore lands on exactly the
|
||
// pixels .hero-bg occupies today and is held there. Everything else
|
||
// is measured from .hero-zone, which is `position: relative` and
|
||
// cannot lie.
|
||
//
|
||
// Measured from the TOP OF THE BAND, so these compare directly
|
||
// against band-local gradient stops.
|
||
const span = zone.offsetTop + zone.offsetHeight - BAND_TOP;
|
||
// Constant by construction: the band's top sits at scrollport
|
||
// -24px (BAND_TOP) and the glass bar's stuck bottom at +22px
|
||
// (BAR_BOTTOM), so the bar clears the band's top by 46px and the
|
||
// banner ends 124px below that.
|
||
const banner = clampPx(BANNER_MIN, span * BANNER_OF_HERO, BANNER_MAX);
|
||
// markPx, NOT mark: the block below already binds `mark` to the
|
||
// logo ELEMENT, and shadowing it there made `markHeight > mark`
|
||
// compare a number with a DOM node - silently false, so
|
||
// --mark-end stayed 1 and the wordmark never scaled at all.
|
||
const markPx = clampPx(MARK_MIN, banner * MARK_OF_BANNER, MARK_MAX);
|
||
|
||
// ROUND 12 GLUE (user request): the identity never moves relative
|
||
// to the content. The wordmark shrinks about its own resting
|
||
// bottom, the Play button shrinks inside its resting slot, and
|
||
// identity + content ride as ONE rigid block until the park; only
|
||
// past 100% do the rows scroll on and fade out under the parked
|
||
// identity. The parked layout is therefore derived TOP-DOWN from
|
||
// the glass bar — padTop equal to the pane's 28px side padding
|
||
// ("make the logo padding equal from the top the same as it is
|
||
// from the sides") — instead of bottom-up from a banner fraction,
|
||
// which dumped its slack into a 63px gap under the bar.
|
||
|
||
// The wordmark's resting bottom and parked ink first: the park
|
||
// point (--strip) is built from them. Measured, not assumed — the
|
||
// narrow-width media query changes .hero-copy's bottom padding.
|
||
const copy = page.querySelector(".hero-copy");
|
||
const slot = page.querySelector(".logo-slot");
|
||
let markRest = 16;
|
||
let inkPark = markPx;
|
||
if (copy && slot) {
|
||
markRest = copy.offsetHeight - slot.offsetTop - slot.offsetHeight;
|
||
page.style.setProperty("--mark-rest", `${markRest}px`);
|
||
// offsetHeight, not a rect: the slot carries the ride-up scale
|
||
// while scrolled and a rect would feed that back into itself.
|
||
const mark = slot.querySelector(".logo-wrap") ?? slot.querySelector(".name-title");
|
||
const markHeight = mark?.offsetHeight ?? 0;
|
||
page.style.setProperty(
|
||
"--mark-end",
|
||
markHeight > markPx ? (markPx / markHeight).toFixed(4) : "1",
|
||
);
|
||
inkPark = markHeight > markPx ? markPx : markHeight || markPx;
|
||
}
|
||
// The Play/chips row's resting height, from the hole it leaves in
|
||
// .popular, so the pinned row and its hole can never drift apart.
|
||
const gap = page.querySelector(".actions-gap");
|
||
const rowH = gap?.offsetHeight ?? 76;
|
||
page.style.setProperty("--row-h", `${rowH}px`);
|
||
|
||
// The park point: the content edge stops exactly where the shrunk
|
||
// wordmark's top lands PAD_TOP_PARKED below the glass bar with the
|
||
// identity still in its resting slots.
|
||
const strip =
|
||
BAR_BOTTOM - BAND_TOP + PAD_TOP_PARKED + inkPark + markRest;
|
||
page.style.setProperty("--banner-h", `${banner}px`);
|
||
// Glue, not travel. --lift cancels .hero-copy's parked term
|
||
// exactly, so its bottom stays 0 at every p — the wordmark
|
||
// shrinks in place about its resting baseline. The row needs no
|
||
// travel var at all any more: its box is pinned to its resting
|
||
// slot in CSS.
|
||
page.style.setProperty("--lift", `${banner - 14 - markRest}px`);
|
||
|
||
// Where the pins sit in flow. .results-head's net flow height is
|
||
// zero (-24 + 46 - 22), so the hero zone starts at exactly the
|
||
// same offset the pins do — and it is `position: relative`, so
|
||
// asking it cannot feed a sticky offset back into the measurement
|
||
// the way asking a pin would.
|
||
page.style.setProperty("--pin-origin", `${zone.offsetTop}px`);
|
||
page.style.setProperty("--hero-span", `${span}px`);
|
||
page.style.setProperty("--strip", `${strip}px`);
|
||
travel = span - strip > 1 ? span - strip : Infinity;
|
||
|
||
// ROUND 12 — the sheet's two poses, both in band coordinates.
|
||
// At rest its gradient sits on the VISIBLE hero (onset at 60% of
|
||
// the zone, spanning 42% of it — the live page's look; anchoring
|
||
// to the artwork box instead slid the dissolve below the fold,
|
||
// the bug the user caught against live screenshots). Parked, its
|
||
// top rides fogAbove px above the content edge — the pose the
|
||
// user approved. measure() only sizes the poses; the glide
|
||
// between them is CSS calc on --p, a rigid translation.
|
||
const fogSpan = Math.round(span * 0.42);
|
||
const fogAbove = Math.round(fogSpan * 0.294);
|
||
const fogRest = Math.round(span * 0.6);
|
||
page.style.setProperty("--fog-span", `${fogSpan}px`);
|
||
page.style.setProperty("--fog-above", `${fogAbove}px`);
|
||
page.style.setProperty("--fog-rest", `${fogRest}px`);
|
||
|
||
// The content's fade line, precomputed so apply() is arithmetic
|
||
// only: flow-local cut = stripCut + scrollTop. The zone must sit
|
||
// in the ONLY gap the parked layout leaves: between the pills'
|
||
// real bottom edge and the parked Popular heading (~24px). So the
|
||
// anchor is the MEASURED Play button bottom, not a derivation —
|
||
// three earlier zones each missed by construction (row-box
|
||
// bottom: covers half-visible behind the pills; row-box top: rows
|
||
// fully visible IN the pills' band; rowH + 2: the button hangs
|
||
// 14px below its box, 12px of rows still showing). The button's
|
||
// bottom offset below --edge is constant at every p (that is what
|
||
// the glue means), so measuring it during any scroll state is
|
||
// safe. Rows dissolve over [bottom + 2, bottom + 20], gone two
|
||
// pixels under the pills, parked heading ~4px clear.
|
||
const body = page.querySelector(".scrollbody");
|
||
const playEl = page.querySelector(".hero-play");
|
||
let rowBottom = rowH + 14;
|
||
if (playEl && band) {
|
||
const pNow = Math.min(
|
||
1,
|
||
Math.max(0, scroller.scrollTop / Math.max(1, span - strip)),
|
||
);
|
||
const edgeNow = span - pNow * (span - strip);
|
||
rowBottom =
|
||
playEl.getBoundingClientRect().bottom -
|
||
band.getBoundingClientRect().top -
|
||
edgeNow;
|
||
}
|
||
// The cut line in scrollport coordinates — scroll-invariant by
|
||
// construction (BAND_TOP and strip are scrollport-frame
|
||
// constants, rowBottom is measured relative to --edge).
|
||
const cutScreen = BAND_TOP + strip + Math.round(rowBottom) + 2;
|
||
stripCut = cutScreen - (body?.offsetTop ?? 0);
|
||
|
||
// The compositor-driven fade line's constants (see the CSS):
|
||
// --cut-top parks .fade-host's box top at the cut line,
|
||
// --fade-s is the scrollTop at which that happens, and
|
||
// --frame-h sizes .fade-frame to a pane height (from the parked
|
||
// cut it must reach past the viewport bottom; a pane is that
|
||
// with slack to spare). Both sides of the park equation live in
|
||
// the same scrollport frame (the pane's padding cancels), so S
|
||
// is simply the host's flow top minus the cut. The host's flow
|
||
// top comes from .hero-zone — never from the host itself:
|
||
// measuring a sticky element to position that same element is
|
||
// the exact feedback loop the warning at the top of measure()
|
||
// exists for. The host sits at .scrollbody's top (= the zone's
|
||
// bottom) with a -18px margin hanging it 18px above the
|
||
// content top.
|
||
if (scrollDriven && body) {
|
||
// A sticky inset resolves from inside the pane's padding
|
||
// (the same 24px BAR_BOTTOM exists to cancel), so `top: X`
|
||
// parks the box at scrollport X + padding — measured live:
|
||
// top 217 parked at 241, a fade line one full slot too low
|
||
// AND a 24px content jump at the stick moment, since S below
|
||
// is derived in scrollport coordinates. Subtracting the
|
||
// padding parks the box on the exact line the mask fallback
|
||
// draws, and makes the stick moment land precisely at S.
|
||
const padTop =
|
||
parseFloat(getComputedStyle(scroller).paddingTop) || 0;
|
||
page.style.setProperty(
|
||
"--cut-top",
|
||
`${(cutScreen - padTop).toFixed(1)}px`,
|
||
);
|
||
const hostFlowTop = zone.offsetTop + zone.offsetHeight - 18;
|
||
const fadeS = Math.max(0, hostFlowTop - cutScreen);
|
||
page.style.setProperty("--fade-s", `${fadeS.toFixed(1)}px`);
|
||
page.style.setProperty("--frame-h", `${scroller.clientHeight}px`);
|
||
}
|
||
}
|
||
|
||
function apply() {
|
||
frame = 0;
|
||
const scrollTop = scroller.scrollTop;
|
||
// FALLBACK ONLY (no scroll-driven animations): the fade line
|
||
// rides EVERY frame — including past the park, where --p is
|
||
// pinned at 1 but the rows keep scrolling under the banner. It
|
||
// must not sit behind the p-guard below, or the mask freezes at
|
||
// the park and rows climb over the banner. On the scrollDriven
|
||
// path the sticky .scrollbody + composited .fade-flow transform
|
||
// own the fade line and nothing here updates per frame.
|
||
if (!scrollDriven) {
|
||
page.style.setProperty("--flow-cut", `${(stripCut + scrollTop).toFixed(1)}px`);
|
||
}
|
||
const p = Math.min(1, Math.max(0, scrollTop / travel));
|
||
if (p === previous) return;
|
||
previous = p;
|
||
page.style.setProperty("--p", p.toFixed(4));
|
||
// Faded-out extras must stop taking clicks; opacity alone doesn't.
|
||
page.style.setProperty("--extra-hit", p > 0.42 ? "none" : "auto");
|
||
// The mask, the veil, the monochrome treatment and the wordmark's
|
||
// scale are all gated on [data-scroll], so at the top of the page
|
||
// they are absent rather than merely neutral. Every one of them is
|
||
// continuous across the switch (a zero-length feather, opacity 0,
|
||
// an identity filter, scale(1)), so nothing pops on the first
|
||
// pixel of scroll.
|
||
heroScrolled = p > 0;
|
||
}
|
||
|
||
function onScroll() {
|
||
if (!frame) frame = requestAnimationFrame(apply);
|
||
}
|
||
|
||
function reflow() {
|
||
measure();
|
||
previous = -1;
|
||
apply();
|
||
}
|
||
|
||
// scrollDriven only: .fade-frame is absolutely positioned, so the
|
||
// content no longer sizes .scrollbody in flow — sync its height to
|
||
// .fade-flow's or the page below the hero collapses. offsetHeight
|
||
// ignores .fade-flow's scroll-driven transform, so observing it
|
||
// cannot feed back into itself. (The fallback path leaves the fade
|
||
// divs unstyled and .scrollbody sizes itself as it always did.)
|
||
let flowObserver = null;
|
||
if (scrollDriven) {
|
||
const bodyEl = page.querySelector(".scrollbody");
|
||
const flowEl = page.querySelector(".fade-flow");
|
||
if (bodyEl && flowEl) {
|
||
const syncHeight = () => {
|
||
bodyEl.style.height = `${flowEl.offsetHeight}px`;
|
||
};
|
||
syncHeight();
|
||
flowObserver = new ResizeObserver(syncHeight);
|
||
flowObserver.observe(flowEl);
|
||
}
|
||
}
|
||
|
||
reflow();
|
||
remeasureHero = reflow;
|
||
scroller.addEventListener("scroll", onScroll, { passive: true });
|
||
const observer = new ResizeObserver(reflow);
|
||
observer.observe(band);
|
||
const zone = page.querySelector(".hero-zone");
|
||
if (zone) observer.observe(zone);
|
||
return {
|
||
destroy() {
|
||
remeasureHero = () => {};
|
||
scroller.removeEventListener("scroll", onScroll);
|
||
observer.disconnect();
|
||
flowObserver?.disconnect();
|
||
if (frame) cancelAnimationFrame(frame);
|
||
},
|
||
};
|
||
}
|
||
|
||
$effect(() => {
|
||
// The parked wordmark scale is derived from the wordmark's RENDERED
|
||
// height, and the row's resting height from the spacer's — both change
|
||
// out from under the ResizeObserver above (the logo crossfades in, the
|
||
// chip set fills out) without either the band or the zone resizing.
|
||
void logo.mode;
|
||
void logoRenderHeight;
|
||
void popularTracks.length;
|
||
remeasureHero();
|
||
});
|
||
|
||
function loadImage(src) {
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
img.decoding = "async";
|
||
img.onload = () => {
|
||
const decoded = typeof img.decode === "function"
|
||
? img.decode().catch(() => {})
|
||
: Promise.resolve();
|
||
decoded.then(() => resolve(img));
|
||
};
|
||
img.onerror = reject;
|
||
img.src = src;
|
||
});
|
||
}
|
||
|
||
/** RGBA pixels of `img` at width `w`, optionally Gaussian-blurred by `blur`
|
||
* source px — the blur synthesises anti-aliasing on hard-edged sources. */
|
||
function rgba(img, w = img.naturalWidth, blur = 0) {
|
||
const h = Math.max(1, Math.round((w * img.naturalHeight) / img.naturalWidth));
|
||
const cv = document.createElement("canvas");
|
||
cv.width = w;
|
||
cv.height = h;
|
||
const ctx = cv.getContext("2d", { willReadFrequently: true });
|
||
ctx.imageSmoothingEnabled = true;
|
||
ctx.imageSmoothingQuality = "high";
|
||
if (blur) ctx.filter = `blur(${blur}px)`;
|
||
ctx.drawImage(img, 0, 0, w, h);
|
||
return { px: ctx.getImageData(0, 0, w, h).data, w, h };
|
||
}
|
||
|
||
/** Share of ink pixels that are partially transparent — i.e. anti-aliased.
|
||
* A hard 1-bit alpha mask scores ~0; a normal AA logo scores several %. */
|
||
function aaFraction({ px, w, h }) {
|
||
let partial = 0;
|
||
let opaque = 0;
|
||
for (let p = 0; p < w * h; p++) {
|
||
const a = px[p * 4 + 3];
|
||
if (a > 8) opaque++;
|
||
if (a > 16 && a < 240) partial++;
|
||
}
|
||
return opaque ? partial / opaque : 0;
|
||
}
|
||
|
||
/** Share of opaque pixels sharing one dominant colour: 1 = flat wordmark. */
|
||
function monoScore({ px, w, h }) {
|
||
const buckets = new Map();
|
||
let opaque = 0;
|
||
for (let p = 0; p < w * h; p++) {
|
||
const i = p * 4;
|
||
if (px[i + 3] < 128) continue;
|
||
opaque++;
|
||
// Coarse 3-bit-per-channel quantisation (512 buckets).
|
||
const key =
|
||
((px[i] >> 5) << 6) | ((px[i + 1] >> 5) << 3) | (px[i + 2] >> 5);
|
||
buckets.set(key, (buckets.get(key) ?? 0) + 1);
|
||
}
|
||
if (opaque < 40) return 0;
|
||
let top = 0;
|
||
for (const v of buckets.values()) if (v > top) top = v;
|
||
return top / opaque;
|
||
}
|
||
|
||
$effect(() => {
|
||
// Provider-logo discovery is its own identity-checked stage. Once that
|
||
// complete candidate set settles, score it immediately without waiting
|
||
// for exhaustive album details. Cover-derived marks still require the
|
||
// hardened final cover list.
|
||
const providerLogos = page?.logos ?? [];
|
||
const providerCandidatesFinal = page?.provider_logos_settled === true;
|
||
const coverCandidatesFinal = !page?.art_preview;
|
||
const usingCoverLogo =
|
||
coverCandidatesFinal
|
||
&& !providerLogos.length
|
||
&& !page?.cover_logo_pending
|
||
&& !!page?.cover_logo;
|
||
const urls = providerCandidatesFinal && providerLogos.length
|
||
? providerLogos
|
||
: usingCoverLogo
|
||
? [page.cover_logo]
|
||
: [];
|
||
const candidateStage = providerLogos.length
|
||
? (providerCandidatesFinal ? "provider" : "pending")
|
||
: (usingCoverLogo ? "cover" : "pending");
|
||
const fingerprint = `${page?.artist.id ?? ""}:${candidateStage}:${urls.join("|")}`;
|
||
// The fast artwork response is later replaced by the full artist page.
|
||
// Identical logo candidates must not flash back to text or be retraced.
|
||
if (fingerprint === logoFingerprint) return;
|
||
logoFingerprint = fingerprint;
|
||
const request = ++logoRequest;
|
||
logo = { mode: "none", url: null, aspect: 3, height: LOGO_MAX_H };
|
||
if (!urls.length) return;
|
||
(async () => {
|
||
// Score small — the dominant-colour share doesn't need full res, and
|
||
// decoding six 800x310 PNGs at native size would be wasteful.
|
||
const loadCandidate = async (url) => {
|
||
try {
|
||
const dataUrl = await getCatalogArt(url);
|
||
if (request !== logoRequest || !dataUrl) return null;
|
||
const img = await loadImage(dataUrl);
|
||
if (request !== logoRequest) return null;
|
||
return { dataUrl, img, score: monoScore(rgba(img, 200)) };
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
const selectedUrls = urls.slice(0, LOGO_MAX);
|
||
|
||
// Evaluate the final candidate set with bounded concurrency. There
|
||
// is no provisional paint or idle delay: text remains stable while
|
||
// two small scoring downloads at a time find the curated winner.
|
||
const cands = [];
|
||
for (let index = 0; index < selectedUrls.length; index += 2) {
|
||
const batch = await Promise.all(
|
||
selectedUrls.slice(index, index + 2).map(loadCandidate),
|
||
);
|
||
if (request !== logoRequest) return;
|
||
cands.push(...batch.filter(Boolean));
|
||
await new Promise(requestAnimationFrame);
|
||
}
|
||
if (request !== logoRequest) return;
|
||
if (!cands.length) return;
|
||
|
||
const cleanest = Math.max(...cands.map((candidate) => candidate.score));
|
||
const eligible = cands.filter(
|
||
(candidate) =>
|
||
candidate.score >= LOGO_MONO_MIN &&
|
||
candidate.score >= cleanest - LOGO_MONO_TOLERANCE,
|
||
);
|
||
const tracedCandidates = [];
|
||
for (const candidate of eligible) {
|
||
// True width is only known after tracing because every source
|
||
// PNG uses the same padded 800x310 canvas. Trace the small set
|
||
// near the top quality score, then prefer the widest result.
|
||
let source, eps, minArea;
|
||
if (usingCoverLogo) {
|
||
// Cover-derived wordmark (recipe D): the Rust side already
|
||
// despeckled the alpha; upscale to a fixed 1400px width and
|
||
// blur so the low-res letterforms trace as smooth curves
|
||
// rather than a staircase, then simplify at eps 0.5.
|
||
//
|
||
// The blur MUST scale with the enlargement: a fixed 1px did
|
||
// almost nothing on a 29px-tall source (a ~7x upscale to
|
||
// 1400px), so its pixel-steps survived into the trace as
|
||
// aliasing. ~0.6px of blur per unit of scale synthesises the
|
||
// anti-aliasing the tracer needs — hi-res marks (scale≈1) stay
|
||
// crisp, tiny marks get proportionally more smoothing.
|
||
const scale = 1400 / candidate.img.naturalWidth;
|
||
source = rgba(candidate.img, 1400, Math.max(1, scale * 0.6));
|
||
eps = 0.5;
|
||
minArea = 24;
|
||
} else if (aaFraction(rgba(candidate.img)) < LOGO_AA_MIN) {
|
||
// Hard-edged 1-bit source: supersample and blur to synthesise
|
||
// anti-aliasing, so the tracer resolves smooth sub-pixel
|
||
// edges instead of a pixel staircase. Genuine corners survive
|
||
// because a blurred corner is still a corner in the field.
|
||
const long = Math.max(candidate.img.naturalWidth, candidate.img.naturalHeight);
|
||
const scale = Math.min(LOGO_TRACE_MAX_SCALE, Math.max(2, LOGO_TRACE_EDGE / long));
|
||
source = rgba(
|
||
candidate.img,
|
||
Math.round(candidate.img.naturalWidth * scale),
|
||
LOGO_TRACE_BLUR * scale,
|
||
);
|
||
eps = 0.5;
|
||
minArea = 6 * scale * scale;
|
||
} else {
|
||
// Already anti-aliased (Fanart): trace natively, unchanged.
|
||
source = rgba(candidate.img);
|
||
eps = 0.22;
|
||
minArea = 6;
|
||
}
|
||
const traced = traceToSvg(source.px, source.w, source.h, { eps, minArea });
|
||
if (request !== logoRequest) return;
|
||
if (traced) tracedCandidates.push(traced);
|
||
// Avoid one long main-thread burst when several provider
|
||
// candidates are all clean enough to trace. A cover-derived
|
||
// mark is the only candidate, so apply it in this frame.
|
||
if (!usingCoverLogo) await new Promise(requestAnimationFrame);
|
||
}
|
||
if (request !== logoRequest) return;
|
||
if (tracedCandidates.length) {
|
||
const traced = tracedCandidates.reduce((widest, candidate) =>
|
||
candidate.aspect > widest.aspect ? candidate : widest,
|
||
);
|
||
logo = {
|
||
mode: "mask",
|
||
url: svgDataUrl(traced.svg),
|
||
aspect: traced.aspect,
|
||
height: logoHeight(traced.aspect),
|
||
};
|
||
return;
|
||
}
|
||
// Nothing clean enough to tint (or the trace found no shape) — show
|
||
// the most-liked candidate in full colour. This one keeps the simple
|
||
// max-height rule: the PNG still carries its transparent padding, so
|
||
// its raw proportions aren't the ink's and the area rule would be
|
||
// sizing against the wrong box.
|
||
logo = { mode: "color", url: cands[0].dataUrl, aspect: 3, height: LOGO_MAX_H };
|
||
})();
|
||
});
|
||
|
||
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||
|
||
// MusicBrainz release-group type hints from the deferred identity pass,
|
||
// keyed by normalized title. MB's secondary types are the authoritative
|
||
// signal for the releases Deezer/Apple mislabel a plain "album" — a
|
||
// compilation ("KID A MNESIA"), soundtrack or remix album. Declared above
|
||
// `categoryFor`/`tabs` so it is initialized before they first read it (the
|
||
// same before-initialization trap the FLIP effects avoid).
|
||
const typeHintMap = $derived.by(() => {
|
||
const map = new Map();
|
||
for (const hint of page?.typeHints ?? []) {
|
||
if (hint?.title) map.set(norm(hint.title), hint);
|
||
}
|
||
return map;
|
||
});
|
||
|
||
// Release-type evidence harvested from description LEADS ("…is a
|
||
// compilation album by the Killers…"), keyed by the family's normalized
|
||
// base title so every edition inherits it. Direct editorial evidence —
|
||
// it outranks the title heuristics in categoryFor, which is how a
|
||
// compilation with an unhinting name ("Direct Hits") leaves the Albums
|
||
// tab. Filled by expander opens and by a bounded background harvest.
|
||
// Declared above `categoryFor`/`tabs` for the same before-initialization
|
||
// reason as `typeHintMap`.
|
||
let descHints = $state({});
|
||
let descHintsFor = null;
|
||
// Harvest bookkeeping (see the harvest effect below): the queue of titles
|
||
// awaiting a description fetch, the set of every key ever enqueued for
|
||
// this artist (the loop-breaker — hint arrivals recompute `tabs`, which
|
||
// re-runs the effect), and the batching buffer that coalesces hint
|
||
// arrivals into one filing pass instead of one FLIP re-flow per album.
|
||
let hintHarvestFor = null;
|
||
let harvestQueue = [];
|
||
let harvestQueued = new Set();
|
||
let harvestRunning = 0;
|
||
let harvestDestroyed = false;
|
||
let pendingHints = {};
|
||
let hintFlushTimer = 0;
|
||
$effect(() => {
|
||
const id = page?.artist?.id ?? null;
|
||
if (descHintsFor !== id) {
|
||
descHintsFor = id;
|
||
descHints = {};
|
||
pendingHints = {};
|
||
clearTimeout(hintFlushTimer);
|
||
hintFlushTimer = 0;
|
||
clearTimeout(descPrefetchTimer);
|
||
hintHarvestFor = id;
|
||
harvestQueue = [];
|
||
harvestQueued = new Set();
|
||
}
|
||
});
|
||
onDestroy(() => {
|
||
harvestDestroyed = true;
|
||
clearTimeout(hintFlushTimer);
|
||
});
|
||
|
||
// Map a MusicBrainz hint to one of our tab categories, or null when its
|
||
// secondary types say nothing we file separately. Order mirrors
|
||
// `categoryFor`'s own precedence so the two agree.
|
||
function mbCategory(hint) {
|
||
const secondary = (hint?.secondary_types ?? []).map((s) => s.toLowerCase());
|
||
if (secondary.includes("soundtrack")) return "soundtrack";
|
||
if (secondary.includes("live")) return "live";
|
||
if (secondary.includes("compilation")) return "compilation";
|
||
if (secondary.includes("remix") || secondary.includes("dj-mix")) return "remix";
|
||
if (secondary.includes("demo")) return "other";
|
||
return null;
|
||
}
|
||
|
||
// Stable hue in [0,360) from the artist id/name, for the no-photo gradient.
|
||
function hueFromKey(key) {
|
||
const s = String(key);
|
||
let h = 0;
|
||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
||
return h % 360;
|
||
}
|
||
|
||
// Which of this artist's releases are already in the local library. Uses
|
||
// the shared evidence-based matcher (variant name + release year for
|
||
// self-titled records), so the ✓ badge and library-first playback agree.
|
||
const inLibrary = (album) => !!page && libraryHasAlbum(page.artist.name, album);
|
||
|
||
// Distinct owned titles for this artist — drives the library progress chip.
|
||
const libraryOwnedCount = $derived.by(() => {
|
||
if (!page) return 0;
|
||
const artist = norm(page.artist.name);
|
||
return new Set(
|
||
libraryStore.albums
|
||
.filter((a) => norm(a.artist || "") === artist)
|
||
.map((a) => norm(a.title || "")),
|
||
).size;
|
||
});
|
||
|
||
// Release category and edition are deliberately separate dimensions. A
|
||
// deluxe live album remains a live album, while its edition suffix is used
|
||
// only to fold it under the same card as the original release.
|
||
// "mixes" catches the "(Dance Mixes, Vol. I)" / "(Urban Mixes)" family;
|
||
// "dj mix" files continuous mix releases ("fabric presents …") with the
|
||
// other rework material instead of leaving them among studio albums.
|
||
const isRemix = (a) => /\b(?:remix(?:es|ed)?|rework(?:s|ed)?|reconfigur(?:e|ed|ation)|rmx|mixes|dj\s+mix)\b/i.test(a.title);
|
||
// `a.is_live` is the backend's track-level verdict (a majority of the
|
||
// release's tracks are marked live) — it catches live albums whose titles
|
||
// give no hint at all ("Ballet Slippers", "FEELSLive 04/05"). The title
|
||
// regex remains as a cheap fallback for albums whose tracks weren't fetched.
|
||
// Audited additions: "live <year>" ("Queen Rock Montreal (Live 1981)"),
|
||
// a standalone "concert(s)" word ("The Berlin Concert", "Legendary
|
||
// Concerts" — "Concerto" is safe, the word boundary fails inside it),
|
||
// "live" followed by punctuation ("David Live (2005 Mix)", "(Live - 2022
|
||
// Remaster)"), and the common non-English forms. Bare "live" + word stays
|
||
// unmatched so "Live Through This" remains a studio album.
|
||
const isLive = (a) => a.is_live || /\b(?:live\s+(?:at|from|in|on|around)|in\s+concert|unplugged|concert\s+recording|live\s+(?:album|recording)|bbc\s+sessions?|peel\s+sessions?|alive\s+(?:19|20)\d{2}|live\s+(?:19|20)\d{2}|concerts?\b|en\s+vivo|ao\s+vivo|en\s+directo|dal\s+vivo|live\s+aus)\b|(?:^|[\s(\[–—-])live(?:\s*[()\[\],:–—-]|$)/i.test(a.title);
|
||
// Titles that name a genuine repackage of already-released material:
|
||
// greatest-hits/best-of sets, anthologies, "collections", box sets. Kept
|
||
// deliberately narrow — an album of previously-unreleased *original*
|
||
// material (e.g. Weezer's "Death to False Metal") is a studio album, not a
|
||
// compilation, even when a provider tags it one, so bare "b-sides" and
|
||
// "rarities" are intentionally NOT matched here.
|
||
// Audited additions: a standalone "collection" ("The Platinum Collection",
|
||
// "Christmas Collection"), "the hits", numbered-singles box series
|
||
// (Depeche Mode's «Violator | The 12" Singles»), "complete … recordings"
|
||
// with a label word in the middle, and archival "bootleg series" sets.
|
||
// Bare "collection" is safe against edition packages ("… + Acoustic
|
||
// Collection") because edition-grouped rows inherit their base album's
|
||
// category before this regex is consulted.
|
||
const COMPILATION_TITLE = /\b(?:greatest\s+hits|best\s+of|very\s+best|anthology|singles\s+collection|the\s+singles|the\s+complete\s+recordings|complete\s+collection|collected\s+works|selected\s+works|retrospective|b[-\s]?sides\s+collection|box\s?set|\d+\s+album\s+set|the\s+essential|collection|the\s+hits|\d+["″]?\s+singles|complete\s+(?:\w+\s+)?recordings|bootleg\s+series)\b/i;
|
||
|
||
// A slash-joined title of short single-word parts, carrying a full album's
|
||
// worth of tracks, is a box set of separate records (Weezer's
|
||
// "Blue/Green/Red" = the Blue + Green + Red albums, 30 tracks), not a split
|
||
// single. Gated to album record_type with a real track count so
|
||
// "A-Side / B-Side" singles and ordinary titles that happen to contain a
|
||
// slash are left alone.
|
||
const isBoxSetTitle = (a) => {
|
||
if (a.record_type?.toLowerCase() !== "album") return false;
|
||
if ((a.track_count ?? 0) < 15) return false;
|
||
const parts = a.title.split("/").map((part) => part.trim());
|
||
return parts.length >= 2 && parts.every((part) => /^[\p{L}\p{N}]{1,14}$/u.test(part));
|
||
};
|
||
|
||
const isCompilation = (a) => {
|
||
const type = a.record_type?.toLowerCase();
|
||
return type === "compile"
|
||
|| type === "compilation"
|
||
|| COMPILATION_TITLE.test(a.title)
|
||
|| isBoxSetTitle(a);
|
||
};
|
||
|
||
// Does this release's title contain another (non-compilation) album's title?
|
||
// A genuine repackage is usually named after what it collects — "KID A
|
||
// MNESIA" contains "Kid A". This is the corroboration MusicBrainz's noisy
|
||
// "Compilation" tag needs before it may override the local classification, so
|
||
// an original-material set MB mislabels (Weezer's "Death to False Metal",
|
||
// "SZNZ: Autumn") stays a studio album. No per-album tracklists exist
|
||
// client-side, so title containment is the available evidence.
|
||
const titleContainsAnotherAlbum = (album) => {
|
||
const self = norm(album.title);
|
||
return (page?.albums ?? []).some((other) => {
|
||
if (other.id === album.id) return false;
|
||
if (isCompilation(other)) return false;
|
||
const otherNorm = norm(other.title);
|
||
return otherNorm.length >= 4 && otherNorm !== self && self.includes(otherNorm);
|
||
});
|
||
};
|
||
|
||
// A "song" description hint reports that the only editorial article for
|
||
// this title describes a TRACK, not a record — so an album-typed release
|
||
// carrying the name is a package built around that song ("Run For Cover
|
||
// (Workout Mix)": eleven earlier Killers recordings under a single's
|
||
// name), never a studio album of its own. That is the corroboration
|
||
// `titleContainsAnotherAlbum` cannot supply when the collected material
|
||
// shares no title with the package. An original-material set MusicBrainz
|
||
// over-tags ("Death to False Metal") is unaffected: it has an ALBUM
|
||
// article, so its hint is never "song".
|
||
const namesASongNotAnAlbum = (a) =>
|
||
descHints[norm(editionBaseTitle(a.title))] === "song";
|
||
|
||
// MusicBrainz "Compilation" is applied only when a local signal agrees it is
|
||
// a real repackage. Its Live/Soundtrack/Remix types are trusted outright
|
||
// (see `categoryFor`); only Compilation is gated.
|
||
const compilationCorroborated = (album) =>
|
||
isCompilation(album) || titleContainsAnotherAlbum(album) || namesASongNotAnAlbum(album);
|
||
|
||
// There is no soundtrack record_type. Prefer Deezer's Films/Games genre
|
||
// signal from album details, then cover the common distributor title forms.
|
||
// `soundtracks?` — the plural mattered: composer catalogues are full of
|
||
// "Iconic Soundtracks" score compilations and the singular-only word
|
||
// boundary missed every one. "motion picture" standing alone covers the
|
||
// "(Music From and Inspired by the Motion Picture)" phrasing, and the
|
||
// "music from the …" net now reaches films/movies/trilogy/documentary
|
||
// forms ("Music from the Batman Trilogy", "Music from the Disney+
|
||
// Documentary").
|
||
const isSoundtrack = (a) => a.is_soundtrack || /\b(?:soundtracks?|original\s+(?:motion\s+picture\s+)?score|film\s+score|motion\s+picture|music\s+from\s+(?:and\s+inspired\s+by\s+)?(?:the\s+)?(?:original\s+)?(?:films?|movies|television(?:\s+series)?|tv\s+(?:series|show)|video\s+game|\S+\s+(?:trilogy|documentary|saga)))\b/i.test(a.title);
|
||
|
||
// Practice takes, rehearsals, demos, outtakes and radio-session odds —
|
||
// bonus material bundled with reissues (e.g. Weezer 30's "Superman (Garage
|
||
// Practice) / The BBC Tracks / Undone (Third Practice)"). Not studio albums;
|
||
// routed to Other releases. Checked AFTER isLive so a genuine "BBC Sessions"
|
||
// / "Peel Sessions" live album is kept in Live Albums — only "BBC Tracks" /
|
||
// "BBC Recordings" (which isLive does not match) fall through to here.
|
||
const isSessionOrDemo = (a) => /\b(?:practice|rehearsals?|demos?|outtakes?|bbc\s+(?:recordings?|tracks?)|radio\s+sessions?|karaoke)\b/i.test(a.title);
|
||
const todayKey = (() => {
|
||
const now = new Date();
|
||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||
})();
|
||
function isFutureOrUnresolvedDate(value) {
|
||
if (!value) return false;
|
||
if (value.length >= 10) return value.slice(0, 10) > todayKey;
|
||
// A provider that knows only the current year/month has not established
|
||
// that the release already happened. Keep it Upcoming until a precise
|
||
// past date arrives rather than silently treating Jan 1 as the date.
|
||
if (value.length >= 7) return value.slice(0, 7) >= todayKey.slice(0, 7);
|
||
if (value.length >= 4) return value.slice(0, 4) >= todayKey.slice(0, 4);
|
||
return false;
|
||
}
|
||
const isUpcomingAlbum = (a) => a.record_type?.toLowerCase() === "album"
|
||
&& isFutureOrUnresolvedDate(a.release_date);
|
||
|
||
const EDITION_MARKER = /\b(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|legacy\s+edition|special\s+edition|collector'?s\s+edition|bonus\s+tracks?|complete\s+edition|definitive\s+edition|reissue|redux|mono\s+edition|stereo\s+edition|\w+(?:['’]s)?\s+edition|half[-\s]speed|track\s+by\s+track)\b/i;
|
||
const EDITION_SUFFIX = /\s*(?:[-–—:]\s*)?(?:the\s+)?(?:(?:\d{4}|\d+(?:st|nd|rd|th))\s+)?(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|legacy\s+edition|special\s+edition|collector'?s\s+edition|bonus\s+tracks?|complete\s+edition|definitive\s+edition|reissue|redux|mono\s+edition|stereo\s+edition|\w+(?:['’]s)?\s+edition|half[-\s]speed|track\s+by\s+track)(?:\s+(?:edition|version))?.*$/i;
|
||
|
||
// A bracketed tail naming an alternate cut of the same work — "(Acoustic
|
||
// Version)", "(2010 Mono Version)", "(Urban Mixes)", "(Sampler)". These
|
||
// fold into the base release's family. "<Name>'s Version" is exempt: a
|
||
// re-recorded catalogue (Taylor's Version) is its own work and must keep
|
||
// its own card.
|
||
const VARIANT_SEGMENT = /\b(?:versions?|mix(?:es)?|edits?|acoustic|instrumentals?|sampler|extras|mono|stereo|demos?|extended|live)\b/i;
|
||
const OWN_WORK_SEGMENT = /^\S+['’]s\s+version$/i;
|
||
|
||
const isEdition = (a) => EDITION_MARKER.test(a.title)
|
||
|| /[\[(][^\])]*\bedition\b[^\])]*[\])]/i.test(a.title);
|
||
|
||
function editionBaseTitle(title) {
|
||
// Keep a useful qualifier that shares an edition bracket. For example,
|
||
// "Weezer (Deluxe Edition / Red Album)" belongs to "Weezer (Red
|
||
// Album)", not to every self-titled Weezer record.
|
||
const bracketSafe = title.replace(/\s*[\[(]([^\])]+)[\])]/g, (whole, inner) => {
|
||
const variantInner = VARIANT_SEGMENT.test(inner) && !OWN_WORK_SEGMENT.test(inner.trim());
|
||
if (!EDITION_MARKER.test(inner) && !/\bedition\b/i.test(inner) && !variantInner) return whole;
|
||
const qualifiers = inner
|
||
.split(/\s*[/|;]\s*/)
|
||
// A segment can carry a real qualifier AND a marker tail —
|
||
// "1973-1976 2011 Remaster" must keep "1973-1976" so the
|
||
// remaster lands in the same family as "Deep Cuts
|
||
// (1973-1976)". Strip the marker tail first, then judge.
|
||
.map((part) => part
|
||
.replace(/\s*(?:\d{4}\s+)?(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|reissue|redux)(?:\s+(?:edition|version))?\s*$/i, "")
|
||
.trim())
|
||
.filter((part) => part
|
||
&& !EDITION_MARKER.test(part)
|
||
&& !/\bedition\b/i.test(part)
|
||
&& !(VARIANT_SEGMENT.test(part) && !OWN_WORK_SEGMENT.test(part)));
|
||
return qualifiers.length ? ` (${qualifiers.join(" / ")})` : "";
|
||
});
|
||
const stripped = bracketSafe
|
||
.replace(EDITION_SUFFIX, "")
|
||
.replace(/\s+/g, " ")
|
||
.trim()
|
||
.replace(/\s*[-–—:]\s*$/, "");
|
||
return stripped || title;
|
||
}
|
||
|
||
function editionLabel(edition, family) {
|
||
// The expander rail header already names the album, so each edition
|
||
// row shows only what differs: "Original" for the canonical release,
|
||
// otherwise the qualifier ("Deluxe Edition", "2025 Remaster"). The
|
||
// full title stays available as the row tooltip.
|
||
if (edition.id === family.id) return "Original";
|
||
const title = edition.title;
|
||
const base = family.baseTitle ?? "";
|
||
// "Weezer (Deluxe Edition / Red Album)" shares its bracket with the
|
||
// family qualifier — drop segments the family already carries so the
|
||
// label is just "Deluxe Edition". Test against the canonical title,
|
||
// not baseTitle: edition_group families derive baseTitle from
|
||
// whichever release was processed first, so it can be bare "Weezer".
|
||
const known = `${family.title ?? ""} ${base}`.toLowerCase();
|
||
const dropBaseParts = (raw) => {
|
||
const parts = raw.split(/\s*[/|;]\s*/).map((p) => p.trim()).filter(Boolean);
|
||
const kept = parts.filter((p) => !known.includes(p.toLowerCase()));
|
||
return (kept.length ? kept : parts).join(" / ");
|
||
};
|
||
const trim = (raw) => raw
|
||
.replace(/^[\s\-–—:([]+/, "")
|
||
.replace(/[)\]]+$/, "")
|
||
.trim();
|
||
if (base && title.toLowerCase().startsWith(base.toLowerCase())) {
|
||
const rest = trim(title.slice(base.length));
|
||
if (rest) return dropBaseParts(rest);
|
||
}
|
||
const suffix = title.match(EDITION_SUFFIX)?.[0];
|
||
return (suffix && dropBaseParts(trim(suffix))) || title;
|
||
}
|
||
|
||
function categoryFor(album) {
|
||
// A backend-linked edition files WITH its base release: "The Life of a
|
||
// Showgirl + Acoustic Collection" belongs beside the album it extends,
|
||
// not wherever its own title's keywords would send it (bare
|
||
// "collection" reads as a compilation). One hop only — the base is the
|
||
// row whose id IS the group id, and it classifies by its own title.
|
||
if (album.edition_group != null && album.id !== album.edition_group) {
|
||
const base = (page?.albums ?? []).find((other) => other.id === album.edition_group);
|
||
if (base) return categoryFor(base);
|
||
}
|
||
// Announced full-lengths stay visible without being presented as part
|
||
// of the released discography. They move automatically on release day.
|
||
if (isUpcomingAlbum(album)) return "upcoming";
|
||
// Editorial description evidence outranks every heuristic below: a
|
||
// lead sentence that says "…is a compilation album by the Killers…"
|
||
// is the record declaring its own kind, seen even when the title
|
||
// ("Direct Hits") hints at nothing. "album"/"ep" hints are POSITIVE
|
||
// studio confirmations — they bypass the title regexes so an
|
||
// innocently-worded studio record can't be misfiled by one.
|
||
//
|
||
// "song" is deliberately NOT filed here. It says the article found was
|
||
// about a track, so it is evidence about the DESCRIPTION, not a
|
||
// category — the record kinds such a lead mentions belong to the album
|
||
// the song appears on. It is consumed by `compilationCorroborated`.
|
||
const descHint = descHints[norm(editionBaseTitle(album.title))];
|
||
if (descHint === "compilation" || descHint === "live"
|
||
|| descHint === "soundtrack" || descHint === "remix") {
|
||
return descHint;
|
||
}
|
||
if (descHint === "ep") return "ep";
|
||
if (descHint === "album") {
|
||
const declared = (album.record_type ?? "album").toLowerCase();
|
||
return ["album", "ep", "single"].includes(declared) ? declared : "album";
|
||
}
|
||
// MusicBrainz's verdict catches bespoke names the title heuristics
|
||
// below can't (a compilation named "KID A MNESIA", a soundtrack with no
|
||
// "soundtrack" in its title). Live/Soundtrack/Remix are trusted outright;
|
||
// MB's "Compilation" is noisy (it also tags original-material sets), so
|
||
// it must be corroborated locally, else fall through to the heuristics.
|
||
const hinted = typeHintMap.get(norm(album.title));
|
||
if (hinted) {
|
||
const category = mbCategory(hinted);
|
||
if (category === "compilation") {
|
||
if (compilationCorroborated(album)) return "compilation";
|
||
} else if (category) {
|
||
return category;
|
||
}
|
||
}
|
||
if (isSoundtrack(album)) return "soundtrack";
|
||
if (isLive(album)) return "live";
|
||
if (isCompilation(album)) return "compilation";
|
||
if (isRemix(album)) return "remix";
|
||
// Practice/demo/session bonus material is not a studio album, EP or
|
||
// single — send it to the Other releases catch-all regardless of the
|
||
// record_type Deezer assigned it.
|
||
if (isSessionOrDemo(album)) return "other";
|
||
const type = album.record_type?.toLowerCase() ?? "other";
|
||
return ["album", "ep", "single"].includes(type) ? type : "other";
|
||
}
|
||
|
||
function releaseFamilies(items, category) {
|
||
// A work whose rows are only PARTLY edition_group-linked must not
|
||
// split into a work-keyed family and a title-keyed family showing two
|
||
// cards ("Nevermind" linked to its deluxe, "Nevermind (Remastered)"
|
||
// unlinked). Rows without a group adopt the group of a linked row
|
||
// that shares their base title.
|
||
const groupByBase = new Map();
|
||
for (const album of items) {
|
||
if (album.edition_group == null) continue;
|
||
const base = norm(editionBaseTitle(album.title));
|
||
if (base && !groupByBase.has(base)) groupByBase.set(base, album.edition_group);
|
||
}
|
||
const families = new Map();
|
||
for (const album of items) {
|
||
const baseTitle = editionBaseTitle(album.title);
|
||
const group = album.edition_group ?? groupByBase.get(norm(baseTitle));
|
||
const key = group != null
|
||
? `${category}:work:${group}`
|
||
: `${category}:${norm(baseTitle) || album.id}`;
|
||
if (!families.has(key)) {
|
||
families.set(key, { key, baseTitle, editions: [] });
|
||
}
|
||
const family = families.get(key);
|
||
// Deezer can return the same regional/digital release under more
|
||
// than one catalog id, while MusicBrainz supplements can carry the
|
||
// same title/year without a track count. Those are duplicate source
|
||
// records, not editions. Different known track counts remain
|
||
// separate because they can represent a real expanded edition.
|
||
const releaseYear = year(album);
|
||
const duplicate = releaseYear && family.editions.some((edition) =>
|
||
norm(edition.title) === norm(album.title)
|
||
&& year(edition) === releaseYear
|
||
&& (edition.track_count == null
|
||
|| album.track_count == null
|
||
|| edition.track_count === album.track_count),
|
||
);
|
||
if (duplicate) continue;
|
||
family.editions.push(album);
|
||
}
|
||
|
||
return [...families.values()]
|
||
.map((family) => {
|
||
const byDate = family.editions
|
||
.slice()
|
||
.sort((a, b) => (a.release_date ?? "9999").localeCompare(b.release_date ?? "9999"));
|
||
const canonical = byDate.find((album) => !isEdition(album)) ?? byDate[0];
|
||
const editions = [canonical, ...byDate.filter((album) => album.id !== canonical.id)];
|
||
return { ...canonical, familyKey: family.key, baseTitle: family.baseTitle, editions };
|
||
})
|
||
// Studio chronology should follow the original album, not the date
|
||
// of its latest anniversary reissue.
|
||
.sort((a, b) => (b.release_date ?? "").localeCompare(a.release_date ?? ""));
|
||
}
|
||
|
||
// Discography tabs (Spotify-style). Each tab merges one or more of the
|
||
// nine fine-grained categories into a single date-sorted grid, so every
|
||
// release stays reachable and the per-grid expander machinery works
|
||
// unchanged (keyed by the tab key). Only non-empty tabs are shown.
|
||
// One tab per release category — nothing is folded into a neighbour, so
|
||
// a soundtrack is never filed under Albums and a remix EP is never filed
|
||
// under Compilations. Empty tabs are dropped below, so a typical artist
|
||
// still shows only three or four.
|
||
const TAB_DEFS = [
|
||
{ key: "upcoming", label: "Upcoming", cats: ["upcoming"] },
|
||
{ key: "album", label: "Albums", cats: ["album"] },
|
||
{ key: "ep", label: "EPs", cats: ["ep"] },
|
||
{ key: "single", label: "Singles", cats: ["single"] },
|
||
{ key: "live", label: "Live", cats: ["live"] },
|
||
{ key: "compilation", label: "Compilations", cats: ["compilation"] },
|
||
{ key: "soundtrack", label: "Soundtracks", cats: ["soundtrack"] },
|
||
{ key: "remix", label: "Remixes", cats: ["remix"] },
|
||
{ key: "other", label: "Other", cats: ["other"] },
|
||
];
|
||
|
||
const tabs = $derived.by(() => {
|
||
if (!page) return [];
|
||
const by = {
|
||
upcoming: [], album: [], live: [], soundtrack: [], compilation: [],
|
||
ep: [], single: [], remix: [], other: [],
|
||
};
|
||
for (const album of page.albums) by[categoryFor(album)].push(album);
|
||
return TAB_DEFS
|
||
.map((def) => ({
|
||
key: def.key,
|
||
label: def.label,
|
||
items: releaseFamilies(def.cats.flatMap((c) => by[c]), def.key),
|
||
}))
|
||
.filter((tab) => tab.items.length > 0);
|
||
});
|
||
|
||
// Albums is the default landing (like Spotify's "Popular releases"); if
|
||
// an artist has no albums, fall back to the first non-empty tab. The
|
||
// effect also re-validates as the staged catalog rebuilds `tabs`. Reset
|
||
// per artist so a niche tab selected on the previous page never hides the
|
||
// new artist's album grid (and its near-viewport cover prefetch).
|
||
let activeTabKey = $state("album");
|
||
let activeTabArtistId = null;
|
||
$effect(() => {
|
||
if (!tabs.length) return;
|
||
if (artistIdentity !== activeTabArtistId) {
|
||
activeTabArtistId = artistIdentity;
|
||
activeTabKey = tabs.find((tab) => tab.key === "album")?.key ?? tabs[0].key;
|
||
} else if (!tabs.some((tab) => tab.key === activeTabKey)) {
|
||
activeTabKey = tabs.find((tab) => tab.key === "album")?.key ?? tabs[0].key;
|
||
}
|
||
});
|
||
|
||
const visibleTabs = $derived(tabs.filter((tab) => tab.key === activeTabKey));
|
||
|
||
// FLIP for the stage-2 correction. Svelte's `animate:` directive can't be
|
||
// used here — it requires the animated element to be the ONLY child of the
|
||
// keyed each, and this each also renders the inline expander — so the same
|
||
// effect is done by hand: snapshot every card's position BEFORE the DOM
|
||
// updates ($effect.pre), then after the update play each card from its old
|
||
// position back to its new one. Cards glide to their corrected places
|
||
// instead of snapping. Only on-screen cards are animated.
|
||
// MUST live below `tabs`: $effect.pre also runs during setup, so reading a
|
||
// `const` declared further down throws a TDZ ReferenceError that blanks
|
||
// the whole page.
|
||
let cardRects = new Map();
|
||
|
||
$effect.pre(() => {
|
||
void tabs;
|
||
if (!untrack(() => gridAnimated)) return;
|
||
cardRects = new Map();
|
||
for (const el of document.querySelectorAll(".release[data-fkey]")) {
|
||
cardRects.set(el.dataset.fkey, el.getBoundingClientRect());
|
||
}
|
||
});
|
||
|
||
$effect(() => {
|
||
void tabs;
|
||
if (!untrack(() => gridAnimated) || !cardRects.size) return;
|
||
const rects = cardRects;
|
||
cardRects = new Map();
|
||
for (const el of document.querySelectorAll(".release[data-fkey]")) {
|
||
const prev = rects.get(el.dataset.fkey);
|
||
if (!prev) continue; // newly added — its in:fade covers it
|
||
const next = el.getBoundingClientRect();
|
||
if (next.bottom < 0 || next.top > innerHeight) continue; // off-screen
|
||
const dx = prev.left - next.left;
|
||
const dy = prev.top - next.top;
|
||
if (!dx && !dy) continue;
|
||
el.animate(
|
||
[{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }],
|
||
{ duration: 340, easing: "cubic-bezier(0.2, 0.8, 0.3, 1)" },
|
||
);
|
||
}
|
||
});
|
||
|
||
async function selectTab(key) {
|
||
if (key === activeTabKey) return;
|
||
// The open expander lives inside the outgoing tab's grid; switching
|
||
// tabs would orphan it, so close first.
|
||
if (openFamilyKey) closeExpander();
|
||
|
||
// Switching must read as a lateral change, so the tab bar has to stay
|
||
// exactly where it is. The grid's min-height reserve keeps the target
|
||
// scroll position reachable, but the grid is keyed by tab — Svelte
|
||
// destroys and recreates it, and that swap still shifts the scroll
|
||
// (measured 381px) via Chromium re-anchoring on the new subtree. So
|
||
// pin the bar's viewport offset explicitly across the swap.
|
||
const bar = document.querySelector(".disco-tabs");
|
||
const pane = bar?.closest(".content");
|
||
const barOffset = bar && pane
|
||
? bar.getBoundingClientRect().top - pane.getBoundingClientRect().top
|
||
: null;
|
||
|
||
activeTabKey = key;
|
||
|
||
if (barOffset == null) return;
|
||
const restore = () => {
|
||
const now = bar.getBoundingClientRect().top - pane.getBoundingClientRect().top;
|
||
if (now !== barOffset) pane.scrollTop += now - barOffset;
|
||
};
|
||
await tick();
|
||
restore();
|
||
// content-visibility/intrinsic-size settle a frame later.
|
||
requestAnimationFrame(restore);
|
||
}
|
||
|
||
const releasedCount = $derived(page?.albums.filter((album) => !isUpcomingAlbum(album)).length ?? 0);
|
||
|
||
const familyNowPlaying = (family) => family.editions.some((album) => nowPlaying("albumId", album.id));
|
||
const familyInLibrary = (family) => family.editions.some(inLibrary);
|
||
|
||
// ---- inline album expander ----
|
||
// One expander at a time, rendered INSIDE the grid as a full-width item
|
||
// directly after the last card of the open family's row, so it always
|
||
// opens under the album you clicked and pushes later rows down.
|
||
let openFamilyKey = $state(null);
|
||
const openFamily = $derived(
|
||
tabs.flatMap((tab) => tab.items).find((family) => family.familyKey === openFamilyKey) ?? null,
|
||
);
|
||
|
||
let openAlbumId = $state(null);
|
||
let railArtAlbumId = $state(null);
|
||
let previousRailArtAlbumId = $state(null);
|
||
let railArtTransitionKey = $state(0);
|
||
let railArtTransitionTimer;
|
||
let openTracks = $state([]);
|
||
let openLoading = $state(false);
|
||
let openInfo = $state(null); // AlbumInfo (description/genre/label) for the open family
|
||
let descExpanded = $state(false);
|
||
let libraryTicks = $state(new Set()); // normalized titles of locally saved tracks
|
||
let gridCols = $state({}); // group key -> measured column count
|
||
|
||
const albumInfoCache = new Map(); // artist id + canonical title -> Promise<AlbumInfo|null>
|
||
const ALBUM_INFO_CACHE_MAX = 64;
|
||
let editionLoadGeneration = 0;
|
||
let libraryTickGeneration = 0;
|
||
|
||
/** Owns a catalog cover's `src` so the high-res upgrade never blanks it.
|
||
*
|
||
* `resolveCovers` publishes an upgrade URL the moment the BACKEND returns
|
||
* it — before the browser has fetched a byte of that image. Binding it
|
||
* straight to `src` tears down the painted cover and leaves the element
|
||
* empty for the whole download: the visible "flash".
|
||
*
|
||
* So: if the img is already showing a frame (i.e. the user can see it),
|
||
* fetch + decode the next URL off-DOM first and only then swap, which
|
||
* paints from the memory cache in a single frame. An img that hasn't
|
||
* painted yet takes the URL directly — nothing to flash from, and that
|
||
* keeps `loading="lazy"` intact for off-screen cards so this never turns
|
||
* into an eager download of every cover on the page.
|
||
*
|
||
* A decode failure simply never swaps, so a dead upgrade URL leaves the
|
||
* working cover in place instead of flashing to a placeholder.
|
||
*/
|
||
/** Cover Art Archive 302-redirects into archive.org, which routinely
|
||
* stalls or times out under load. MusicBrainz-supplemented cards (Hurley,
|
||
* the Gold Album…) are the only ones served from there — every other cover
|
||
* comes from Deezer's fast CDN — which is why the SAME albums keep losing
|
||
* their art. A browser <img> gets one shot at such a URL and no retry, so
|
||
* route these through the Rust proxy instead: it has a 10s timeout,
|
||
* single-flight, an in-memory cache, and it never caches a failure. */
|
||
const PROXIED_ART_HOST = /(^|\.)(coverartarchive\.org|archive\.org)$/i;
|
||
function needsArtProxy(url) {
|
||
try {
|
||
return PROXIED_ART_HOST.test(new URL(url).hostname);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function handleCoverError(node, originalCover, title = "") {
|
||
const fallback = coverLoadError(
|
||
originalCover,
|
||
node?.currentSrc || node?.src || null,
|
||
page?.artist?.name ?? "",
|
||
title,
|
||
page?.artist?.id ?? null,
|
||
);
|
||
if (fallback && node?.src !== fallback) node.src = fallback;
|
||
}
|
||
|
||
// Every release card shares one observer. Large discographies previously
|
||
// allocated two IntersectionObserver instances per card (one for image
|
||
// proxying and one for backend cover verification); this single gate owns
|
||
// both jobs and keeps off-screen/hidden work dormant.
|
||
let coverViewportObserver;
|
||
const coverViewportCallbacks = new Map();
|
||
function watchCoverViewport(node, callback) {
|
||
if (!coverViewportObserver) {
|
||
coverViewportObserver = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const entry of entries) {
|
||
if (!entry.isIntersecting) continue;
|
||
const ready = coverViewportCallbacks.get(entry.target);
|
||
coverViewportCallbacks.delete(entry.target);
|
||
coverViewportObserver.unobserve(entry.target);
|
||
ready?.();
|
||
}
|
||
},
|
||
{ root: node.closest(".content"), rootMargin: "500px" },
|
||
);
|
||
}
|
||
coverViewportCallbacks.set(node, callback);
|
||
coverViewportObserver.observe(node);
|
||
return () => {
|
||
coverViewportCallbacks.delete(node);
|
||
coverViewportObserver?.unobserve(node);
|
||
};
|
||
}
|
||
|
||
function smoothSrc(node, options) {
|
||
let seq = 0;
|
||
let current = options;
|
||
let pendingUrl = options?.url ?? "";
|
||
let nearViewport = typeof IntersectionObserver === "undefined";
|
||
let stopWatching;
|
||
let scheduledKey = "";
|
||
let thumbSettleTimer;
|
||
// Tracks the LOGICAL url applied. Can't compare against node.src: a
|
||
// proxied cover ends up as a data: URL that never equals its source.
|
||
let currentKey = null;
|
||
// First-paint reveal: the img sits at opacity 0 until it has fully
|
||
// arrived, so the browser's top-down raster fill happens invisibly and
|
||
// only the finished picture animates in over the blurred thumb.
|
||
const onLoad = () => {
|
||
if (!node.naturalWidth) return;
|
||
node.classList.add("is-loaded");
|
||
const stack = node.parentElement;
|
||
stack?.classList.add("cover-ready");
|
||
clearTimeout(thumbSettleTimer);
|
||
thumbSettleTimer = setTimeout(
|
||
() => stack?.classList.add("cover-settled"),
|
||
320,
|
||
);
|
||
};
|
||
node.addEventListener("load", onLoad);
|
||
if (node.complete && node.naturalWidth) onLoad();
|
||
|
||
const load = async (next) => {
|
||
if (!next || currentKey === next) return;
|
||
const mine = ++seq;
|
||
const requestOptions = current;
|
||
currentKey = next;
|
||
|
||
let resolved = next;
|
||
if (needsArtProxy(next)) {
|
||
const data = await getCatalogArt(next);
|
||
if (mine !== seq) return;
|
||
if (data) resolved = data;
|
||
}
|
||
|
||
if (!node.naturalWidth) {
|
||
node.src = resolved;
|
||
return;
|
||
}
|
||
try {
|
||
const probe = new Image();
|
||
probe.decoding = "async";
|
||
probe.src = resolved;
|
||
await probe.decode();
|
||
} catch {
|
||
if (mine !== seq) return;
|
||
// The off-DOM probe intentionally shields the painted card from
|
||
// a flash, but it is still proof that this upgrade is unusable.
|
||
// Evict both memory and persistent resolution entries here;
|
||
// otherwise no real <img> error event ever gets a chance to.
|
||
coverLoadError(
|
||
requestOptions?.album?.cover,
|
||
next,
|
||
requestOptions?.artist ?? "",
|
||
requestOptions?.album?.title ?? "",
|
||
requestOptions?.artistId ?? null,
|
||
);
|
||
return; // dead upgrade — keep the cover that works
|
||
}
|
||
if (mine === seq && node.isConnected) node.src = resolved;
|
||
};
|
||
const scheduleUpgrade = () => {
|
||
const album = current?.album;
|
||
const key = `${album?.title ?? ""}\u{1f}${album?.cover ?? ""}`;
|
||
if (!nearViewport || !current?.ready || !album?.cover || key === scheduledKey) return;
|
||
scheduledKey = key;
|
||
void resolveCovers([album], current.artist ?? "", {
|
||
artistId: current.artistId ?? null,
|
||
});
|
||
};
|
||
const enterViewport = () => {
|
||
nearViewport = true;
|
||
void load(pendingUrl);
|
||
scheduleUpgrade();
|
||
if (current?.ready) queueCardAccent(current?.album?.cover);
|
||
};
|
||
const apply = (next) => {
|
||
current = next;
|
||
pendingUrl = next?.url ?? "";
|
||
if (nearViewport) {
|
||
void load(pendingUrl);
|
||
scheduleUpgrade();
|
||
if (current?.ready) queueCardAccent(current?.album?.cover);
|
||
}
|
||
};
|
||
if (!nearViewport) {
|
||
stopWatching = watchCoverViewport(node, enterViewport);
|
||
}
|
||
apply(options);
|
||
return {
|
||
update: apply,
|
||
destroy: () => {
|
||
seq += 1;
|
||
stopWatching?.();
|
||
clearTimeout(thumbSettleTimer);
|
||
node.removeEventListener("load", onLoad);
|
||
},
|
||
};
|
||
}
|
||
|
||
onDestroy(() => {
|
||
leaveCoverUpgradeScope(coverScopeArtistId);
|
||
coverViewportObserver?.disconnect();
|
||
coverViewportCallbacks.clear();
|
||
});
|
||
|
||
// Measures a grid's real column count (the tracks are fixed-width
|
||
// auto-fill, so it changes with the pane) AND reserves a minimum grid
|
||
// height. The reserve is what makes tab switching read as a lateral
|
||
// change: without it, moving from a tall tab (17 albums) to a short one
|
||
// (2 live) shrinks the page below the current scrollTop, the browser
|
||
// clamps the scroll, and the whole view yanks upward. Reserving
|
||
// "one pane-height below the tab bar" keeps every scroll position where
|
||
// the bar is visible achievable, so the bar never moves. It is a
|
||
// min-height, so tabs with plenty of releases are unaffected.
|
||
function measureCols(node, groupKey) {
|
||
const measure = () => {
|
||
const n = getComputedStyle(node).gridTemplateColumns.split(" ").length;
|
||
if (gridCols[groupKey] !== n) gridCols = { ...gridCols, [groupKey]: n };
|
||
|
||
const pane = scrollPaneFor(node);
|
||
// Anchor on the TAB BAR, not the heading: the bar is what
|
||
// `selectTab` pins, and the worst case is the bar sitting flush
|
||
// at the pane's top. Everything below it (the bar itself + this
|
||
// grid) must then still fill a pane-height, or the scroll clamps.
|
||
const bar = node.closest(".pane")?.querySelector(".disco-tabs")
|
||
?? document.querySelector(".disco-tabs");
|
||
if (!pane || !bar) return;
|
||
const chrome = node.getBoundingClientRect().top - bar.getBoundingClientRect().top;
|
||
const reserve = Math.max(0, Math.round(pane.clientHeight - chrome));
|
||
if (node.style.minHeight !== `${reserve}px`) node.style.minHeight = `${reserve}px`;
|
||
};
|
||
measure();
|
||
const ro = new ResizeObserver(measure);
|
||
ro.observe(node);
|
||
return { destroy: () => ro.disconnect() };
|
||
}
|
||
|
||
/** Index in group.items after which the expander renders (-1 = not here):
|
||
* the last card of the open family's row. Same-row switches therefore keep
|
||
* the same insertion point and morph in place; cross-row switches move the
|
||
* block, which plays the slide out/in. */
|
||
function expanderIndex(group) {
|
||
const idx = group.items.findIndex((f) => f.familyKey === openFamilyKey);
|
||
if (idx < 0) return -1;
|
||
const cols = gridCols[group.key] || 1;
|
||
return Math.min(Math.floor(idx / cols) * cols + cols - 1, group.items.length - 1);
|
||
}
|
||
|
||
/** Notch x-position: centered under the open card. Tracks are fixed
|
||
* (--card-w) with a fixed gap, so this is exact. */
|
||
function notchLeft(group) {
|
||
const idx = group.items.findIndex((f) => f.familyKey === openFamilyKey);
|
||
const cols = gridCols[group.key] || 1;
|
||
const col = idx >= 0 ? idx % cols : 0;
|
||
return `${col * (CARD_W + GRID_GAP) + CARD_W / 2 - 6}px`;
|
||
}
|
||
const CARD_W = 150;
|
||
const GRID_GAP = 16;
|
||
|
||
function closeExpander() {
|
||
clearTimeout(railArtTransitionTimer);
|
||
editionLoadGeneration += 1;
|
||
libraryTickGeneration += 1;
|
||
openFamilyKey = null;
|
||
openAlbumId = null;
|
||
openLoading = false;
|
||
railArtAlbumId = null;
|
||
previousRailArtAlbumId = null;
|
||
openTracks = [];
|
||
openInfo = null;
|
||
}
|
||
|
||
function toggleExpander(family) {
|
||
if (openFamilyKey === family.familyKey) {
|
||
closeExpander();
|
||
return;
|
||
}
|
||
openFamilyKey = family.familyKey;
|
||
// A newly opened family already has its card artwork loaded. Edition
|
||
// switches below preload independently so the existing art never
|
||
// disappears while the next cover is still decoding.
|
||
setRailArt(family.id, true);
|
||
descExpanded = false;
|
||
showEdition(family);
|
||
loadAlbumInfo(family);
|
||
loadLibraryTicks(family);
|
||
}
|
||
|
||
async function showEdition(album) {
|
||
const generation = ++editionLoadGeneration;
|
||
openAlbumId = album.id;
|
||
void resolveCovers([album], page?.artist?.name ?? "", {
|
||
artistId: page?.artist?.id ?? null,
|
||
});
|
||
void preloadEditionArt(album);
|
||
openTracks = [];
|
||
openLoading = true;
|
||
try {
|
||
const details = await api.catalogAlbum(album.id);
|
||
if (openAlbumId === album.id) openTracks = details.tracks;
|
||
} catch {
|
||
if (openAlbumId === album.id) openTracks = [];
|
||
} finally {
|
||
if (generation === editionLoadGeneration && openAlbumId === album.id) {
|
||
openLoading = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function preloadEditionArt(album) {
|
||
if (railArtAlbumId === album.id) return;
|
||
const source = album.cover
|
||
? (coverUpgrades[album.cover] ?? album.cover)
|
||
: null;
|
||
if (!source || artBroken[album.cover]) {
|
||
if (openAlbumId === album.id) setRailArt(album.id);
|
||
return;
|
||
}
|
||
|
||
const image = new Image();
|
||
image.src = source;
|
||
try {
|
||
await image.decode();
|
||
} catch {
|
||
// A failed decode still advances to the normal image element,
|
||
// whose error handler supplies the existing placeholder behavior.
|
||
}
|
||
if (openAlbumId === album.id) setRailArt(album.id);
|
||
}
|
||
|
||
function setRailArt(albumId, immediate = false) {
|
||
if (railArtAlbumId === albumId) return;
|
||
clearTimeout(railArtTransitionTimer);
|
||
previousRailArtAlbumId = immediate ? null : railArtAlbumId;
|
||
railArtAlbumId = albumId;
|
||
railArtTransitionKey += 1;
|
||
if (!immediate && previousRailArtAlbumId != null) {
|
||
const transition = railArtTransitionKey;
|
||
railArtTransitionTimer = setTimeout(() => {
|
||
if (railArtTransitionKey === transition) previousRailArtAlbumId = null;
|
||
}, 520);
|
||
}
|
||
}
|
||
|
||
onDestroy(() => clearTimeout(railArtTransitionTimer));
|
||
|
||
async function loadAlbumInfo(family) {
|
||
openInfo = null;
|
||
// The CANONICAL title, not baseTitle: baseTitle comes from whichever
|
||
// edition the newest-first list iterated first ("Weezer 30" for the
|
||
// Blue Album family), while the canonical is the original release —
|
||
// the record the sources actually catalogue.
|
||
const title = family.title || family.baseTitle;
|
||
const artistId = page?.artist?.id ?? null;
|
||
const artistName = page?.artist?.name ?? "";
|
||
const familyKey = family.familyKey;
|
||
if (artistId == null || !artistName || !title) return;
|
||
const { key, promise } = fetchAlbumInfo(artistId, artistName, title);
|
||
const info = await promise;
|
||
// A page-load network burst can time one source out; show whatever
|
||
// arrived but don't pin a partial result for the session — the next
|
||
// open retries (the backend caches each source, so retries are cheap).
|
||
if (!info || !info.description || (!info.genre && !info.label)) {
|
||
albumInfoCache.delete(key);
|
||
}
|
||
harvestDescHint(artistId, title, info);
|
||
if (
|
||
String(page?.artist?.id ?? "") === String(artistId)
|
||
&& openFamilyKey === familyKey
|
||
) openInfo = info;
|
||
}
|
||
|
||
/** Hover-intent description prefetch: 150ms of dwell on a card warms its
|
||
* album info, so the expander's description is a cache hit by click time
|
||
* (~3ms instead of ~400ms cold). One timer — moving between cards
|
||
* retargets it; leaving cancels; `fetchAlbumInfo` dedups repeats. */
|
||
let descPrefetchTimer = 0;
|
||
function queueDescPrefetch(family) {
|
||
clearTimeout(descPrefetchTimer);
|
||
descPrefetchTimer = setTimeout(() => {
|
||
const artistId = page?.artist?.id ?? null;
|
||
const artistName = page?.artist?.name ?? "";
|
||
const title = family.title || family.baseTitle;
|
||
if (artistId == null || !artistName || !title) return;
|
||
fetchAlbumInfo(artistId, artistName, title);
|
||
}, 150);
|
||
}
|
||
function cancelDescPrefetch() {
|
||
clearTimeout(descPrefetchTimer);
|
||
}
|
||
onDestroy(() => clearTimeout(descPrefetchTimer));
|
||
|
||
function fetchAlbumInfo(artistId, artistName, title) {
|
||
const key = `${String(artistId)}:${title}`;
|
||
if (!albumInfoCache.has(key)) {
|
||
albumInfoCache.set(
|
||
key,
|
||
api.catalogAlbumInfo(artistId, artistName, title).catch(() => null),
|
||
);
|
||
while (albumInfoCache.size > ALBUM_INFO_CACHE_MAX) {
|
||
albumInfoCache.delete(albumInfoCache.keys().next().value);
|
||
}
|
||
}
|
||
return { key, promise: albumInfoCache.get(key) };
|
||
}
|
||
|
||
/** Buffered hint application: each write used to reassign a `descHints`
|
||
* key immediately, so a 12-album harvest re-ran filing and animated a
|
||
* FLIP re-flow a dozen times in a row. Arrivals now pool for 250ms and
|
||
* land as ONE update — same final filing, one visible correction. */
|
||
function flushDescHints() {
|
||
hintFlushTimer = 0;
|
||
const entries = Object.entries(pendingHints);
|
||
if (!entries.length) return;
|
||
pendingHints = {};
|
||
descHints = { ...descHints, ...Object.fromEntries(entries) };
|
||
}
|
||
|
||
function harvestDescHint(artistId, title, info) {
|
||
if (!info?.type_hint || !title) return;
|
||
if (String(page?.artist?.id ?? "") !== String(artistId)) return;
|
||
const key = norm(editionBaseTitle(title));
|
||
pendingHints[key] = info.type_hint;
|
||
if (import.meta.env.DEV) {
|
||
(globalThis.__descHints ??= {})[key] = info.type_hint;
|
||
}
|
||
if (!hintFlushTimer) hintFlushTimer = setTimeout(flushDescHints, 250);
|
||
}
|
||
|
||
// Background hint harvest: the sorting evidence must not depend on the
|
||
// user opening every card. It starts as soon as the OVERVIEW's albums
|
||
// exist (~0.6s into a cold page) instead of waiting for the exhaustive
|
||
// page — the harvest touches TheAudioDB/Wikipedia, which the page burst
|
||
// (Deezer/Apple) barely uses, and its one MusicBrainz URL is the same
|
||
// one the identity warmup already has in flight, so overlapping them
|
||
// adds no provider pressure. When the full page lands and re-shuffles
|
||
// the tabs, the effect re-runs and TOPS UP the queue with any new
|
||
// first-14 Albums-tab titles, so final coverage is what it always was.
|
||
// `harvestQueued` is the loop-breaker: hint arrivals recompute `tabs`,
|
||
// which re-runs this effect, and only never-enqueued titles are added.
|
||
// Four workers, not an unbounded fan-out: the backend paces its own
|
||
// providers, but a 14-wide burst still serializes behind those gates
|
||
// while a strictly sequential walk paid full round-trip latency per
|
||
// title (measured 5.2x slower than fanning out).
|
||
const HARVEST_SLICE = 14;
|
||
const HARVEST_ENQUEUE_CAP = 28;
|
||
const HARVEST_WORKERS = 4;
|
||
$effect(() => {
|
||
const artistId = page?.artist?.id ?? null;
|
||
const artistName = page?.artist?.name ?? "";
|
||
if (!artistId || !artistName || page?.loading_shell) return;
|
||
// The reset effect above rebinds the harvest state on artist change.
|
||
// It is declared before this effect, and Svelte runs sibling effects
|
||
// in creation order within a flush, so by the time this body runs for
|
||
// a new artist the rebind has happened; the guard is belt-and-braces
|
||
// (an early-returned run would NOT be re-triggered by the reset's
|
||
// writes, since it never read `descHints`/`tabs`).
|
||
if (hintHarvestFor !== artistId) return;
|
||
const albumTab = tabs.find((tab) => tab.key === "album");
|
||
if (!albumTab) return;
|
||
let added = 0;
|
||
for (const family of albumTab.items.slice(0, HARVEST_SLICE)) {
|
||
const title = family.title || family.baseTitle;
|
||
if (!title) continue;
|
||
const key = norm(editionBaseTitle(title));
|
||
if (harvestQueued.has(key) || descHints[key] || pendingHints[key]) continue;
|
||
if (harvestQueued.size >= HARVEST_ENQUEUE_CAP) break;
|
||
harvestQueued.add(key);
|
||
harvestQueue.push(title);
|
||
added += 1;
|
||
}
|
||
if (import.meta.env.DEV && added && !globalThis.__harvestStarted) {
|
||
globalThis.__harvestStarted = `${artistId}:${albumTab.items.length}`;
|
||
}
|
||
// Kick whenever work is QUEUED, not only when this run added some:
|
||
// after an artist switch the previous artist's workers can still hold
|
||
// all the slots for a moment, so the run that enqueues the new titles
|
||
// may start zero workers — a later run must be able to start them.
|
||
if (harvestQueue.length) startHarvestWorkers();
|
||
});
|
||
|
||
function startHarvestWorkers() {
|
||
while (harvestRunning < HARVEST_WORKERS && harvestRunning < harvestQueue.length) {
|
||
harvestRunning += 1;
|
||
void (async () => {
|
||
try {
|
||
// Brief settle so the overview's own paint wins the frame.
|
||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||
// Workers are artist-agnostic drainers: they re-read the
|
||
// CURRENT artist every iteration (the reset effect rebinds
|
||
// the queue on artist change, and this closure sees the
|
||
// new binding), so a worker that outlives a navigation
|
||
// simply continues on the new artist's queue instead of
|
||
// dying and stranding it.
|
||
while (harvestQueue.length) {
|
||
if (harvestDestroyed) return;
|
||
const artistId = page?.artist?.id ?? null;
|
||
const artistName = page?.artist?.name ?? "";
|
||
if (
|
||
artistId == null
|
||
|| !artistName
|
||
|| hintHarvestFor !== artistId
|
||
) return;
|
||
const title = harvestQueue.shift();
|
||
const key = norm(editionBaseTitle(title));
|
||
if (descHints[key] || pendingHints[key]) continue;
|
||
const { promise } = fetchAlbumInfo(artistId, artistName, title);
|
||
harvestDescHint(artistId, title, await promise);
|
||
}
|
||
} finally {
|
||
harvestRunning -= 1;
|
||
}
|
||
})();
|
||
}
|
||
}
|
||
|
||
/** Saved-track ticks: when an edition of this family is in the library,
|
||
* mark the expander's rows that exist in that local copy. */
|
||
async function loadLibraryTicks(family) {
|
||
const generation = ++libraryTickGeneration;
|
||
libraryTicks = new Set();
|
||
const artistId = page?.artist?.id ?? null;
|
||
const artist = page?.artist?.name ?? "";
|
||
const familyKey = family.familyKey;
|
||
if (artistId == null || !artist) return;
|
||
const local = family.editions
|
||
.map((edition) => libraryLocalAlbum(artist, edition))
|
||
.find(Boolean);
|
||
if (!local) return;
|
||
try {
|
||
const tracks = await api.albumTracks(local.id);
|
||
if (
|
||
generation === libraryTickGeneration
|
||
&& String(page?.artist?.id ?? "") === String(artistId)
|
||
&& openFamilyKey === familyKey
|
||
) {
|
||
libraryTicks = new Set(tracks.map((t) => norm(t.title || "")));
|
||
}
|
||
} catch {
|
||
// library unavailable — ticks simply stay off
|
||
}
|
||
}
|
||
|
||
// The staged catalog can rebuild `tabs` (MB supplement, exhaustive
|
||
// pass) and re-key the open family — e.g. an `edition_group` id landing
|
||
// switches its key form. Re-attach the open expander to the successor
|
||
// family (following it to whichever tab now holds it) instead of letting
|
||
// it silently vanish under the user's click.
|
||
$effect(() => {
|
||
if (!openFamilyKey) return;
|
||
const families = tabs.flatMap((tab) => tab.items);
|
||
const current = families.find((f) => f.familyKey === openFamilyKey);
|
||
if (current) return;
|
||
const successor = families.find((f) =>
|
||
f.editions.some((e) => e.id === openAlbumId) || f.id === openAlbumId);
|
||
if (successor) {
|
||
openFamilyKey = successor.familyKey;
|
||
const owner = tabs.find((tab) => tab.items.some((f) => f.familyKey === successor.familyKey));
|
||
if (owner) activeTabKey = owner.key;
|
||
} else {
|
||
closeExpander();
|
||
}
|
||
});
|
||
|
||
// Esc, and clicking away from the album WITHIN the artist page, close the
|
||
// panel. Deliberately scoped to `.artist-page`: reaching for the player
|
||
// bar, the sidebar, the Now Playing panel or the title bar is not
|
||
// "browsing away from this album", so those clicks leave it open.
|
||
// Clicking another card is excluded here — its own handler switches the
|
||
// expander over to it.
|
||
$effect(() => {
|
||
if (!openFamilyKey) return;
|
||
const onKey = (e) => {
|
||
if (e.key === "Escape") closeExpander();
|
||
};
|
||
const onDown = (e) => {
|
||
if (!e.target.closest(".artist-page")) return;
|
||
if (e.target.closest(".release, .album-expander")) return;
|
||
closeExpander();
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
window.addEventListener("mousedown", onDown);
|
||
return () => {
|
||
window.removeEventListener("keydown", onKey);
|
||
window.removeEventListener("mousedown", onDown);
|
||
};
|
||
});
|
||
|
||
// `.content` is the app's scroll pane. Ask for it by name and only walk as
|
||
// a fallback — and when walking, test whether the box actually SCROLLS.
|
||
// "Taller than its client box" is not the same question: round 12 put a
|
||
// 300vh sheet inside `.artist-page`, so that box overflows while staying
|
||
// `overflow: visible`, and the old walk stopped there. Assigning scrollTop
|
||
// to it is a silent no-op, which is exactly how the album reveal died.
|
||
function scrollPaneFor(node) {
|
||
const named = node.closest(".content");
|
||
if (named) return named;
|
||
let pane = node.parentElement;
|
||
while (pane) {
|
||
const overflow = getComputedStyle(pane).overflowY;
|
||
if (
|
||
(overflow === "auto" || overflow === "scroll")
|
||
&& pane.scrollHeight > pane.clientHeight + 4
|
||
) {
|
||
return pane;
|
||
}
|
||
pane = pane.parentElement;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Opening (or moving) the expander scrolls the clicked album's row to
|
||
// the top of the pane, so the row + its expander read like the album's
|
||
// own page — a low click never leaves the panel off-screen.
|
||
// Hand-driven scroll, NOT scrollIntoView({behavior:"smooth"}): smooth
|
||
// scrollIntoView computes its destination once, and the expander's
|
||
// height animation + Chromium scroll anchoring yank that destination
|
||
// mid-flight (measured settling ~500px short). Recomputing the target
|
||
// every frame and setting scrollTop directly is immune to both, and
|
||
// also tracks an old expander collapsing above during row switches.
|
||
function revealIntoView(node) {
|
||
const row = node.previousElementSibling; // last card of the open row
|
||
const pane = scrollPaneFor(node);
|
||
if (!row || !pane) return;
|
||
// Clears the sticky results-head AND the parked banner under it, plus
|
||
// breathing room. READ, not hard-coded: --banner-h is a fraction of the
|
||
// hero now, so a constant tuned to the old 124px banner would drop a
|
||
// clicked row underneath a taller one. Matches .release's
|
||
// scroll-margin-top, which is the same expression in CSS.
|
||
const page = node.closest(".artist-page");
|
||
const banner = page
|
||
? parseFloat(getComputedStyle(page).getPropertyValue("--banner-h")) || 176
|
||
: 176;
|
||
const MARGIN = Math.round(22 + banner + 18);
|
||
const DURATION = 420;
|
||
const from = pane.scrollTop;
|
||
const start = performance.now();
|
||
let raf = requestAnimationFrame(function step(now) {
|
||
const t = Math.min(1, (now - start) / DURATION);
|
||
const ease = 1 - (1 - t) ** 3;
|
||
const target = row.getBoundingClientRect().top
|
||
- pane.getBoundingClientRect().top + pane.scrollTop - MARGIN;
|
||
pane.scrollTop = from + (target - from) * ease;
|
||
raf = t < 1 ? requestAnimationFrame(step) : null;
|
||
});
|
||
const cancel = () => raf && cancelAnimationFrame(raf);
|
||
// The user's own scrolling wins immediately.
|
||
pane.addEventListener("wheel", cancel, { once: true, passive: true });
|
||
return {
|
||
destroy: () => {
|
||
cancel();
|
||
pane.removeEventListener("wheel", cancel);
|
||
},
|
||
};
|
||
}
|
||
|
||
// Mock-exact open/close motion: the row's height collapses while the
|
||
// content lifts 8px and fades, on a fast-out/long-settle curve (quintOut
|
||
// tracks the mock's cubic-bezier(0.33, 1, 0.4, 1) within a percent).
|
||
function expandSlide(node, { duration = 460 } = {}) {
|
||
const height = node.offsetHeight;
|
||
return {
|
||
duration,
|
||
easing: quintOut,
|
||
css: (t, u) =>
|
||
`overflow: hidden; height: ${t * height}px; min-height: 0;` +
|
||
`opacity: ${Math.min(1, t / 0.74)}; transform: translateY(${-8 * u}px);`,
|
||
};
|
||
}
|
||
|
||
const openAlbum = $derived(
|
||
page?.albums.find((a) => a.id === openAlbumId) ?? null,
|
||
);
|
||
|
||
// Scoped browse accent: the open album's own palette, applied as local
|
||
// --accent/--accent-hover overrides on the open card and expander only.
|
||
// The global accent keeps following the playing track everywhere else.
|
||
let browseAccent = $state(null); // { accent, hover } | null
|
||
const accentColorCache = new Map(); // art source url -> Promise<colors|null>
|
||
const ACCENT_CACHE_MAX = 96;
|
||
|
||
function extractAccent(source) {
|
||
if (!accentColorCache.has(source)) {
|
||
// Palette extraction needs only color distribution, not canonical
|
||
// cover resolution. Sampling the CDN's tiny variant avoids a full
|
||
// image proxy/base64/decode pass for every card.
|
||
const sampleSource = tinyCover(source) ?? gridCover(source);
|
||
const pending = (async () => {
|
||
try {
|
||
const dataUrl = await getCatalogArt(sampleSource);
|
||
return dataUrl ? await accentFromImage(dataUrl) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
})();
|
||
accentColorCache.set(source, pending);
|
||
while (accentColorCache.size > ACCENT_CACHE_MAX) {
|
||
accentColorCache.delete(accentColorCache.keys().next().value);
|
||
}
|
||
// Failures are transient (load-burst contention, CDN hiccups,
|
||
// the art proxy's 10s timeout) — never cache a null palette, so
|
||
// a later attempt retries instead of staying colorless forever.
|
||
pending.then((colors) => {
|
||
if (!colors && accentColorCache.get(source) === pending) {
|
||
accentColorCache.delete(source);
|
||
}
|
||
});
|
||
}
|
||
return accentColorCache.get(source);
|
||
}
|
||
|
||
const accentStyle = (colors) =>
|
||
`--accent: ${colors.accent}; --accent-hover: ${colors.hover};`;
|
||
|
||
$effect(() => {
|
||
const cover = openAlbum?.cover ?? openFamily?.cover ?? null;
|
||
if (!openFamilyKey || !cover || artBroken[cover]) {
|
||
browseAccent = null;
|
||
return;
|
||
}
|
||
let stale = false;
|
||
extractAccent(coverUpgrades[cover] ?? cover).then((colors) => {
|
||
if (!stale) browseAccent = colors;
|
||
});
|
||
return () => { stale = true; };
|
||
});
|
||
|
||
const browseAccentStyle = $derived(browseAccent ? accentStyle(browseAccent) : undefined);
|
||
|
||
// Per-card scoped accents: a card's chrome (edition badge, Playing pill,
|
||
// hover play disc) tints from its OWN cover, not the global accent. The
|
||
// shared viewport gate queues only nearby cards; a 100-release Singles tab
|
||
// no longer performs 100 Rust proxies, base64 allocations and canvas reads
|
||
// just because its metadata exists.
|
||
let cardAccentStyles = $state({}); // canonical cover url -> style string
|
||
const accentQueue = [];
|
||
const accentQueued = new Set();
|
||
let accentQueueActive = 0;
|
||
let accentArtistId = null;
|
||
let accentGeneration = 0;
|
||
|
||
function queueCardAccent(cover, prioritize = false) {
|
||
if (!cover || artBroken[cover]) return;
|
||
if (untrack(() => cardAccentStyles[cover] !== undefined)) return;
|
||
if (accentQueued.has(cover)) {
|
||
if (prioritize) {
|
||
const index = accentQueue.indexOf(cover);
|
||
if (index > 0) {
|
||
accentQueue.splice(index, 1);
|
||
accentQueue.unshift(cover);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
accentQueued.add(cover);
|
||
if (prioritize) accentQueue.unshift(cover);
|
||
else accentQueue.push(cover);
|
||
pumpAccentQueue();
|
||
}
|
||
|
||
function pumpAccentQueue() {
|
||
while (accentQueueActive < 2 && accentQueue.length) {
|
||
const cover = accentQueue.shift();
|
||
accentQueueActive += 1;
|
||
const generation = accentGeneration;
|
||
const extraction = extractAccent(coverUpgrades[cover] ?? cover);
|
||
extraction.then((colors) => {
|
||
// Stored even if the slot below already moved on — a slow
|
||
// fetch still delivers its palette late instead of never.
|
||
if (colors && generation === accentGeneration) {
|
||
cardAccentStyles[cover] = accentStyle(colors);
|
||
}
|
||
});
|
||
// Free the slot after 12s regardless (the art proxy settles in
|
||
// ≤10s) so one slow transfer can't clog the whole queue.
|
||
Promise.race([
|
||
extraction,
|
||
new Promise((resolve) => setTimeout(resolve, 12000)),
|
||
]).then(() => {
|
||
accentQueueActive = Math.max(0, accentQueueActive - 1);
|
||
if (generation === accentGeneration) accentQueued.delete(cover);
|
||
pumpAccentQueue();
|
||
});
|
||
}
|
||
}
|
||
|
||
$effect(() => {
|
||
const artistId = page?.artist?.id ?? null;
|
||
if (artistId !== accentArtistId) {
|
||
accentArtistId = artistId;
|
||
accentGeneration += 1;
|
||
accentQueue.length = 0;
|
||
accentQueued.clear();
|
||
cardAccentStyles = {};
|
||
}
|
||
});
|
||
|
||
onDestroy(() => {
|
||
accentGeneration += 1;
|
||
accentQueue.length = 0;
|
||
accentQueued.clear();
|
||
});
|
||
const railArtAlbum = $derived(
|
||
page?.albums.find((a) => a.id === railArtAlbumId) ?? openAlbum,
|
||
);
|
||
const previousRailArtAlbum = $derived(
|
||
page?.albums.find((a) => a.id === previousRailArtAlbumId) ?? null,
|
||
);
|
||
|
||
// Disc-aware view of the open tracklist: single downward column with
|
||
// quiet disc rules and per-disc numbering when there is more than one.
|
||
const openDiscs = $derived.by(() => {
|
||
const by = new Map();
|
||
for (const t of openTracks) {
|
||
const disc = t.disk_number ?? 1;
|
||
if (!by.has(disc)) by.set(disc, []);
|
||
by.get(disc).push(t);
|
||
}
|
||
return [...by.entries()]
|
||
.sort((a, b) => a[0] - b[0])
|
||
.map(([disc, tracks]) => ({ disc, tracks }));
|
||
});
|
||
const multiDisc = $derived(openDiscs.length > 1);
|
||
const openReleaseLocked = $derived(
|
||
isUpcomingAlbum(openAlbum ?? openFamily ?? {})
|
||
|| openTracks.some((track) => track.unreleased),
|
||
);
|
||
|
||
function fullDate(album) {
|
||
const value = album?.release_date;
|
||
if (!value || value.length < 10) return year(album ?? {});
|
||
const date = new Date(`${value.slice(0, 10)}T00:00:00`);
|
||
return date.toLocaleDateString(undefined, {
|
||
month: "numeric", day: "numeric", year: "numeric",
|
||
});
|
||
}
|
||
|
||
function fmtRuntime(tracks) {
|
||
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`;
|
||
}
|
||
|
||
function year(album) {
|
||
return album.release_date?.slice(0, 4) ?? "";
|
||
}
|
||
|
||
function upcomingDate(album) {
|
||
const value = album.release_date;
|
||
if (!value) return "Upcoming · Date TBA";
|
||
if (value.length === 4) return `Expected ${value} · Date TBA`;
|
||
if (value.length < 10) {
|
||
const month = new Date(`${value.slice(0, 7)}-01T00:00:00`).toLocaleDateString(undefined, {
|
||
month: "short", year: "numeric",
|
||
});
|
||
return `Expected ${month} · Date TBA`;
|
||
}
|
||
const date = new Date(`${value.slice(0, 10)}T00:00:00`);
|
||
return `Releases ${date.toLocaleDateString(undefined, {
|
||
month: "short", day: "numeric", year: "numeric",
|
||
})}`;
|
||
}
|
||
|
||
function fmtDuration(s) {
|
||
if (s == null) return "";
|
||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||
}
|
||
|
||
function fmtFans(n) {
|
||
if (n == null) return "";
|
||
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M fans`;
|
||
if (n >= 1e3) return `${Math.round(n / 1e3)}k fans`;
|
||
return `${n} fans`;
|
||
}
|
||
|
||
function fmtBytes(n) {
|
||
if (!n) return "";
|
||
if (n >= 1e9) return `${(n / 1e9).toFixed(2)} GB`;
|
||
if (n >= 1e6) return `${Math.round(n / 1e6)} MB`;
|
||
return `${Math.round(n / 1e3)} kB`;
|
||
}
|
||
|
||
// Disk space + audio quality of this artist's saved library files (the
|
||
// "on disk" and quality chips). Track sizes/formats are already in the
|
||
// library DB, so this is local-only work — no provider calls — but it does
|
||
// need one album_tracks fetch per matching library album, so it's keyed to
|
||
// skip recomputation until the artist or their library albums change.
|
||
let artistDisk = $state({ key: "", bytes: 0, quality: "", perAlbum: [] });
|
||
let artistDiskRequest = 0;
|
||
const LOSSLESS_EXT = new Set(["flac", "wav", "ape", "aiff", "alac"]);
|
||
$effect(() => {
|
||
const artistName = page?.artist.name ?? "";
|
||
const a = norm(artistName);
|
||
const mine = libraryStore.albums.filter((al) => norm(al.artist || "") === a);
|
||
const key = `${a}:${mine.map((al) => al.id).join(",")}`;
|
||
const request = ++artistDiskRequest;
|
||
if (!a || artistDisk.key === key) return;
|
||
let active = true;
|
||
(async () => {
|
||
let bytes = 0;
|
||
const all = [];
|
||
const perAlbum = [];
|
||
for (const al of mine) {
|
||
if (!active || request !== artistDiskRequest) return;
|
||
try {
|
||
const tracks = await api.albumTracks(al.id);
|
||
if (!active || request !== artistDiskRequest) return;
|
||
let albumBytes = 0;
|
||
for (const t of tracks) {
|
||
albumBytes += t.size ?? 0;
|
||
all.push(t);
|
||
}
|
||
bytes += albumBytes;
|
||
if (albumBytes > 0) perAlbum.push({ title: al.title, bytes: albumBytes });
|
||
} catch {
|
||
// A missing album is just excluded from the total.
|
||
}
|
||
}
|
||
perAlbum.sort((x, y) => y.bytes - x.bytes);
|
||
// Another artist may have loaded while sizes were being fetched.
|
||
if (
|
||
active
|
||
&& request === artistDiskRequest
|
||
&& norm(catalog.artistPage?.artist.name ?? "") === a
|
||
) {
|
||
artistDisk = { key, bytes, quality: qualityLabel(all), perAlbum };
|
||
}
|
||
})();
|
||
return () => (active = false);
|
||
});
|
||
|
||
/** Dominant format of the saved tracks, as "FLAC · 24-bit" / "MP3 · 320".
|
||
* Majority wins so one stray MP3 in a FLAC library doesn't flip the label;
|
||
* the detail (bit depth / bitrate) is the majority among that format. */
|
||
function qualityLabel(tracks) {
|
||
const byExt = new Map();
|
||
for (const t of tracks) {
|
||
const ext = (/\.(\w+)$/.exec(t.path ?? "")?.[1] ?? "").toLowerCase();
|
||
if (!ext) continue;
|
||
if (!byExt.has(ext)) byExt.set(ext, []);
|
||
byExt.get(ext).push(t);
|
||
}
|
||
if (!byExt.size) return "";
|
||
const [ext, group] = [...byExt.entries()].reduce((top, entry) =>
|
||
entry[1].length > top[1].length ? entry : top,
|
||
);
|
||
const majority = (values) => {
|
||
const counts = new Map();
|
||
for (const v of values) {
|
||
if (v == null) continue;
|
||
counts.set(v, (counts.get(v) ?? 0) + 1);
|
||
}
|
||
let best = null;
|
||
for (const [v, n] of counts) {
|
||
if (best == null || n > counts.get(best)) best = v;
|
||
}
|
||
return best;
|
||
};
|
||
const label = ext === "aiff" ? "AIFF" : ext.toUpperCase();
|
||
if (LOSSLESS_EXT.has(ext)) {
|
||
const depth = majority(group.map((t) => t.bit_depth));
|
||
return depth ? `${label} · ${depth}-bit` : label;
|
||
}
|
||
const rate = majority(group.map((t) => t.bitrate_kbps));
|
||
return rate ? `${label} · ${rate}` : label;
|
||
}
|
||
|
||
// Listening history for the play-time / last-played chips. Keyed by the
|
||
// artist name so navigation resets immediately (no stale stats flash)
|
||
// and a same-artist page replacement (art_preview → full) doesn't refetch.
|
||
let playStats = $state({ key: "", seconds: 0, last: null, topTitle: null, topCount: 0 });
|
||
$effect(() => {
|
||
const name = page?.artist.name ?? "";
|
||
if (playStats.key === name) return;
|
||
playStats = { key: name, seconds: 0, last: null, topTitle: null, topCount: 0 };
|
||
if (!name) return;
|
||
(async () => {
|
||
try {
|
||
const stats = await api.artistPlayStats(name);
|
||
if ((catalog.artistPage?.artist.name ?? "") === name) {
|
||
playStats = {
|
||
key: name,
|
||
seconds: stats.seconds ?? 0,
|
||
last: stats.last_played_at ?? null,
|
||
topTitle: stats.top_title ?? null,
|
||
topCount: stats.top_count ?? 0,
|
||
};
|
||
}
|
||
} catch {
|
||
// Backend unavailable — the chips simply stay hidden.
|
||
}
|
||
})();
|
||
});
|
||
|
||
function fmtPlayTime(secs) {
|
||
if (!secs || secs < 60) return "";
|
||
if (secs < 3600) return `${Math.round(secs / 60)} min`;
|
||
return `${(secs / 3600).toFixed(1)} hours`;
|
||
}
|
||
|
||
function fmtLastPlayed(ts) {
|
||
if (!ts) return "";
|
||
const d = new Date(ts * 1000);
|
||
const now = new Date();
|
||
if (d.toDateString() === now.toDateString()) return "Today";
|
||
const yesterday = new Date(now);
|
||
yesterday.setDate(now.getDate() - 1);
|
||
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
|
||
const opts = { month: "short", day: "numeric" };
|
||
if (d.getFullYear() !== now.getFullYear()) opts.year = "numeric";
|
||
return d.toLocaleDateString(undefined, opts);
|
||
}
|
||
|
||
// Social-link display: icon + pale desaturated brand tint per kind (the
|
||
// approved "ghost icons" treatment — full brand colours would fight the
|
||
// adaptive accent theme). `rgb` feeds the hover bloom via rgba().
|
||
const SOCIAL_META = {
|
||
website: { icon: "globe", label: "Website", rgb: "160,168,177" },
|
||
instagram: { icon: "brand-instagram", label: "Instagram", rgb: "213,154,171" },
|
||
x: { icon: "brand-x", label: "X", rgb: "213,217,221", solid: true },
|
||
facebook: { icon: "brand-facebook", label: "Facebook", rgb: "143,168,205" },
|
||
tiktok: { icon: "brand-tiktok", label: "TikTok", rgb: "159,196,204" },
|
||
youtube: { icon: "brand-youtube", label: "YouTube", rgb: "201,143,143" },
|
||
bandcamp: { icon: "brand-bandcamp", label: "Bandcamp", rgb: "127,173,189", solid: true },
|
||
soundcloud: { icon: "brand-soundcloud", label: "SoundCloud", rgb: "208,162,132" },
|
||
};
|
||
|
||
// TODO(follow): visual-only for now — no persistence. Resets when the
|
||
// artist changes so a heart never carries over to the next page.
|
||
let followed = $state(false);
|
||
let followedArtistId = null;
|
||
$effect(() => {
|
||
const id = page?.artist.id ?? null;
|
||
if (id !== followedArtistId) {
|
||
followedArtistId = id;
|
||
followed = false;
|
||
}
|
||
});
|
||
|
||
// First→latest release year of the released discography ("years active").
|
||
// A release within the last two calendar years reads as "present".
|
||
const activeYears = $derived.by(() => {
|
||
if (!page) return null;
|
||
const years = page.albums
|
||
.filter((album) => !isUpcomingAlbum(album))
|
||
.map((album) => Number(album.release_date?.slice(0, 4)))
|
||
.filter((y) => Number.isFinite(y) && y > 1900);
|
||
if (!years.length) return null;
|
||
const first = Math.min(...years);
|
||
const last = Math.max(...years);
|
||
const nowYear = new Date().getFullYear();
|
||
return last >= nowYear - 1 ? `${first}–present` : `${first}–${last}`;
|
||
});
|
||
|
||
function playPopular(track) {
|
||
if (!page) return;
|
||
streamSong(page.artist.name, track.title, {
|
||
cover: track.cover,
|
||
trackId: track.id,
|
||
albumId: track.album_id,
|
||
album: track.album || null,
|
||
expectSecs: track.duration_secs ?? null,
|
||
});
|
||
}
|
||
|
||
let heroBusy = $state(false);
|
||
let heroNotice = $state("");
|
||
let heroNoticeTimer;
|
||
onDestroy(() => clearTimeout(heroNoticeTimer));
|
||
|
||
function showHeroNotice(message) {
|
||
heroNotice = message;
|
||
clearTimeout(heroNoticeTimer);
|
||
heroNoticeTimer = setTimeout(() => (heroNotice = ""), 5000);
|
||
}
|
||
|
||
async function chooseHeroPhoto() {
|
||
if (!page || heroBusy) return;
|
||
heroBusy = true;
|
||
try {
|
||
const imported = await api.pickArtistHero(page.artist.id);
|
||
if (imported) {
|
||
const seed = page.artist;
|
||
invalidateCatalogArtist(page.artist.id);
|
||
clearCatalogHeroArtCache();
|
||
await openCatalogArtist(page.artist.id, seed);
|
||
showHeroNotice(`Custom hero saved · ${imported.width}×${imported.height}`);
|
||
}
|
||
} catch (error) {
|
||
showHeroNotice(String(error).replace(/^Error:\s*/, ""));
|
||
} finally {
|
||
heroBusy = false;
|
||
}
|
||
}
|
||
|
||
async function clearHeroPhoto() {
|
||
if (!page || heroBusy) return;
|
||
heroBusy = true;
|
||
try {
|
||
await api.clearArtistHero(page.artist.id);
|
||
const seed = page.artist;
|
||
invalidateCatalogArtist(page.artist.id);
|
||
clearCatalogHeroArtCache();
|
||
await openCatalogArtist(page.artist.id, seed);
|
||
showHeroNotice("Custom hero removed");
|
||
} catch (error) {
|
||
showHeroNotice(String(error).replace(/^Error:\s*/, ""));
|
||
} finally {
|
||
heroBusy = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<div class="artist-page" data-load-stage={pageLoadStage} data-scroll={heroScrolled ? "1" : "0"}>
|
||
{#if catalog.artistLoading && !page}
|
||
<button class="back flow" onclick={() => (ui.view = "catalog")}>
|
||
<Icon name="chevron-left" size={16} /> Results
|
||
</button>
|
||
{#if resolver.active}
|
||
<p class="resolving"><span class="pulse">●</span> {resolver.label} — finding the best source…</p>
|
||
{:else if resolver.error}
|
||
<p class="resolving error">{resolver.error}</p>
|
||
{/if}
|
||
<p class="hint">Loading artist…</p>
|
||
{:else if !page}
|
||
<button class="back flow" onclick={() => (ui.view = "catalog")}>
|
||
<Icon name="chevron-left" size={16} /> Results
|
||
</button>
|
||
{#if resolver.error}
|
||
<p class="resolving error">{resolver.error}</p>
|
||
{/if}
|
||
<p class="hint">{catalog.error || "Artist not found."}</p>
|
||
{:else}
|
||
<!-- ROUND 12, "three layers" (the user's own model, mocked and
|
||
approved as research/artist-header-scroll/v12-three-layers.html
|
||
build 5). Content on top (z 3) → the page background SHEET with
|
||
its gradient edge dissolving into the hero (z 2) → the hero at
|
||
the bottom (z 1), pinned, NEVER cut or masked. The seams of
|
||
rounds 7-10 came from recomputing gradient stops against the
|
||
scroll — here every gradient is baked and only whole layers
|
||
translate, so there is no boundary a line can form on.
|
||
|
||
LAYER 1 — THE HERO, PINNED, AT THE BOTTOM. It never moves and is
|
||
never cut; the sheet and the content slide over it. Only filters
|
||
and the round-9 uniform squeeze (rigid translations of an
|
||
unmasked layer) ride --p. -->
|
||
<div class="heropin" use:heroScroll>
|
||
<!-- Banner-as-background: the full artwork at native aspect, bled to
|
||
the pane edges, extending down behind the content and dissolving
|
||
into the page. A blurred bloom fills the strip behind the glass bar
|
||
(behind the sharp photo, so the artwork itself is untouched); the
|
||
vertical scrim finishes the lower dissolve. During rotation,
|
||
the outgoing pair stays underneath until the new pair fades in. -->
|
||
<div class="hero-bg" aria-hidden="true">
|
||
<div class="hero-fallback" style="--seed-hue: {seedHue}"></div>
|
||
{#if seedUrl}
|
||
<div
|
||
class="hero-seed"
|
||
class:hidden={heroPainted}
|
||
style="background-image: url('{seedUrl}')"
|
||
></div>
|
||
{/if}
|
||
{#if previousBanner?.url}
|
||
<img class="bloom previous" src={previousBanner.url} alt="" draggable="false" />
|
||
<img class="banner previous" src={previousBanner.url} alt="" draggable="false" />
|
||
{/if}
|
||
{#if selectedUsesGpu && banner}
|
||
<img class="bloom gpu-underlay" src={banner} alt="" draggable="false" />
|
||
<img class="banner gpu-underlay" src={banner} alt="" draggable="false" />
|
||
{/if}
|
||
{#if gpuRendererMounted}
|
||
<HeroGpuCanvas
|
||
source={selectedUsesGpu ? selectedHeroSource : ""}
|
||
scope={page?.artist.id ?? ""}
|
||
plan={displayedBanner.source === selectedHeroSource
|
||
? displayedBanner.plan ?? null
|
||
: null}
|
||
active={documentVisible}
|
||
onloading={gpuHeroLoading}
|
||
onready={gpuHeroReady}
|
||
onerror={gpuHeroError}
|
||
/>
|
||
{/if}
|
||
{#if banner && !selectedUsesGpu}
|
||
{#key displayedBanner.key}
|
||
<img class="bloom current" class:initial={initialBannerPaint} class:finish={finishBannerPaint} src={banner} alt="" draggable="false" />
|
||
<img
|
||
class="banner current"
|
||
class:initial={initialBannerPaint}
|
||
class:finish={finishBannerPaint}
|
||
src={banner}
|
||
alt=""
|
||
draggable="false"
|
||
onerror={() => {
|
||
const source = displayedBanner.source;
|
||
if (source && !failedBannerSources.includes(source)) {
|
||
failedBannerSources = [...failedBannerSources, source];
|
||
}
|
||
displayedBanner = previousBanner ?? { source: "", url: null, key: "" };
|
||
previousBanner = null;
|
||
advanceHero(source);
|
||
}}
|
||
/>
|
||
{/key}
|
||
{:else if !selectedUsesGpu}
|
||
<div class="banner base"></div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- LAYER 2 — THE SHEET. The page background itself: its top edge is
|
||
a BAKED gradient dissolving into the hero (the live scrim's own
|
||
easing), and below that it IS the page. It translates RIGIDLY
|
||
between two poses on --p — at rest the dissolve sits on the
|
||
hero's foot exactly as the old .hero-scrim did; parked, its edge
|
||
rides just above the content edge (the pose the user approved).
|
||
This one gradient replaces the scrim, the veil, the eraser and
|
||
the wash of rounds 7-10: fewer layers, nothing to hand over
|
||
between them, and a baked gradient moved as a whole cannot
|
||
seam. -->
|
||
<div class="sheetpin" aria-hidden="true"><div class="sheet"></div></div>
|
||
|
||
<!-- LAYER 30 — THE IDENTITY. The block's bottom IS --edge at every
|
||
scroll position, so the wordmark and the Play/chips row are
|
||
anchored to the same moving line the artwork is cut at and can
|
||
never drift from it. Both children are absolutely positioned
|
||
and the pin is height:0, so the layer adds nothing to the flow;
|
||
the room they occupied at rest is reserved by .hero-zone and
|
||
.actions-gap below. -->
|
||
<div class="idpin" data-w={chipTier}>
|
||
<div class="idblock">
|
||
<div class="hero-copy">
|
||
<!-- Reserved-height title slot: the text name and the logo share
|
||
one bottom-left origin and crossfade, so the wordmark fades
|
||
in over the name without a size/position pop or reflow. -->
|
||
<div class="logo-slot" style="--slot-h: {logoRenderHeight}px">
|
||
{#if page.artist.name}
|
||
<h1 class="name-title" class:gone={logo.mode !== "none"}>{page.artist.name}</h1>
|
||
{:else}
|
||
<div class="name-title loading-name" aria-label="Loading artist"></div>
|
||
{/if}
|
||
{#if logo.mode === "mask"}
|
||
<div class="logo-wrap">
|
||
<div
|
||
class="artist-logo mask"
|
||
style="--logo: url('{logo.url}'); --logo-aspect: {logo.aspect}; --logo-h: {logoRenderHeight}px"
|
||
role="img"
|
||
aria-label={page.artist.name}
|
||
></div>
|
||
</div>
|
||
{:else if logo.mode === "color"}
|
||
<div class="logo-wrap">
|
||
<img
|
||
class="artist-logo"
|
||
src={logo.url}
|
||
style="--logo-h: {logoRenderHeight}px"
|
||
alt={page.artist.name}
|
||
draggable="false"
|
||
/>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{#if popularTracks.length}
|
||
<div class="artist-actions">
|
||
<button class="hero-play" title="Play popular songs" onclick={() => playPopular(popularTracks[0])}>
|
||
<Icon name="play" size={21} />
|
||
</button>
|
||
<!-- Preview tracks: a secondary circle in the transport
|
||
slot, never inside .stat-chips — the chip row sheds
|
||
its tail on narrow panes and an action must not be
|
||
what disappears. The slot is unconditional so the
|
||
chips beside it cannot shift as staged data lands;
|
||
only the button's own state changes. -->
|
||
<button
|
||
class="hero-preview"
|
||
disabled={previewState === "none"}
|
||
title={previewLabel}
|
||
aria-label={previewLabel}
|
||
onclick={openPreviews}
|
||
>
|
||
<Icon name="preview-reel" size={18} strokeWidth={1.8} />
|
||
{#if previewState === "partial"}
|
||
<span class="preview-count" aria-hidden="true">{previewableCount}</span>
|
||
{/if}
|
||
</button>
|
||
<!-- The hero's old meta line, repurposed as floating stat
|
||
chips beside Play (Steam-style, but free-floating —
|
||
deliberately no containing bar). Each chip hides when
|
||
its stat is empty rather than showing a zero. -->
|
||
{#if !page.art_preview}
|
||
<div class="stat-chips">
|
||
{#if releasedCount > 0}
|
||
<span class="chip"><Icon name="disc" size={13} /> {releasedCount} releases</span>
|
||
{/if}
|
||
{#if page.artist.fans}
|
||
<span class="chip"><Icon name="users" size={13} /> {fmtFans(page.artist.fans)}</span>
|
||
{/if}
|
||
{#if activeYears}
|
||
<span class="chip" title="Years active"><Icon name="calendar" size={13} /> {activeYears}</span>
|
||
{/if}
|
||
{#if libraryOwnedCount > 0 && releasedCount > 0}
|
||
<span class="chip" title="{libraryOwnedCount} of {releasedCount} releases in your library">
|
||
<Icon name="library" size={13} /> {libraryOwnedCount}/{releasedCount}
|
||
<span class="chip-bar"><span
|
||
class="chip-fill"
|
||
style="width: {Math.min(100, Math.round((libraryOwnedCount / releasedCount) * 100))}%"
|
||
></span></span>
|
||
</span>
|
||
{/if}
|
||
{#if artistDisk.bytes > 0}
|
||
<span class="chip has-pop">
|
||
<Icon name="hard-drive" size={13} /> {fmtBytes(artistDisk.bytes)}
|
||
{#if artistDisk.perAlbum.length}
|
||
<span class="chip-pop" role="tooltip">
|
||
{#each artistDisk.perAlbum as row (row.title)}
|
||
<span class="pop-row">
|
||
<span class="pop-title">{row.title}</span>
|
||
<span class="pop-val">{fmtBytes(row.bytes)}</span>
|
||
</span>
|
||
{/each}
|
||
</span>
|
||
{/if}
|
||
</span>
|
||
{/if}
|
||
{#if artistDisk.quality}
|
||
<span class="chip" title="Dominant format of your saved tracks">
|
||
<Icon name="music" size={13} /> {artistDisk.quality}
|
||
</span>
|
||
{/if}
|
||
{#if playStats.seconds >= 60}
|
||
<span class="chip" title="Total listening time">
|
||
<Icon name="clock" size={13} /> {fmtPlayTime(playStats.seconds)}
|
||
</span>
|
||
{/if}
|
||
{#if playStats.last}
|
||
<span class="chip" title="Last played">
|
||
<Icon name="history" size={13} /> {fmtLastPlayed(playStats.last)}
|
||
</span>
|
||
{/if}
|
||
{#if playStats.topTitle && playStats.topCount >= 2}
|
||
<span class="chip" title="Your most played track">
|
||
<Icon name="trophy" size={13} /> {playStats.topTitle} · {playStats.topCount}×
|
||
</span>
|
||
{/if}
|
||
{#if page.top_genre}
|
||
<span class="chip" title="Top genre">
|
||
<Icon name="tag" size={13} /> {page.top_genre}
|
||
</span>
|
||
{/if}
|
||
<!-- TODO(follow): visual toggle only — persistence
|
||
and the followed-artists feature come later. -->
|
||
<button
|
||
class="chip follow"
|
||
class:on={followed}
|
||
title={followed ? "Unfollow artist" : "Follow artist"}
|
||
aria-pressed={followed}
|
||
onclick={() => (followed = !followed)}
|
||
><Icon name="heart" size={14} fill={followed} /></button>
|
||
{#if page.socials?.length}
|
||
<span class="chip-divider" aria-hidden="true"></span>
|
||
{#each page.socials as link (link.kind)}
|
||
{@const meta = SOCIAL_META[link.kind] ?? SOCIAL_META.website}
|
||
<a
|
||
class="social-ghost"
|
||
href={link.url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
title={meta.label}
|
||
style="--soc: {meta.rgb}"
|
||
onclick={(e) => { e.preventDefault(); api.openExternal(link.url); }}
|
||
><Icon name={meta.icon} size={16} fill={meta.solid ?? false} /></a>
|
||
{/each}
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Frosted-glass header in parity with the Now Playing panel's
|
||
.np-head; it floats over the banner's bloom strip at the top.
|
||
data-scrollhead keeps the overlay scrollbar below this bar. -->
|
||
<div class="results-head" data-scrollhead>
|
||
<button class="back" onclick={() => (ui.view = "catalog")}>
|
||
<Icon name="chevron-left" size={16} /> Results
|
||
</button>
|
||
{#if resolver.active}
|
||
<span class="rstatus"><span class="pulse">●</span> {resolver.label} — finding the best source…</span>
|
||
{:else if resolver.error}
|
||
<span class="rstatus error">{resolver.error}</span>
|
||
{/if}
|
||
<div class="hero-art-actions">
|
||
{#if heroNotice}
|
||
<span class:error={heroNotice.includes("Choose") || heroNotice.includes("must")} class="hero-art-notice">{heroNotice}</span>
|
||
{/if}
|
||
<button
|
||
class:active={page.banner_custom}
|
||
class="hero-art-btn"
|
||
title="Choose a high-resolution hero photo (minimum 1920×800)"
|
||
disabled={heroBusy || page.artist.id == null}
|
||
onclick={chooseHeroPhoto}
|
||
>
|
||
<Icon name="image" size={15} />
|
||
<span>{page.banner_custom ? "Change hero" : "Choose hero"}</span>
|
||
</button>
|
||
{#if page.banner_custom}
|
||
<button
|
||
class="hero-art-btn icon-only"
|
||
title="Remove custom hero"
|
||
disabled={heroBusy}
|
||
onclick={clearHeroPhoto}
|
||
><Icon name="x" size={14} /></button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pure spacer now: it still reserves the room the artwork needs and
|
||
still sets where the content begins (both from its aspect-ratio,
|
||
which never depended on its children), but the wordmark it used to
|
||
hold has moved into the pinned identity layer above. -->
|
||
<div class="hero-zone" use:measureHero></div>
|
||
|
||
<!-- LAYER 3 — THE CONTENT, on top of the sheet and the hero. Its one
|
||
special behaviour: rows fade out over 18px as they slide under
|
||
the banner line. Where scroll-driven animations exist, the
|
||
height-0 sticky .fade-host parks at the cut line, .fade-frame
|
||
(a real box hung off it) carries a CONSTANT clip + mask, and
|
||
.fade-flow counter-translates the content past it with a
|
||
compositor-driven scroll animation — nothing updates per frame
|
||
on the main thread, so a
|
||
fast flick can never outrun the mask (the old per-frame
|
||
--flow-cut rewrite lagged the compositor scroll and rows poked
|
||
above the cut; a scroll-driven CUSTOM PROPERTY animation lagged
|
||
identically — only transform/opacity/filter composite). Without
|
||
the mask, the top layer would climb over the banner past the
|
||
park — the exact problem that made round 7 flip the hero on top
|
||
and start cutting it. Mask gated on data-scroll: the resting
|
||
page carries none at all. Full mechanism notes at the CSS. -->
|
||
<div class="scrollbody">
|
||
<div class="fade-host">
|
||
<div class="fade-frame">
|
||
<div class="fade-flow">
|
||
{#if page.partial_error}
|
||
<p class="resolving error">{page.partial_error}</p>
|
||
{:else if page.art_preview}
|
||
<p class="resolving stage-status"><span class="pulse">●</span>
|
||
{page.albums.length || popularTracks.length
|
||
? "Refining release details…"
|
||
: "Loading songs and releases…"}
|
||
</p>
|
||
{/if}
|
||
|
||
{#if page.art_preview && !popularTracks.length && !page.albums.length}
|
||
<section class="catalog-skeleton" aria-label="Loading artist catalog">
|
||
<div class="skeleton-heading"></div>
|
||
<div class="skeleton-tracks">
|
||
{#each [0, 1, 2, 3, 4] as row (row)}
|
||
<div class="skeleton-track">
|
||
<span class="skeleton-rank"></span>
|
||
<span class="skeleton-cover"></span>
|
||
<span class="skeleton-copy"></span>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
<div class="skeleton-heading releases"></div>
|
||
<div class="skeleton-cards">
|
||
{#each [0, 1, 2, 3, 4] as card (card)}
|
||
<span class="skeleton-card"></span>
|
||
{/each}
|
||
</div>
|
||
</section>
|
||
{/if}
|
||
|
||
{#if popularTracks.length}
|
||
<section class="popular">
|
||
<!-- Reserves exactly the flow space the Play/chips row used
|
||
to occupy: the row itself now rides in the pinned identity
|
||
layer above. Its measured height is what --row-h is read
|
||
from, so the two can never drift apart. -->
|
||
<div class="actions-gap" aria-hidden="true"></div>
|
||
<h2>Popular</h2>
|
||
<div class="popular-list">
|
||
{#each popularTracks as track, index (track.id)}
|
||
<div
|
||
class="popular-row"
|
||
class:now={nowPlaying("trackId", track.id)}
|
||
role="button"
|
||
tabindex="0"
|
||
title="Play — streams from the best source"
|
||
onclick={() => playPopular(track)}
|
||
onkeydown={(e) => e.key === "Enter" && playPopular(track)}
|
||
>
|
||
<span class="rank">{nowPlaying("trackId", track.id) ? "♫" : index + 1}</span>
|
||
{#if track.cover && !artBroken[track.cover]}
|
||
<img class="track-cover" src={track.cover} alt="" loading="lazy" decoding="async" draggable="false" onerror={() => markArtBroken(track.cover)} />
|
||
{:else}
|
||
<div class="track-cover placeholder"><Icon name="disc" size={16} /></div>
|
||
{/if}
|
||
<span class="track-name">{track.title}</span>
|
||
<span class="track-album">{track.album}</span>
|
||
<span class="tdur">{fmtDuration(track.duration_secs)}</span>
|
||
<button
|
||
class="mini-get"
|
||
title="Save to library"
|
||
onclick={(e) => { e.stopPropagation(); saveTrackAction(page.artist.name, track); }}
|
||
><Icon name={saved[`t${track.id}`] === "ok" ? "check" : saved[`t${track.id}`] === "err" ? "x" : "download"} size={13} /></button>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{#if allPopular.length > 5}
|
||
<button class="popular-more" onclick={() => (showAllPopular = !showAllPopular)}>
|
||
{showAllPopular ? "Show less" : "Show more"}
|
||
</button>
|
||
{/if}
|
||
</section>
|
||
{/if}
|
||
|
||
{#if tabs.length}
|
||
<div class="disco-head">
|
||
<h2 class="disco-title">Discography</h2>
|
||
</div>
|
||
<div class="disco-tabs" role="tablist">
|
||
{#each tabs as tab (tab.key)}
|
||
<button
|
||
class="disco-tab"
|
||
class:on={activeTabKey === tab.key}
|
||
role="tab"
|
||
aria-selected={activeTabKey === tab.key}
|
||
onclick={() => selectTab(tab.key)}
|
||
>{tab.label}</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
{#each visibleTabs as group (group.key)}
|
||
<div class="grid" use:measureCols={group.key}>
|
||
{#each group.items as al, cardIndex (al.familyKey)}
|
||
<div
|
||
class="release"
|
||
class:active={openFamilyKey === al.familyKey}
|
||
class:now={familyNowPlaying(al)}
|
||
style={(openFamilyKey === al.familyKey ? browseAccentStyle : undefined) ?? cardAccentStyles[al.cover]}
|
||
data-fkey={al.familyKey}
|
||
in:fade={{ duration: gridAnimated ? 260 : 0 }}
|
||
role="button"
|
||
tabindex="0"
|
||
title={openFamilyKey === al.familyKey ? "Close" : `Open ${al.title}`}
|
||
onclick={() => toggleExpander(al)}
|
||
onkeydown={(e) => e.key === "Enter" && toggleExpander(al)}
|
||
onmouseenter={() => {
|
||
queueCardAccent(al.cover, true);
|
||
queueDescPrefetch(al);
|
||
}}
|
||
onmouseleave={cancelDescPrefetch}
|
||
>
|
||
<div class="cover-wrap">
|
||
{#if familyNowPlaying(al)}
|
||
<span class="now-badge" class:live={player.status === "playing" && documentVisible}>
|
||
<span class="eqb"><b></b><b></b><b></b></span>Playing
|
||
</span>
|
||
{/if}
|
||
{#if al.editions.length > 1}
|
||
<span class="ed-badge" title="{al.editions.length} editions">
|
||
<Icon name="layers" size={10} /> {al.editions.length}
|
||
</span>
|
||
{/if}
|
||
{#if al.cover && !artBroken[al.cover]}
|
||
<div class="cover-stack">
|
||
{#if tinyCover(al.cover)}
|
||
<img class="cover-thumb" src={tinyCover(al.cover)} alt="" aria-hidden="true" loading="lazy" decoding="async" draggable="false" />
|
||
{/if}
|
||
<img
|
||
class="cover"
|
||
use:smoothSrc={{
|
||
url: gridCover(coverUpgrades[al.cover] ?? al.cover),
|
||
album: al,
|
||
artist: page.artist.name,
|
||
artistId: page.artist.id,
|
||
ready: !page.art_preview && !page.cover_logo_pending,
|
||
}}
|
||
alt=""
|
||
loading="lazy"
|
||
decoding="async"
|
||
draggable="false"
|
||
onerror={(event) => handleCoverError(event.currentTarget, al.cover, al.title)}
|
||
/>
|
||
</div>
|
||
{:else}
|
||
<div class="cover placeholder"><Icon name="disc" size={30} /></div>
|
||
{/if}
|
||
{#if group.key !== "upcoming"}
|
||
<button
|
||
class="hoverplay"
|
||
title="Play — streams from the best source"
|
||
onclick={(e) => { e.stopPropagation(); streamRelease(al, al); }}
|
||
><Icon name="play" size={13} /></button>
|
||
{/if}
|
||
</div>
|
||
<div class="title" title={al.title}>
|
||
{al.title}
|
||
{#if familyInLibrary(al)}<span class="owned" title="In your library"><Icon name="check" size={11} /></span>{/if}
|
||
</div>
|
||
<div class="sub">
|
||
{group.key === "upcoming" ? upcomingDate(al) : year(al)}{al.track_count ? ` · ${al.track_count} tracks` : ""}
|
||
</div>
|
||
</div>
|
||
{#if expanderIndex(group) === cardIndex}
|
||
<div
|
||
class="album-expander"
|
||
style={browseAccentStyle ?? (openFamily ? cardAccentStyles[openFamily.cover] : undefined)}
|
||
transition:expandSlide={{ duration: 460 }}
|
||
use:revealIntoView
|
||
>
|
||
<span class="notch" style="left: {notchLeft(group)}"></span>
|
||
{#key openFamilyKey}
|
||
<div class="xp-panel" in:fly={{ y: 6, duration: 150 }}>
|
||
<div class="xp-body">
|
||
<div class="xp-rail">
|
||
{#if previousRailArtAlbum?.cover && !artBroken[previousRailArtAlbum.cover]}
|
||
<img class="rail-glow rail-art-outgoing" src={coverUpgrades[previousRailArtAlbum.cover] ?? previousRailArtAlbum.cover} alt="" aria-hidden="true" draggable="false" />
|
||
{/if}
|
||
{#key railArtTransitionKey}
|
||
{#if railArtAlbum?.cover && !artBroken[railArtAlbum.cover]}
|
||
<img class="rail-glow" class:rail-art-incoming={previousRailArtAlbumId != null} src={coverUpgrades[railArtAlbum.cover] ?? railArtAlbum.cover} alt="" aria-hidden="true" draggable="false" />
|
||
{/if}
|
||
{/key}
|
||
<div class="rail-shade"></div>
|
||
<div class="rail-art-stage">
|
||
{#if previousRailArtAlbum?.cover && !artBroken[previousRailArtAlbum.cover]}
|
||
<img class="rail-art rail-art-outgoing" src={coverUpgrades[previousRailArtAlbum.cover] ?? previousRailArtAlbum.cover} alt="" draggable="false" />
|
||
{/if}
|
||
{#key railArtTransitionKey}
|
||
{#if railArtAlbum?.cover && !artBroken[railArtAlbum.cover]}
|
||
<img class="rail-art" class:rail-art-incoming={previousRailArtAlbumId != null} src={coverUpgrades[railArtAlbum.cover] ?? railArtAlbum.cover} alt="" draggable="false" onerror={(event) => handleCoverError(event.currentTarget, railArtAlbum.cover, railArtAlbum.title)} />
|
||
{:else}
|
||
<div class="rail-art placeholder" class:rail-art-incoming={previousRailArtAlbumId != null}><Icon name="disc" size={40} /></div>
|
||
{/if}
|
||
{/key}
|
||
</div>
|
||
<h3 class="rail-title">{openAlbum?.title ?? openFamily?.baseTitle ?? ""}</h3>
|
||
<p class="rail-meta">
|
||
{group.key === "upcoming" ? upcomingDate(openAlbum ?? openFamily) : fullDate(openAlbum ?? openFamily)}{openTracks.length
|
||
? ` · ${openTracks.length} tracks`
|
||
: openAlbum?.track_count
|
||
? ` · ${openAlbum.track_count} tracks`
|
||
: ""}{fmtRuntime(openTracks) ? ` · ${fmtRuntime(openTracks)}` : ""}{openFamily && familyInLibrary(openFamily) ? " · in your library" : ""}
|
||
</p>
|
||
{#if !openReleaseLocked}
|
||
<div class="rail-cta">
|
||
<button
|
||
class="pill grow"
|
||
title="Play — streams from the best source"
|
||
onclick={() => streamRelease(openAlbum ?? openFamily, openFamily)}
|
||
><Icon name="play" size={12} /> Play</button>
|
||
<button
|
||
class="pill ghost"
|
||
title="Save to library"
|
||
onclick={() => saveAlbumAction({ ...(openAlbum ?? openFamily), artist: page.artist.name }, openFamily)}
|
||
><Icon name={saved[`a${openAlbumId}`] === "ok" ? "check" : saved[`a${openAlbumId}`] === "err" ? "x" : "download"} size={13} /></button>
|
||
</div>
|
||
{/if}
|
||
{#if openInfo?.description}
|
||
<p class="rail-desc" class:clamped={!descExpanded}>{openInfo.description}</p>
|
||
<button
|
||
class="rail-more"
|
||
class:open={descExpanded}
|
||
onclick={() => (descExpanded = !descExpanded)}
|
||
>{descExpanded ? "Show less" : "Show more"} <Icon name="chevron-down" size={13} /></button>
|
||
{/if}
|
||
{#if openFamily && openFamily.editions.length > 1}
|
||
<div class="rail-eds">
|
||
<span class="rail-lab">Editions</span>
|
||
{#each openFamily.editions as edition (edition.id)}
|
||
<button
|
||
class="rail-ed"
|
||
class:on={openAlbumId === edition.id}
|
||
title={edition.title}
|
||
onclick={() => showEdition(edition)}
|
||
>
|
||
{#if edition.cover && !artBroken[edition.cover]}
|
||
<img class="rail-ed-art" src={coverUpgrades[edition.cover] ?? edition.cover} alt="" loading="lazy" decoding="async" draggable="false" onerror={(event) => handleCoverError(event.currentTarget, edition.cover, edition.title)} />
|
||
{:else}
|
||
<span class="rail-ed-art placeholder"><Icon name="disc" size={14} /></span>
|
||
{/if}
|
||
<span class="rail-ed-copy">
|
||
<span class="n">{editionLabel(edition, openFamily)}</span>
|
||
<span class="y">{year(edition)}{edition.track_count ? ` · ${edition.track_count} tracks` : ""}</span>
|
||
</span>
|
||
{#if inLibrary(edition)}<span class="own" title="In your library"><Icon name="check" size={12} /></span>{/if}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
<div class="xp-tracks">
|
||
{#if openLoading}
|
||
<p class="hint small">Loading tracks…</p>
|
||
{:else}
|
||
{#each openDiscs as d (d.disc)}
|
||
{#if multiDisc}
|
||
<div class="disc-head">Disc {d.disc} · {d.tracks.length} tracks</div>
|
||
{/if}
|
||
{#each d.tracks as t (t.id)}
|
||
{@const locked = t.unreleased}
|
||
{@const playTrack = () => {
|
||
if (locked) return;
|
||
streamSong(page.artist.name, t.title, {
|
||
cover: t.cover ?? openAlbum?.cover,
|
||
trackId: t.id,
|
||
albumId: openAlbumId,
|
||
album: openAlbum?.title || null,
|
||
expectSecs: t.duration_secs ?? null,
|
||
});
|
||
}}
|
||
<div
|
||
class="trow"
|
||
class:now={nowPlaying("trackId", t.id)}
|
||
class:locked
|
||
role="button"
|
||
aria-disabled={locked}
|
||
tabindex={locked ? -1 : 0}
|
||
title={locked ? "Not released yet" : "Play — streams from the best source"}
|
||
onclick={playTrack}
|
||
onkeydown={(e) => e.key === "Enter" && playTrack()}
|
||
>
|
||
<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 locked}
|
||
<span class="tag-unreleased">Unreleased</span>
|
||
{:else}
|
||
<span class="tdur">{fmtDuration(t.duration_secs)}</span>
|
||
{/if}
|
||
{#if !locked}
|
||
<button
|
||
class="mini-btn play"
|
||
title="Play"
|
||
onclick={(e) => { e.stopPropagation(); playTrack(); }}
|
||
><Icon name="play" size={13} /></button>
|
||
<button
|
||
class="mini-get"
|
||
title="Save to library"
|
||
onclick={(e) => { e.stopPropagation(); saveTrackAction(page.artist.name, t); }}
|
||
><Icon name={saved[`t${t.id}`] === "ok" ? "check" : saved[`t${t.id}`] === "err" ? "x" : "download"} size={13} /></button>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
{/each}
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/key}
|
||
</div>
|
||
{/if}
|
||
{/each}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<style>
|
||
.hint {
|
||
color: var(--text-secondary);
|
||
padding-top: 10vh;
|
||
text-align: center;
|
||
}
|
||
|
||
.hint.small {
|
||
padding: 12px 0;
|
||
text-align: left;
|
||
}
|
||
|
||
/* Back control for the loading / error states, shown before the hero
|
||
exists so a back affordance is always present (the glass header below
|
||
only exists once the artist page has loaded). */
|
||
.back.flow {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
color: var(--text-secondary);
|
||
font-weight: 600;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.back.flow:hover {
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
/* The page is the positioning + stacking context for the full-bleed banner
|
||
background. isolation keeps the banner's z-index:-1 layer above the
|
||
content pane's own background instead of disappearing behind it.
|
||
|
||
It also carries the whole hero → banner transition, which is one number
|
||
and a pile of calc() off it. Layer order, top to bottom:
|
||
content (auto) → eraser 15 → hero 20 → identity 30 → glass bar 40. */
|
||
.artist-page {
|
||
position: relative;
|
||
isolation: isolate;
|
||
|
||
/* Written by heroScroll(). --p is the ride-up progress, 0 at the top
|
||
of the page; the three lengths are measured from the DOM and are all
|
||
relative to the TOP OF THE HERO BAND. */
|
||
--p: 0;
|
||
--hero-span: 0px; /* where the content's top edge rests */
|
||
--strip: 0px; /* where that edge parks */
|
||
--row-h: 76px; /* the Play/chips row's resting height */
|
||
--mark-rest: 16px; /* the wordmark's resting bottom, above --edge */
|
||
--mark-end: 1; /* the wordmark's parked scale */
|
||
|
||
/* The band's top in .artist-page's own coordinates — .hero-bg's
|
||
historical `top`, and the origin every band-relative length below is
|
||
measured from. Keep in sync with BAND_TOP. */
|
||
--band-top: -24px;
|
||
/* Where the three pins actually sit in flow, written by heroScroll().
|
||
NOT zero: the pane's top padding puts them a little down the page,
|
||
while .results-head's -24px margin pulls the bar itself back over it.
|
||
Everything hung off a pin has to subtract this to get back into band
|
||
coordinates — leaving it out is what put the banner 24px low and
|
||
dropped the artwork onto the glass bar instead of behind it. */
|
||
--pin-origin: 24px;
|
||
/* Band coordinates, expressed from a pin's origin. */
|
||
--band-from-pin: calc(var(--band-top) - var(--pin-origin));
|
||
/* All three are written by heroScroll() as fractions of the hero, so
|
||
the banner, the wordmark and the vertical rhythm keep their
|
||
proportions from a small window to a full-screen one. The values
|
||
here are only the pre-measurement fallback. */
|
||
--banner-h: 176px;
|
||
/* ROUND 12 glue: --lift is computed to CANCEL the wordmark's parked
|
||
travel (its bottom stays put at every p; only the scale animates),
|
||
and the chip row needs no travel var at all — its box is pinned to
|
||
its resting slot. --play-shrink is the one animation the row keeps. */
|
||
--lift: 59px;
|
||
--play-shrink: 22px;
|
||
|
||
/* Where the content's top edge currently is. At rest it is the bottom
|
||
of the hero zone; parked it is the bottom of the banner. Everything
|
||
in the three pinned layers is anchored to this one number, which is
|
||
why nothing can drift out of register with anything else. It floors
|
||
itself at --strip because --p clamps at 1, so it needs no guard. */
|
||
--edge: calc(var(--hero-span) - var(--p) * (var(--hero-span) - var(--strip)));
|
||
/* ROUND 12 — the sheet's geometry, written by measure(). The
|
||
dissolve's full length (42% of the hero zone), how much of it sits
|
||
above the content edge when parked, and where its top rests at
|
||
--p: 0 (60% of the zone — the live look). */
|
||
--fog-span: 170px;
|
||
--fog-above: 50px;
|
||
--fog-rest: 280px;
|
||
/* FALLBACK ONLY (no scroll-driven animations): the content's fade
|
||
line, in .scrollbody-local coordinates — rewritten every frame by
|
||
heroScroll's apply(). The supported path uses --cut-top/--fade-s
|
||
(written by measure()) instead; see the sticky .scrollbody and
|
||
.fade-flow rules below. */
|
||
--flow-cut: -99999px;
|
||
/* MONOCHROME — the chosen treatment. Every hero lands in the same
|
||
register and the accent is the only colour left on screen. The blur
|
||
is 8px (round 9, "Softer"): by the time the banner has formed the
|
||
picture should read as texture and colour rather than as a
|
||
photograph. */
|
||
--mono: grayscale(var(--p)) contrast(calc(1 + var(--p) * 0.14))
|
||
brightness(calc(1 - var(--p) * 0.34)) blur(calc(var(--p) * 8px));
|
||
}
|
||
|
||
/* The three pinned layers. Zero flow height by construction — a sticky,
|
||
height:0 pin with an absolutely positioned child — so nothing in the
|
||
document changes height while scrolling and Chromium's scroll anchoring
|
||
has nothing to fight (the same trap documented around the album expander
|
||
further down). They stick at exactly the offset they already occupy in
|
||
flow, so sticky only holds them against the scroll: the hero never moves
|
||
or resizes by a single pixel, at any scroll position. */
|
||
.heropin,
|
||
.sheetpin,
|
||
.idpin {
|
||
position: sticky;
|
||
/* A literal 0, and it must stay one. A sticky offset is measured from
|
||
the scroll pane's PADDING box, which already starts at the pins' own
|
||
flow position, so 0 is exactly "stay where you are" — the band does
|
||
not shift by a pixel on the first pixel of scroll. Feeding
|
||
--pin-origin in here instead double-counts that padding and walks
|
||
the pins down the page (measured: 24 → 48). --pin-origin is for
|
||
converting the pins' CHILDREN into band coordinates, nothing else.
|
||
And never derive this from the pins themselves: see the
|
||
feedback-loop note in heroScroll(). */
|
||
top: 0;
|
||
height: 0;
|
||
}
|
||
|
||
/* ROUND 12 layer order, the user's own model: hero 1 → sheet 2 →
|
||
content 3 → identity 30 → glass bar 40. The hero is the BOTTOM layer,
|
||
never cut or masked; the sheet and the content slide over it. */
|
||
.heropin {
|
||
z-index: 1;
|
||
}
|
||
|
||
.sheetpin {
|
||
z-index: 2;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* THE SHEET — the page background itself, and the design's heart. Its
|
||
top --fog-span px are a baked gradient (the live scrim's own easing:
|
||
.3 / .65 / .9 at .294 / .588 / .824 of the span) dissolving into the
|
||
hero; below that it is the opaque page. It translates RIGIDLY between
|
||
two poses on --p — rest: the dissolve on the visible hero's foot,
|
||
exactly the look the old .hero-scrim gave the resting page; parked:
|
||
its edge riding --fog-above px above the content edge, the pose the
|
||
user approved. A baked gradient moved as a whole cannot seam; the
|
||
seams of rounds 7-10 all came from recomputing stops against the
|
||
scroll. This one element replaces the scrim, the veil, the eraser and
|
||
the wash. */
|
||
.sheet {
|
||
position: absolute;
|
||
top: var(--band-from-pin);
|
||
left: -28px;
|
||
right: -28px;
|
||
/* Far past any scrollport bottom; the gradient's tail is opaque page
|
||
colour, so extra height is just more page. */
|
||
height: 300vh;
|
||
pointer-events: none;
|
||
/* The glide is EASED (p²), not linear — the user could see the
|
||
gradient's edge crossing the still-bright photograph mid-scroll.
|
||
Squared, the sheet stays down at the hero's foot through the first
|
||
half of the travel and only climbs to the parked pose late, when
|
||
the monochrome ramp has already turned the hero into dim texture
|
||
where an onset cannot be seen. Still a rigid translation with the
|
||
same two exact endpoints. */
|
||
transform: translateY(calc(
|
||
var(--fog-rest)
|
||
+ var(--p) * var(--p)
|
||
* (var(--strip) - var(--fog-above) - var(--fog-rest))
|
||
));
|
||
/* SMOOTHSTEP, densely sampled — no findable line anywhere in the
|
||
curve. The previous piecewise-linear stops (.3/.65/.9 skeleton)
|
||
had large slope changes at every stop, and those kinks are Mach
|
||
bands: the user kept seeing "the gradient" on the hero however the
|
||
poses moved, because the curve itself had visible corners. These
|
||
thirteen stops sample 3t²-2t³ closely enough that adjacent
|
||
segments differ by a few percent of slope — a dissolve with no
|
||
start, no knees and no end, at any pose, over any photograph. */
|
||
background: linear-gradient(
|
||
180deg,
|
||
rgba(18, 18, 18, 0) 0px,
|
||
rgba(18, 18, 18, 0.019) calc(var(--fog-span) * 0.08),
|
||
rgba(18, 18, 18, 0.081) calc(var(--fog-span) * 0.17),
|
||
rgba(18, 18, 18, 0.156) calc(var(--fog-span) * 0.25),
|
||
rgba(18, 18, 18, 0.259) calc(var(--fog-span) * 0.33),
|
||
rgba(18, 18, 18, 0.397) calc(var(--fog-span) * 0.42),
|
||
rgba(18, 18, 18, 0.5) calc(var(--fog-span) * 0.5),
|
||
rgba(18, 18, 18, 0.603) calc(var(--fog-span) * 0.58),
|
||
rgba(18, 18, 18, 0.741) calc(var(--fog-span) * 0.67),
|
||
rgba(18, 18, 18, 0.844) calc(var(--fog-span) * 0.75),
|
||
rgba(18, 18, 18, 0.919) calc(var(--fog-span) * 0.83),
|
||
rgba(18, 18, 18, 0.981) calc(var(--fog-span) * 0.92),
|
||
var(--bg-base) var(--fog-span),
|
||
var(--bg-base) 100%
|
||
);
|
||
}
|
||
|
||
/* LAYER 3 — the content, over the sheet and the hero. Its one special
|
||
behaviour: rows fade out over 18px as they slide under the banner
|
||
line, with zero per-frame main-thread work where scroll-driven
|
||
animations exist (the whole block below); the rAF fallback follows. */
|
||
.scrollbody {
|
||
position: relative;
|
||
z-index: 3;
|
||
}
|
||
|
||
/* Positioned in BOTH branches: absolutely-positioned content descendants
|
||
used to resolve their containing block at .scrollbody; the scroll-
|
||
driven transform (a containing block by itself) would otherwise
|
||
capture them mid-branch. .fade-flow's box coincides exactly with
|
||
.scrollbody's content box, so anchoring is unchanged. */
|
||
.fade-flow {
|
||
position: relative;
|
||
}
|
||
|
||
/* THE FADE LINE, with zero per-frame main-thread work. The old design's
|
||
mask stop had to be rewritten every frame (rAF) because .scrollbody's
|
||
box scrolls, and a fast flick outran it: the compositor scrolls ahead
|
||
of the next main-thread commit and rows poked above the cut
|
||
(user-reported). A scroll-driven animation of a registered
|
||
--flow-cut lagged the SAME way — custom-property animations are
|
||
main-thread; Chromium composites only transform/opacity/filter.
|
||
|
||
Three pieces, nothing per-frame:
|
||
|
||
.fade-host — a HEIGHT-0 sticky box (the pins' own trick) placed 18px
|
||
above the content top, parking at the cut line (--cut-top, a screen
|
||
constant from measure()). Height 0 is load-bearing twice over: it is
|
||
what gives sticky its travel (a box consumes its own height from the
|
||
room its containing block offers — the pins at height 0 ride the
|
||
whole page), and by construction the travel budget works out EXACTLY:
|
||
available = contentBottom - hostTop = content height + 18 = travel
|
||
needed for the park to hold until the last content pixel passes the
|
||
cut. Past that point sticky exhausts and the line drifts up with the
|
||
flow — harmless, nothing is left below it to fade.
|
||
|
||
.fade-frame — the clip-and-mask carrier, an absolutely positioned
|
||
REAL box hung off the host (top 0 = the host's box top = the cut
|
||
when parked), one pane tall (--frame-h, from measure(); reaches
|
||
comfortably past the viewport bottom from the cut at every scroll
|
||
position). Content the transform lifts above the frame top is
|
||
removed by `overflow: visible clip` — a paint-time clip at the
|
||
padding-box edge, no scroll container created, horizontal overflow
|
||
(hover shadows, focus rings) untouched. The mask is CONSTANT: an
|
||
18px feather from the frame top over the frame's own default-sized
|
||
gradient box. The frame having a real box that CONTAINS every
|
||
visible content pixel is the whole reason it exists: this engine's
|
||
handling of mask pixels OUTSIDE an element's border box sank two
|
||
earlier builds (a mask on the height-0 host itself composited ALL
|
||
box-overflowing content — the entire page — to alpha 0; mask-clip:
|
||
no-clip re-anchored the tile to the moving overflow rect and the
|
||
fade line drifted off-screen). Inside the box, no out-of-box
|
||
semantics are ever exercised. The frame's 18px top padding keeps the
|
||
feather over empty space at rest.
|
||
|
||
.fade-flow — the counter-motion: a scroll-driven TRANSFORM animation
|
||
(composited — pixel-locked to the native scroll at any speed or
|
||
jank) translates the content by -(scrollTop - S), where S (--fade-s,
|
||
from measure()) is the scrollTop at which the host parks. Frame top
|
||
parks at the cut, content sits at frame-local 18 - (scrollTop - S) —
|
||
which equals its normal flow position at every scrollTop, stuck or
|
||
not; the handoff is seamless by construction. The keyframe delta and
|
||
the animation range are the SAME oversized constant, so translate =
|
||
-(scrollTop - S) exactly, independent of the scroller's total range
|
||
(which varies as this page scrolls — the tab-grid space reserve).
|
||
Before S the fill-mode holds translate at 0 and the page is
|
||
pixel-identical to today's.
|
||
|
||
The frame takes the content out of flow, so .scrollbody's height is
|
||
synced to .fade-flow's by heroScroll (offsetHeight ignores the
|
||
transform — no feedback loop).
|
||
|
||
The 18px zone, not longer: it lives in the 24px slot between the
|
||
pills' bottom edge and the parked Popular heading — the only place it
|
||
can satisfy "fully visible parked" AND "gone before the pills" at
|
||
once. Mask gated on data-scroll for rest parity (safe: pre-park the
|
||
content never reaches the line, so late gating cannot matter). The
|
||
STICKY, the CLIP and the ANIMATION are deliberately ungated — they
|
||
must be live the frame scrollTop crosses S even if the main thread
|
||
is stalled. One deliberate behaviour change: rows clipped above the
|
||
cut no longer hit-test (the old mask left invisible rows clickable
|
||
under the banner — the clip removing those ghost hits is a fix, not
|
||
a regression). */
|
||
@supports (animation-timeline: scroll()) {
|
||
.fade-host {
|
||
position: sticky;
|
||
/* Pre-measure fallback keeps sticky unreachable, not at 0 —
|
||
parking the content sheet over the hero on the first scrolled
|
||
frame of a fresh page would be quite a look. */
|
||
top: var(--cut-top, 99999px);
|
||
height: 0;
|
||
margin-top: -18px;
|
||
}
|
||
|
||
.fade-frame {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: var(--frame-h, 200vh);
|
||
padding-top: 18px;
|
||
overflow: visible clip;
|
||
}
|
||
|
||
.artist-page[data-scroll="1"] .fade-frame {
|
||
-webkit-mask-image: linear-gradient(
|
||
180deg,
|
||
transparent 0px,
|
||
rgba(0, 0, 0, 0.55) 9px,
|
||
#000 18px
|
||
);
|
||
mask-image: linear-gradient(
|
||
180deg,
|
||
transparent 0px,
|
||
rgba(0, 0, 0, 0.55) 9px,
|
||
#000 18px
|
||
);
|
||
}
|
||
|
||
.fade-flow {
|
||
animation-name: fade-flow-ride;
|
||
animation-duration: auto;
|
||
animation-timing-function: linear;
|
||
animation-fill-mode: both;
|
||
animation-timeline: scroll(nearest block);
|
||
animation-range: var(--fade-s, 0px)
|
||
calc(var(--fade-s, 0px) + 100000px);
|
||
}
|
||
|
||
@keyframes fade-flow-ride {
|
||
from {
|
||
transform: translateY(0);
|
||
}
|
||
to {
|
||
/* Delta must equal the animation-range span above. */
|
||
transform: translateY(-100000px);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Fallback (no scroll-driven animations): the old design — mask on
|
||
.scrollbody, its stop (--flow-cut) rewritten every frame by
|
||
heroScroll's apply(). Can lag a fast flick; correct when settled.
|
||
.fade-host/.fade-frame/.fade-flow stay plain unstyled divs here —
|
||
any one piece of the trio without the others would freeze, clip or
|
||
double-move the content. */
|
||
@supports not (animation-timeline: scroll()) {
|
||
.artist-page[data-scroll="1"] .scrollbody {
|
||
-webkit-mask-image: linear-gradient(
|
||
180deg,
|
||
transparent 0px,
|
||
transparent var(--flow-cut),
|
||
rgba(0, 0, 0, 0.55) calc(var(--flow-cut) + 9px),
|
||
#000 calc(var(--flow-cut) + 18px)
|
||
);
|
||
mask-image: linear-gradient(
|
||
180deg,
|
||
transparent 0px,
|
||
transparent var(--flow-cut),
|
||
rgba(0, 0, 0, 0.55) calc(var(--flow-cut) + 9px),
|
||
#000 calc(var(--flow-cut) + 18px)
|
||
);
|
||
}
|
||
}
|
||
|
||
/* Nothing but the controls themselves may take clicks: the block spans the
|
||
pane and floats over the track list once it has parked. */
|
||
.idpin {
|
||
z-index: 30;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* Height 0 with its children hung off its bottom edge, so the block's
|
||
bottom IS --edge at every scroll position. */
|
||
.idblock {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
top: calc(var(--band-from-pin) + var(--edge));
|
||
height: 0;
|
||
}
|
||
|
||
/* Banner-as-background: bled to the pane edges (cancels its 24/28px
|
||
padding), sized by the artwork's own height, extending down behind the
|
||
content. The pane's border-radius + overflow round the outer corners.
|
||
The band still lands on precisely the pixels it always did, but it is
|
||
now hung off .heropin rather than off .artist-page, and the pin does not
|
||
sit at the page's own origin — so the offset has to come back through
|
||
--band-from-pin. Hard-coding the old -24px here put the artwork 24px low
|
||
and dropped it onto the glass bar instead of bleeding up behind it.
|
||
(z-index -1 is inert: .heropin's own z-index is the stacking context
|
||
this lives in.) NEVER masked or cut — round 12's whole point. */
|
||
.hero-bg {
|
||
position: absolute;
|
||
top: var(--band-from-pin);
|
||
left: -28px;
|
||
right: -28px;
|
||
z-index: -1;
|
||
overflow: hidden;
|
||
isolation: isolate;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* The monochrome treatment, applied to the PICTURE layers only. The
|
||
existing filters are re-stated because filter replaces rather than
|
||
composes. */
|
||
.artist-page[data-scroll="1"] .banner,
|
||
.artist-page[data-scroll="1"] :global(.gpu-hero) {
|
||
filter: var(--mono);
|
||
/* THE SQUEEZE, biased to keep FACES. At rest --edge IS --hero-span,
|
||
so this is exactly 0 and the artwork sits on the pixels it does
|
||
today — top-anchored, nothing tucked up behind the glass bar. As
|
||
the crop closes, only 22% of each lost pixel comes off the TOP and
|
||
78% off the bottom: round 9's centre-crop (50/50) pushed the
|
||
artwork up by half the travel and cut heads off at the park (the
|
||
user: "most faces are cut off") — faces live in a photo's upper
|
||
middle, so the banner keeps it. Still a rigid translation of an
|
||
unmasked layer: zero at rest, seam-proof throughout. */
|
||
transform: translateY(calc(-0.22 * (var(--hero-span) - var(--edge))));
|
||
}
|
||
|
||
/* The accent ground desaturates with everything else. It was the one layer
|
||
left out of the monochrome, and its brightest, reddest point sits
|
||
directly behind the glass bar; the bloom over it is only 95% opaque, so
|
||
that red bled through and the bar's own backdrop saturate(1.7) amplified
|
||
it into a visibly warm bar on a black-and-white banner. */
|
||
.artist-page[data-scroll="1"] .hero-fallback {
|
||
filter: grayscale(var(--p)) brightness(calc(1 - var(--p) * 0.34));
|
||
}
|
||
|
||
.artist-page[data-scroll="1"] .bloom {
|
||
filter: blur(34px) saturate(1.6) var(--mono);
|
||
}
|
||
|
||
.artist-page[data-scroll="1"] .hero-seed {
|
||
filter: blur(46px) saturate(1.3) var(--mono);
|
||
}
|
||
|
||
/* Deterministic muted-accent ground: sits under every hero as the base, and
|
||
IS the no-photo fallback (same reserved geometry, hue seeded from the
|
||
artist). No edge vignette — just a soft top-lit gradient into the page. */
|
||
.hero-fallback {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: -2;
|
||
background: radial-gradient(
|
||
120% 88% at 28% 4%,
|
||
hsl(var(--seed-hue, 210) 30% 24%) 0%,
|
||
hsl(var(--seed-hue, 210) 26% 13%) 46%,
|
||
var(--bg-base) 100%
|
||
);
|
||
}
|
||
|
||
/* Blurred search-portrait seed: an instant colour field while the real hero
|
||
loads. Oversized + blurred so a blurred edge never shows, and the square
|
||
source can't change hero height (the 16:9 base / native banner sets it).
|
||
It dissolves out UNDER the sharp photo once it decodes, never blanking. */
|
||
.hero-seed {
|
||
position: absolute;
|
||
inset: -12% -8%;
|
||
z-index: -1;
|
||
background-position: center 32%;
|
||
background-repeat: no-repeat;
|
||
background-size: cover;
|
||
filter: blur(46px) saturate(1.3);
|
||
transform: scale(1.06);
|
||
opacity: 1;
|
||
visibility: visible;
|
||
transition: opacity 520ms ease, visibility 0s linear;
|
||
}
|
||
|
||
.hero-seed.hidden {
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
transition: opacity 520ms ease, visibility 0s linear 520ms;
|
||
}
|
||
|
||
/* Transparent 16:9 spacer that gives .hero-bg its height before a sharp
|
||
banner exists, so fallback/seed occupy the geometry the real hero will. */
|
||
.banner.base {
|
||
margin-top: 70px;
|
||
width: 100%;
|
||
aspect-ratio: 16 / 9;
|
||
background: transparent;
|
||
}
|
||
|
||
/* Bloom: a blurred copy that only shows in the ~46px strip behind the glass
|
||
bar, artificially extending the image up into it. It sits BEHIND the
|
||
sharp photo (lower z-index), so it never touches the artwork itself. */
|
||
.bloom {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 106px;
|
||
object-fit: cover;
|
||
object-position: center top;
|
||
filter: blur(34px) saturate(1.6);
|
||
transform: scale(1.15);
|
||
opacity: 0.95;
|
||
z-index: 0;
|
||
}
|
||
|
||
.bloom.current {
|
||
animation: hero-bloom-in 1.4s ease backwards;
|
||
}
|
||
|
||
/* ROUND 12: no .hero-scrim and no .hero-veil. Both jobs — the resting
|
||
dissolve and the parked wordmark's backing — now belong to the SHEET,
|
||
whose gradient sits on the hero's foot at rest and rides its darkest
|
||
region up under the parked identity. One gradient, nothing to hand
|
||
over between layers. */
|
||
|
||
/* Transparent spacer that seats the artist name at the bottom of the
|
||
visible hero and sets where the content below begins. Its height tracks
|
||
the window WIDTH (via aspect-ratio) the same way the banner does, so the
|
||
content shifts DOWN as the pane widens — staying off the artwork on a
|
||
tall 16:9 banner, yet compact when the pane is squarer. Capped a notch
|
||
below the banner's own 78vh so the content always meets the dissolved
|
||
tail, never the crisp subject.
|
||
|
||
It is a pure spacer now — the wordmark it used to hold rides in .idpin —
|
||
but its height is unchanged: aspect-ratio with a definite width and an
|
||
explicit min-height never depended on its children. It is the real
|
||
equivalent of the mock's fixed hero band: its bottom is --hero-span. */
|
||
.hero-zone {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
/* width must be DEFINITE: with an auto width, `aspect-ratio` transfers
|
||
`min-height` through the ratio into an implied min-width (390 * 1.85 =
|
||
722px), which overflows a narrower pane and drags the hero copy --
|
||
and anything sized as a % of it -- off the right edge. */
|
||
width: 100%;
|
||
/* A taller zone seats the bottom-anchored copy LOWER over the banner, so
|
||
the wordmark sits toward the dissolved lower third of the hero instead
|
||
of across the crisp subject. Lower aspect number = taller zone; the
|
||
vh cap stays a notch below the banner's 78vh so the copy still meets
|
||
the dissolved tail. Tune all three together to raise/lower the logo. */
|
||
aspect-ratio: 1.85;
|
||
min-height: 390px;
|
||
max-height: 76vh;
|
||
}
|
||
|
||
/* Frosted-glass header floating on the banner — the same recipe as the
|
||
Now Playing panel's .np-head, so the two headers read as siblings: a
|
||
translucent tint over a backdrop blur, with a hairline light edge. */
|
||
.results-head {
|
||
/* Sticky (was absolute) so the bar pins to the top of the scroll pane
|
||
and stays put as the page scrolls, in parity with the Now Playing
|
||
header. The negative margins reproduce the old absolute bleed while
|
||
keeping the bar in normal flow: -24/-28 bleed it up over the pane's
|
||
24px top padding and out to the L/R pane edges; margin-bottom -22
|
||
makes its net flow height zero (-24 + 46 - 22 = 0) so the hero still
|
||
sits directly beneath it instead of being pushed down. `top: -24px`
|
||
pins it flush against the very top of the pane (over that padding). */
|
||
position: sticky;
|
||
top: -24px;
|
||
margin: -24px -28px -22px;
|
||
/* Above the identity (30) and the hero (20): the bar keeps the top of
|
||
the pane to itself and the parked wordmark slides under it. */
|
||
z-index: 40;
|
||
height: 46px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 0 28px;
|
||
background: rgba(20, 20, 24, 0.28);
|
||
backdrop-filter: blur(20px) saturate(1.7);
|
||
-webkit-backdrop-filter: blur(20px) saturate(1.7);
|
||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
/* Matches .np-title: white label legible on the glass, with a soft shadow
|
||
for bright banners. */
|
||
.results-head .back {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
color: #fff;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
.results-head .back:hover {
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* Resolver status rides the right side of the glass bar (was a separate
|
||
line above the hero before the header became the glass strip). */
|
||
.rstatus {
|
||
margin-left: auto;
|
||
max-width: 55%;
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.85);
|
||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.rstatus .pulse {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.rstatus.error {
|
||
color: #ff8a94;
|
||
}
|
||
|
||
.hero-art-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.rstatus + .hero-art-actions {
|
||
margin-left: 0;
|
||
}
|
||
|
||
.hero-art-notice {
|
||
max-width: 260px;
|
||
color: rgba(255, 255, 255, 0.78);
|
||
font-size: 10px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.hero-art-notice.error {
|
||
color: #ff8a94;
|
||
}
|
||
|
||
.hero-art-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
height: 28px;
|
||
padding: 0 9px;
|
||
border: 1px solid rgba(255, 255, 255, 0.11);
|
||
border-radius: 7px;
|
||
background: rgba(12, 12, 14, 0.35);
|
||
color: rgba(255, 255, 255, 0.76);
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.hero-art-btn:hover,
|
||
.hero-art-btn.active {
|
||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||
color: #fff;
|
||
}
|
||
|
||
.hero-art-btn:disabled {
|
||
opacity: 0.45;
|
||
cursor: wait;
|
||
}
|
||
|
||
.hero-art-btn.icon-only {
|
||
width: 28px;
|
||
padding: 0;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* The sharp artwork: native aspect ratio (no crop), offset down so it
|
||
begins right at the bottom of the glass bar. It sits ABOVE the bloom.
|
||
max-height caps the very-wide / non-16:9 case so the hero can't swallow
|
||
the viewport — object-fit then crops from the top, keeping faces.
|
||
The 70px offset = the 46px bar height + the 24px top padding of the
|
||
scroll pane (.content). The bar is `position: sticky`, so it pins at the
|
||
content-area top and cannot bleed up over that padding the way an
|
||
absolute bar could — it therefore sits 24px lower, and the artwork must
|
||
clear it by the same 24px or the bar overlaps the subject (heads) at
|
||
narrower widths. Keep this in sync with .content's padding-top. */
|
||
.banner {
|
||
position: relative;
|
||
display: block;
|
||
width: 100%;
|
||
height: auto;
|
||
max-height: 78vh;
|
||
margin-top: 70px;
|
||
object-fit: cover;
|
||
object-position: center top;
|
||
z-index: 1;
|
||
}
|
||
|
||
/* The outgoing image is taken out of layout and held under the incoming
|
||
one. This keeps the hero's height stable while both full-resolution
|
||
images overlap for the dissolve. */
|
||
.banner.previous {
|
||
position: absolute;
|
||
top: 70px;
|
||
left: 0;
|
||
margin-top: 0;
|
||
}
|
||
|
||
/* Hold the last decoded image beneath the transparent GPU canvas until its
|
||
first fixed-4K frame is ready. It is out of flow, so hero geometry still
|
||
comes from the canvas rather than doubling in height. */
|
||
.banner.gpu-underlay {
|
||
position: absolute;
|
||
top: 70px;
|
||
left: 0;
|
||
margin-top: 0;
|
||
}
|
||
|
||
.banner.current {
|
||
animation: hero-banner-in 1.4s ease backwards;
|
||
}
|
||
|
||
/* The first useful frame should feel immediate; the long dissolve is for
|
||
the periodic photo rotation where an outgoing image exists underneath. */
|
||
.bloom.current.initial,
|
||
.banner.current.initial {
|
||
animation-duration: 180ms;
|
||
}
|
||
|
||
/* The preview→finished 4K swap dissolves faster than a rotation but slower
|
||
than the first paint — enough to mask the grade/grain without dawdling. */
|
||
.bloom.current.finish,
|
||
.banner.current.finish {
|
||
animation-duration: 650ms;
|
||
}
|
||
|
||
@keyframes hero-banner-in {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
@keyframes hero-bloom-in {
|
||
from { opacity: 0; }
|
||
to { opacity: 0.95; }
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.banner.current,
|
||
.bloom.current,
|
||
.banner.current.initial,
|
||
.bloom.current.initial,
|
||
.banner.current.finish,
|
||
.bloom.current.finish {
|
||
animation-duration: 1ms;
|
||
}
|
||
|
||
.hero-seed,
|
||
.name-title,
|
||
.artist-logo.mask {
|
||
transition-duration: 1ms;
|
||
}
|
||
|
||
.logo-wrap {
|
||
animation-duration: 1ms;
|
||
}
|
||
}
|
||
|
||
|
||
/* Full width so its children (esp. the logo) size against the hero, not the
|
||
shrink-to-fit width of the meta text.
|
||
|
||
Hung off .idblock's bottom edge, i.e. off --edge. Its own padding puts
|
||
the wordmark --mark-rest above its bottom edge, so this offset is what
|
||
the wordmark's resting gap has to GROW by: 0 at rest, where the block's
|
||
bottom is the hero zone's bottom and the wordmark therefore lands on
|
||
exactly the pixels it lands on today, and --banner-h - 14 - --lift minus
|
||
that resting gap when parked, which seats the wordmark 10px under the
|
||
top of the banner with the chip row clear beneath it. */
|
||
.hero-copy {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: calc(
|
||
var(--p) * (var(--banner-h) - 14px - var(--lift) - var(--mark-rest))
|
||
);
|
||
width: 100%;
|
||
padding: 0 0 10px;
|
||
color: #fff;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.placeholder {
|
||
display: grid;
|
||
place-items: center;
|
||
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
h1 {
|
||
font-size: clamp(44px, 6vw, 84px);
|
||
font-weight: 800;
|
||
letter-spacing: -0.045em;
|
||
line-height: 1.02;
|
||
margin: 6px 0 14px;
|
||
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.35);
|
||
}
|
||
|
||
/* The logo's drop shadow lives on this WRAPPER, not on the logo itself.
|
||
CSS applies `mask` after `filter`, so a drop-shadow set on the masked
|
||
element would be generated from its un-masked rectangle and then clipped
|
||
away by the mask — invisible. Filtering the parent shadows the already-
|
||
masked result, i.e. the letterforms. (Our source logos bake a shadow into
|
||
the PNG; the trace strips it, so this is the one we control — it scales
|
||
with the logo and can't blur, unlike a 800px raster shadow.) */
|
||
/* Reserved-height slot so text→logo never reflows the meta/content below.
|
||
Both the name and the logo are absolutely anchored to the slot's
|
||
bottom-left, sharing one origin, and crossfade. */
|
||
.logo-slot {
|
||
position: relative;
|
||
min-height: var(--slot-h, 200px);
|
||
margin: 6px 0 6px;
|
||
}
|
||
|
||
/* The wordmark scales down as it parks. transform, not a font/height
|
||
change: the slot's reserved height must not move while it shrinks, and
|
||
the origin is the slot's bottom-left — the one point the name and the
|
||
logo already share — so the mark shrinks onto its own anchor. --mark-end
|
||
is written from the mark's rendered height so the PARKED size is a
|
||
constant ~26px at every pane width: a narrow window gets a smaller hero
|
||
wordmark and the same readable banner. Gated, so at rest there is no
|
||
transform on the type at all. */
|
||
.artist-page[data-scroll="1"] .logo-slot {
|
||
transform-origin: left bottom;
|
||
transform: scale(calc(1 - var(--p) * (1 - var(--mark-end))));
|
||
}
|
||
|
||
.name-title {
|
||
position: absolute;
|
||
left: 0;
|
||
bottom: 0;
|
||
margin: 0;
|
||
transition: opacity 300ms ease;
|
||
}
|
||
|
||
.loading-name {
|
||
bottom: 8px;
|
||
width: min(430px, 64%);
|
||
height: clamp(48px, 6vw, 76px);
|
||
border-radius: 8px;
|
||
background: linear-gradient(
|
||
100deg,
|
||
rgba(255, 255, 255, 0.08) 20%,
|
||
rgba(255, 255, 255, 0.17) 42%,
|
||
rgba(255, 255, 255, 0.08) 64%
|
||
);
|
||
background-size: 220% 100%;
|
||
animation: artist-skeleton-shimmer 1.35s ease-in-out infinite;
|
||
}
|
||
|
||
/* Fades out under the logo once one decodes, instead of hard-swapping. */
|
||
.name-title.gone {
|
||
opacity: 0;
|
||
}
|
||
|
||
.logo-wrap {
|
||
position: absolute;
|
||
left: 0;
|
||
/* right:0 gives a DEFINITE width so the mask's `92%` cap resolves
|
||
against the slot, not a shrink-to-fit 0 (which collapses the logo). */
|
||
right: 0;
|
||
bottom: 0;
|
||
/* Two shadows, as with any lifted UI surface: a tight contact shadow
|
||
that separates the glyphs from a busy banner, plus a wide ambient one
|
||
for depth. Tune here — this is the whole knob. */
|
||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.55))
|
||
drop-shadow(0 10px 24px rgba(0, 0, 0, 0.6));
|
||
/* Fade the final curated wordmark in over the name (which fades out).
|
||
Candidate scoring never mounts an intermediate logo. */
|
||
animation: logo-fade-in 380ms ease both;
|
||
}
|
||
|
||
@keyframes logo-fade-in {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
/* Band ClearLOGO as the title. Full-colour path (.artist-logo, an <img>):
|
||
a transparent HD PNG bounded to hero size — scaling DOWN stays crisp, no
|
||
vectorising needed. Bounded by BOTH max dimensions so aspect is preserved
|
||
and it reads big. */
|
||
.artist-logo {
|
||
display: block;
|
||
width: auto;
|
||
height: auto;
|
||
max-width: 90%;
|
||
max-height: var(--logo-h, 200px);
|
||
}
|
||
|
||
/* Accent-tinted path (.mask, a <div>): the TRACED silhouette masks a solid
|
||
accent fill, so the wordmark renders in the app accent. The SVG's viewBox
|
||
hugs the ink, so `contain` fills the box exactly — no sprite-cropping and
|
||
no dead space.
|
||
|
||
Sizing: --logo-h comes from the equal-area rule, then scales with the
|
||
measured hero width. It is written as a WIDTH here so aspect-ratio drives
|
||
the height and nothing distorts. Being vector there is no resolution
|
||
ceiling; 92% remains the final safety bound for extreme wordmarks. */
|
||
.artist-logo.mask {
|
||
width: min(calc(var(--logo-aspect, 3) * var(--logo-h, 200px)), 92%);
|
||
aspect-ratio: var(--logo-aspect, 3);
|
||
max-height: none;
|
||
background: var(--accent);
|
||
-webkit-mask: var(--logo) center / contain no-repeat;
|
||
mask: var(--logo) center / contain no-repeat;
|
||
}
|
||
|
||
/* Play + chips ride in the identity layer, hung off .idblock's bottom
|
||
edge like the wordmark is — and, round 12 glue, they NEVER leave their
|
||
resting slot: the box's bottom sits a full --row-h below --edge at
|
||
every p, exactly the hole .actions-gap holds open in the flow. The box
|
||
does not collapse either — an earlier cut shrank min-height/padding
|
||
with --p and the Play's bottom wandered ~10px against the content
|
||
mid-ride, breaking the glue. Only the BUTTON animates (its size, via
|
||
--play-shrink), shrinking about its own centre inside a static box:
|
||
"attach it to the chips that are resizing and transforming", verbatim.
|
||
|
||
overflow-x is CLIPPED, not hidden: the row is nowrap (see .stat-chips)
|
||
so a very long chip set can overrun the pane, and clip stops that from
|
||
reaching the scroll pane as horizontal overflow while leaving the
|
||
chips' hover popovers free to escape vertically. */
|
||
.artist-actions {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: calc(-1 * var(--row-h));
|
||
min-height: var(--row-h);
|
||
padding: 8px 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
overflow-x: clip;
|
||
overflow-y: visible;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.hero-play,
|
||
.hero-preview,
|
||
.stat-chips {
|
||
pointer-events: auto;
|
||
}
|
||
|
||
/* The hole the row left behind in .popular. It carries the row's resting
|
||
height rules, and heroScroll() reads --row-h back off it, so the spacer
|
||
and the pinned row can never disagree about how tall the row is. */
|
||
.actions-gap {
|
||
min-height: 76px;
|
||
}
|
||
|
||
/* Floating stat chips beside Play — individually rounded translucent
|
||
pills (the .hero-art-btn glass recipe), deliberately NOT a joined bar.
|
||
They sit over the hero's dissolved tail, so the backdrop blur keeps
|
||
them legible on any artwork. */
|
||
.stat-chips {
|
||
display: flex;
|
||
align-items: center;
|
||
/* nowrap by design: the row has to stay ONE line to fit a 124px
|
||
banner, and a second row would grow up into the wordmark once the
|
||
block is anchored to its bottom edge. Something therefore has to
|
||
give at narrow widths, and it should be the least important stat —
|
||
see the shedding rules below. */
|
||
flex-wrap: nowrap;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* Narrow panes SHED chips rather than letting the row overflow. */
|
||
.idpin[data-w="s"] .stat-chips > :nth-child(n + 3),
|
||
.idpin[data-w="xs"] .stat-chips > :nth-child(n + 2) {
|
||
display: none;
|
||
}
|
||
|
||
/* Only the first three stats survive into the banner; the rest fade out on
|
||
the way up. They keep their layout width — the row is left-aligned, so
|
||
nothing shifts — but they must stop taking clicks once invisible, which
|
||
opacity alone does not do. */
|
||
.stat-chips > :nth-child(n + 4) {
|
||
opacity: calc(1 - var(--p) * 2.4);
|
||
pointer-events: var(--extra-hit, auto);
|
||
}
|
||
|
||
.chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
height: 27px;
|
||
padding: 0 11px;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
border-radius: 14px;
|
||
background: rgba(14, 15, 18, 0.4);
|
||
color: rgba(255, 255, 255, 0.8);
|
||
font-size: 11.5px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.chip :global(.icon) {
|
||
color: rgba(255, 255, 255, 0.45);
|
||
}
|
||
|
||
/* Mini collection meter inside the library chip — the Steam-achievements
|
||
analog: how much of the discography is saved locally. */
|
||
.chip-bar {
|
||
width: 44px;
|
||
height: 4px;
|
||
border-radius: 2px;
|
||
background: rgba(255, 255, 255, 0.16);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.chip-fill {
|
||
display: block;
|
||
height: 100%;
|
||
border-radius: 2px;
|
||
background: var(--accent);
|
||
}
|
||
|
||
/* Hover popover on the disk chip: per-album storage breakdown. Pure CSS
|
||
(opens on hover/focus), anchored above the chip so it never collides
|
||
with the Popular list below. */
|
||
.chip.has-pop {
|
||
position: relative;
|
||
}
|
||
|
||
.chip-pop {
|
||
position: absolute;
|
||
left: 0;
|
||
bottom: calc(100% + 8px);
|
||
z-index: 30;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
min-width: 240px;
|
||
max-width: 340px;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
padding: 9px 11px;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
border-radius: 10px;
|
||
background: rgba(14, 15, 18, 0.92);
|
||
backdrop-filter: blur(14px);
|
||
-webkit-backdrop-filter: blur(14px);
|
||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
transition: opacity 140ms ease, visibility 140ms;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.chip.has-pop:hover .chip-pop,
|
||
.chip.has-pop:focus-within .chip-pop {
|
||
opacity: 1;
|
||
visibility: visible;
|
||
}
|
||
|
||
.pop-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 14px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.pop-title {
|
||
color: rgba(255, 255, 255, 0.82);
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.pop-val {
|
||
color: rgba(255, 255, 255, 0.5);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Follow heart: a square chip-styled button closing the row. Fills with
|
||
the accent when active. Visual-only until the follow feature lands. */
|
||
.chip.follow {
|
||
width: 27px;
|
||
padding: 0;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.chip.follow:hover {
|
||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||
color: #fff;
|
||
}
|
||
|
||
.chip.follow.on {
|
||
color: var(--accent);
|
||
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
||
}
|
||
|
||
/* Socials: ghost icons after the heart — no chip outline, just the pale
|
||
brand-tinted glyph. Hover blooms a soft disc in the same brand colour
|
||
(--soc is an "r,g,b" triple so the wash can be alpha'd). */
|
||
.chip-divider {
|
||
width: 1px;
|
||
height: 18px;
|
||
background: rgba(255, 255, 255, 0.14);
|
||
margin: 0 4px;
|
||
}
|
||
|
||
.social-ghost {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 29px;
|
||
height: 29px;
|
||
border-radius: 50%;
|
||
color: rgb(var(--soc, 160, 168, 177));
|
||
transition: background 160ms ease, transform 160ms ease;
|
||
}
|
||
|
||
.social-ghost:hover {
|
||
background: rgba(var(--soc, 160, 168, 177), 0.13);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.social-ghost {
|
||
transition-duration: 1ms;
|
||
}
|
||
}
|
||
|
||
/* Shrunk by real width/height, not transform: with a transform the row
|
||
would keep its 50px layout box, the chips would stay centred in that
|
||
band and the parked wordmark would collide with them. */
|
||
.hero-play {
|
||
display: grid;
|
||
place-items: center;
|
||
width: calc(50px - var(--p) * var(--play-shrink));
|
||
height: calc(50px - var(--p) * var(--play-shrink));
|
||
/* A flex item may be squeezed narrower than its width by the wrapping
|
||
chip row, turning the circle into an oval. Never shrink. */
|
||
flex: 0 0 auto;
|
||
padding-left: 3px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
color: #000;
|
||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.25);
|
||
}
|
||
|
||
/* The glyph shrinks with its button. At --p: 0 this resolves to the 21px
|
||
the <Icon> already renders at, so nothing changes at rest. */
|
||
.hero-play :global(.icon) {
|
||
width: calc(21px - var(--p) * 6px);
|
||
height: calc(21px - var(--p) * 6px);
|
||
}
|
||
|
||
.hero-play:hover {
|
||
background: var(--accent-hover);
|
||
transform: scale(1.04);
|
||
}
|
||
|
||
/* Preview tracks. Glass, not accent: Play stays the row's one filled
|
||
control. It shrinks on the same --p curve as .hero-play, but the travel
|
||
is chosen by where it LANDS, not by proportion: 40px at rest down to
|
||
27px parked, which is the chips' fixed height (they never scale — only
|
||
Play and this button animate). Matching the chips is the whole point of
|
||
the number; scaling it by 40/50 of --play-shrink instead parked it at
|
||
22px, a visibly undersized dot against a 27px chip row. Parked Play is
|
||
28px, so the order across the row reads 28 / 27 / 27. */
|
||
.hero-preview {
|
||
position: relative;
|
||
display: grid;
|
||
place-items: center;
|
||
width: calc(40px - var(--p) * 13px);
|
||
height: calc(40px - var(--p) * 13px);
|
||
flex: 0 0 auto;
|
||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||
border-radius: 50%;
|
||
background: rgba(14, 15, 18, 0.4);
|
||
color: rgba(255, 255, 255, 0.72);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
}
|
||
|
||
/* The glyph lands at 14px parked — the size the chips' own icons run at,
|
||
so the button reads as one of them rather than a shrunken transport. */
|
||
.hero-preview :global(.icon) {
|
||
width: calc(18px - var(--p) * 4px);
|
||
height: calc(18px - var(--p) * 4px);
|
||
}
|
||
|
||
.hero-preview:hover:not(:disabled) {
|
||
color: #fff;
|
||
border-color: rgba(255, 255, 255, 0.75);
|
||
transform: scale(1.04);
|
||
}
|
||
|
||
.hero-preview:disabled {
|
||
opacity: 0.4;
|
||
cursor: default;
|
||
}
|
||
|
||
/* Availability count, shown only when some tracks have no clip. It fades
|
||
on the same curve the shed chips use (see .stat-chips > :nth-child(n+4)),
|
||
so the row loses its secondary detail all at once on the way up rather
|
||
than the badge keeping its own schedule. The tooltip still carries the
|
||
count once it is gone. Offsets stay fixed: the badge is small enough at
|
||
a 27px parked circle, and animating them slid it off the edge. */
|
||
.preview-count {
|
||
position: absolute;
|
||
top: -3px;
|
||
right: -5px;
|
||
min-width: 16px;
|
||
height: 16px;
|
||
padding: 0 4px;
|
||
/* Dark ring, not --bg-base: the badge sits over hero artwork, where a
|
||
flat page-colour halo would read as a hole punched in the photo. */
|
||
border: 2px solid rgba(10, 11, 14, 0.85);
|
||
border-radius: 8px;
|
||
background: var(--accent);
|
||
color: #000;
|
||
font-size: 9.5px;
|
||
font-weight: 800;
|
||
line-height: 12px;
|
||
text-align: center;
|
||
opacity: calc(1 - var(--p) * 2.4);
|
||
}
|
||
|
||
.popular h2 {
|
||
margin-top: 0;
|
||
}
|
||
|
||
.popular-list {
|
||
max-width: 760px;
|
||
}
|
||
|
||
.popular-more {
|
||
margin: 6px 0 0 8px;
|
||
padding: 4px 0;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.02em;
|
||
text-transform: uppercase;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.popular-more:hover {
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.popular-row {
|
||
display: grid;
|
||
grid-template-columns: 26px 40px minmax(130px, 1fr) minmax(100px, 0.75fr) 40px 24px;
|
||
align-items: center;
|
||
gap: 11px;
|
||
min-height: 54px;
|
||
padding: 6px 8px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.popular-row:hover {
|
||
background: var(--bg-highlight);
|
||
}
|
||
|
||
.popular-row.now .rank,
|
||
.popular-row.now .track-name {
|
||
color: var(--accent-hover);
|
||
}
|
||
|
||
.rank {
|
||
color: var(--text-muted);
|
||
font-size: 12px;
|
||
text-align: center;
|
||
}
|
||
|
||
.track-cover {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 3px;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.track-name,
|
||
.track-album {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.track-name {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.track-album {
|
||
color: var(--text-secondary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
h2 {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
margin: 22px 0 10px;
|
||
}
|
||
|
||
.disco-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin: 26px 0 12px;
|
||
}
|
||
|
||
.disco-title {
|
||
margin: 0;
|
||
font-size: 20px;
|
||
font-weight: 800;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
/* Discography filter tabs. The selected tab uses the app's "accent glass"
|
||
recipe (same as .ed-badge / .now-badge), so it tints to the artist's
|
||
scoped accent and reads as one system with the cards below. */
|
||
.disco-tabs {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.disco-tab {
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
white-space: nowrap;
|
||
padding: 8px 15px;
|
||
border-radius: 8px;
|
||
border: 1px solid transparent;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
transition: background 0.15s ease, color 0.15s ease;
|
||
}
|
||
|
||
.disco-tab:hover {
|
||
background: rgba(255, 255, 255, 0.09);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.disco-tab:focus-visible {
|
||
outline: 2px solid var(--accent);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
.disco-tab.on {
|
||
color: #fff;
|
||
background: color-mix(in srgb, var(--accent) 34%, rgba(10, 10, 12, 0.5));
|
||
border-color: color-mix(in srgb, var(--accent) 60%, transparent);
|
||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent);
|
||
}
|
||
|
||
.grid {
|
||
display: grid;
|
||
/* Fixed tracks — fluid minmax(...,1fr) sawtooths card widths while
|
||
the window or side panels resize (see Home.svelte). 150px = the old
|
||
132px cover plus the card's new 9px padding on each side, so covers
|
||
render at the same size they always did. */
|
||
grid-template-columns: repeat(auto-fill, 150px);
|
||
gap: 16px;
|
||
/* Cards hug their content — the ROW is as tall as its tallest title,
|
||
but a short-titled neighbor must not stretch (its hover/active
|
||
surface would wrap a box of empty space). */
|
||
align-items: start;
|
||
/* Load-bearing with the min-height reserve set in `measureCols`:
|
||
grid's default align-content behaves as `stretch`, which would
|
||
distribute the reserved space BETWEEN the auto-sized rows and
|
||
spread the cards apart. Pin the rows to the top so the reserve
|
||
stays as empty space below them. */
|
||
align-content: start;
|
||
/* Swapping tabs destroys/recreates this grid; scroll anchoring would
|
||
"helpfully" adjust scrollTop to chase the vanishing content and
|
||
fight the pin in `selectTab`. */
|
||
overflow-anchor: none;
|
||
content-visibility: auto;
|
||
contain-intrinsic-size: auto 360px;
|
||
}
|
||
|
||
.stage-status {
|
||
min-height: 20px;
|
||
}
|
||
|
||
.catalog-skeleton {
|
||
padding-top: 2px;
|
||
opacity: 0.72;
|
||
}
|
||
|
||
.skeleton-heading,
|
||
.skeleton-track > span,
|
||
.skeleton-card {
|
||
background: linear-gradient(
|
||
100deg,
|
||
rgba(255, 255, 255, 0.045) 20%,
|
||
rgba(255, 255, 255, 0.1) 42%,
|
||
rgba(255, 255, 255, 0.045) 64%
|
||
);
|
||
background-size: 220% 100%;
|
||
animation: artist-skeleton-shimmer 1.35s ease-in-out infinite;
|
||
}
|
||
|
||
.skeleton-heading {
|
||
width: 92px;
|
||
height: 15px;
|
||
margin: 22px 0 10px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.skeleton-heading.releases {
|
||
width: 68px;
|
||
}
|
||
|
||
.skeleton-tracks {
|
||
display: grid;
|
||
}
|
||
|
||
.skeleton-track {
|
||
display: grid;
|
||
grid-template-columns: 24px 40px minmax(120px, 360px);
|
||
align-items: center;
|
||
gap: 12px;
|
||
min-height: 52px;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.035);
|
||
}
|
||
|
||
.skeleton-rank {
|
||
width: 12px;
|
||
height: 10px;
|
||
border-radius: 3px;
|
||
justify-self: center;
|
||
}
|
||
|
||
.skeleton-cover {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.skeleton-copy {
|
||
width: min(300px, 75%);
|
||
height: 12px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.skeleton-cards {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, 132px);
|
||
gap: 16px;
|
||
}
|
||
|
||
.skeleton-card {
|
||
display: block;
|
||
width: 132px;
|
||
aspect-ratio: 1;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
@keyframes artist-skeleton-shimmer {
|
||
from { background-position: 100% 0; }
|
||
to { background-position: -120% 0; }
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.loading-name,
|
||
.skeleton-heading,
|
||
.skeleton-track > span,
|
||
.skeleton-card {
|
||
animation: none;
|
||
}
|
||
}
|
||
|
||
.cover-wrap {
|
||
position: relative;
|
||
}
|
||
|
||
.cover {
|
||
display: block;
|
||
width: 100%;
|
||
aspect-ratio: 1;
|
||
object-fit: cover;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
/* ---- blur-up first load ----
|
||
A ~3KB Deezer thumb paints almost immediately as a blurred bed, then
|
||
the full-resolution art crossfades over it and settles from 1.04 → 1.
|
||
The real art is held at opacity 0 until its `load` fires (see
|
||
`smoothSrc`), so the browser's progressive top-down raster fill happens
|
||
invisibly instead of reading as a half-drawn cover. */
|
||
.cover-stack {
|
||
position: relative;
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
/* Own stacking context so the blurred bed can never paint over the
|
||
badges/hover-play that share .cover-wrap. */
|
||
isolation: isolate;
|
||
}
|
||
|
||
.cover-thumb {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
/* Scaled up so the blur's soft edge is pushed outside the tile. */
|
||
transform: scale(1.14);
|
||
filter: blur(10px) saturate(1.1);
|
||
transition: opacity 300ms ease;
|
||
}
|
||
|
||
.cover-stack:global(.cover-ready) .cover-thumb { opacity: 0; }
|
||
.cover-stack:global(.cover-settled) .cover-thumb { display: none; }
|
||
|
||
.cover-stack .cover {
|
||
position: relative;
|
||
opacity: 0;
|
||
transform: scale(1.04);
|
||
transition: opacity 300ms ease, transform 300ms cubic-bezier(0.2, 0.8, 0.3, 1);
|
||
}
|
||
|
||
/* `is-loaded` is applied at runtime by the `smoothSrc` action, so Svelte's
|
||
compiler cannot see it in the markup and prunes the rule as unused —
|
||
:global keeps it in the build while `.cover-stack .cover` stays scoped. */
|
||
.cover-stack .cover:global(.is-loaded) {
|
||
opacity: 1;
|
||
transform: scale(1);
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.cover-stack .cover {
|
||
transform: none;
|
||
transition: opacity 120ms ease;
|
||
}
|
||
.cover-stack .cover:global(.is-loaded) { transform: none; }
|
||
}
|
||
|
||
/* Edition-count badge, "accent glass": translucent accent over a backdrop
|
||
blur with an accent hairline, so the artwork glows through tinted. */
|
||
.ed-badge {
|
||
position: absolute;
|
||
top: 6px;
|
||
right: 6px;
|
||
z-index: 1;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
line-height: 1;
|
||
padding: 4px 7px;
|
||
border-radius: 6px;
|
||
font-size: 10.5px;
|
||
font-weight: 800;
|
||
font-variant-numeric: tabular-nums;
|
||
color: #fff;
|
||
background: color-mix(in srgb, var(--accent) 40%, rgba(10, 10, 12, 0.42));
|
||
backdrop-filter: blur(6px) saturate(1.6);
|
||
-webkit-backdrop-filter: blur(6px) saturate(1.6);
|
||
box-shadow:
|
||
inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent),
|
||
0 2px 8px rgba(0, 0, 0, 0.35);
|
||
}
|
||
|
||
.ed-badge :global(svg) {
|
||
display: block;
|
||
}
|
||
|
||
/* Hover play: an accent disc pinned inside the art's bottom-right. */
|
||
.hoverplay {
|
||
position: absolute;
|
||
right: 8px;
|
||
bottom: 8px;
|
||
z-index: 2;
|
||
display: grid;
|
||
place-items: center;
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
color: #000;
|
||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.55);
|
||
opacity: 0;
|
||
transform: translateY(4px);
|
||
transition: opacity 0.18s ease, transform 0.18s ease, background 0.18s ease;
|
||
}
|
||
|
||
.hoverplay:hover {
|
||
background: var(--accent-hover);
|
||
}
|
||
|
||
.release:hover .hoverplay,
|
||
.release:focus-visible .hoverplay {
|
||
opacity: 1;
|
||
transform: none;
|
||
}
|
||
|
||
/* Now-playing pill, same "accent glass" language as .ed-badge. The
|
||
playing card is marked by the pill's motion alone — no outline on the
|
||
art, so the selected card's ring (.release.active) stays the only
|
||
rectangle. Bars animate only while audio is actually playing and the
|
||
window is visible (the shared activity gate); paused freezes them. */
|
||
.now-badge {
|
||
position: absolute;
|
||
left: 6px;
|
||
bottom: 6px;
|
||
z-index: 1;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
line-height: 1;
|
||
padding: 4px 7px;
|
||
border-radius: 6px;
|
||
font-size: 9.5px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
color: #fff;
|
||
background: color-mix(in srgb, var(--accent) 40%, rgba(10, 10, 12, 0.42));
|
||
backdrop-filter: blur(6px) saturate(1.6);
|
||
-webkit-backdrop-filter: blur(6px) saturate(1.6);
|
||
box-shadow:
|
||
inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent),
|
||
0 2px 8px rgba(0, 0, 0, 0.35);
|
||
}
|
||
|
||
/* Stepped equalizer, same recipe as Now Playing's queue cue: steps(1)
|
||
keeps the playing signal alive without a repaint every display frame. */
|
||
.now-badge .eqb {
|
||
display: inline-flex;
|
||
align-items: flex-end;
|
||
gap: 1.5px;
|
||
width: 11px;
|
||
height: 10px;
|
||
}
|
||
.now-badge .eqb b { width: 2.5px; background: currentColor; border-radius: 1px; }
|
||
.now-badge.live .eqb b { animation: npeq 1.2s steps(1, end) infinite; }
|
||
.now-badge .eqb b:nth-child(1) { height: 40%; animation-delay: -0.2s; }
|
||
.now-badge .eqb b:nth-child(2) { height: 90%; }
|
||
.now-badge .eqb b:nth-child(3) { height: 60%; animation-delay: -0.4s; }
|
||
@keyframes npeq {
|
||
0%, 100% { transform: scaleY(0.4); }
|
||
50% { transform: scaleY(1); }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.now-badge.live .eqb b { animation: none; }
|
||
}
|
||
|
||
.trow.now .ttitle,
|
||
.trow.now .tno {
|
||
color: var(--accent-hover);
|
||
}
|
||
|
||
.resolving {
|
||
color: var(--text-secondary);
|
||
font-size: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.resolving .pulse {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.resolving.error {
|
||
color: #f15e6c;
|
||
}
|
||
|
||
.pill {
|
||
background: var(--accent);
|
||
color: #000;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
padding: 6px 14px;
|
||
border-radius: 14px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.pill:hover {
|
||
background: var(--accent-hover);
|
||
}
|
||
|
||
.pill.ghost {
|
||
background: var(--bg-highlight);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.pill.ghost:hover {
|
||
background: var(--bg-press, #333);
|
||
}
|
||
|
||
.pill:disabled {
|
||
background: var(--bg-highlight);
|
||
color: var(--text-muted);
|
||
cursor: default;
|
||
opacity: 0.6;
|
||
}
|
||
.pill:disabled:hover {
|
||
background: var(--bg-highlight);
|
||
}
|
||
|
||
/* Policy 1: the full title always shows — grid rows grow to the tallest
|
||
card; nothing is ever ellipsized away. */
|
||
.title {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
margin-top: 9px;
|
||
line-height: 1.3;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.owned {
|
||
display: inline-block;
|
||
vertical-align: -1px;
|
||
color: var(--accent-hover);
|
||
margin-left: 3px;
|
||
}
|
||
|
||
.sub {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
/* The card is one clickable surface that opens the inline expander. */
|
||
.release {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 9px;
|
||
border-radius: var(--radius);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition: background 0.15s ease;
|
||
content-visibility: auto;
|
||
contain-intrinsic-size: 150px 210px;
|
||
/* Anchor for the open-expander scroll: clears the sticky results-head
|
||
AND the parked banner beneath it plus breathing room, so a clicked
|
||
row lands just below the banner instead of underneath it. Tracks
|
||
--banner-h rather than a constant, because the banner now scales
|
||
with the window. Same expression as revealIntoView's MARGIN. */
|
||
scroll-margin-top: calc(22px + var(--banner-h) + 18px);
|
||
}
|
||
|
||
.release:hover,
|
||
.release:focus-visible {
|
||
background: var(--bg-elevated);
|
||
}
|
||
|
||
/* INSET ring: a spread shadow paints outside the border box and the
|
||
grid's paint containment clips it at the first row's top edge. */
|
||
.release.active {
|
||
background: var(--bg-elevated);
|
||
box-shadow: inset 0 0 0 1.5px var(--accent);
|
||
}
|
||
|
||
/* ---- inline album expander ---- */
|
||
/* Full grid row directly below the open card's row. The slide transition
|
||
animates its height on open/close; same-row switches keep this element
|
||
and only crossfade the panel content. */
|
||
.album-expander {
|
||
grid-column: 1 / -1;
|
||
position: relative;
|
||
}
|
||
|
||
/* Mock-exact notch: a rotated square whose BOTTOM half is hidden by the
|
||
panel (which paints above it — no z-index, panel is positioned and
|
||
later in the DOM), so only a clean bordered tip pokes over the panel's
|
||
top edge, whatever the rail glow tints the panel beneath it. */
|
||
.notch {
|
||
position: absolute;
|
||
top: -6px;
|
||
width: 12px;
|
||
height: 12px;
|
||
background: var(--bg-elevated);
|
||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||
transform: rotate(45deg);
|
||
transition: left 240ms cubic-bezier(0.33, 1, 0.4, 1);
|
||
/* The open animation clips the expander's overflow (the tip lives
|
||
above its top edge), so fade the notch in as the height settles —
|
||
the same 180ms appearance the mock used. */
|
||
animation: notch-fade 200ms ease 300ms backwards;
|
||
}
|
||
|
||
@keyframes notch-fade {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
.xp-panel {
|
||
position: relative;
|
||
background: var(--bg-elevated);
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.xp-body {
|
||
display: grid;
|
||
grid-template-columns: 250px 1fr;
|
||
}
|
||
|
||
/* Rail ambience: the Now Playing "glow" recipe — a blurred copy of the
|
||
SAME cover behind the content (blur(38px) saturate(1.4)) plus a
|
||
legibility shade, so the two panels read as siblings. */
|
||
.xp-rail {
|
||
position: relative;
|
||
padding: 20px;
|
||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 13px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.rail-glow {
|
||
--rail-art-opacity: 0.32;
|
||
position: absolute;
|
||
inset: -10%;
|
||
z-index: 0;
|
||
width: 120%;
|
||
height: 120%;
|
||
object-fit: cover;
|
||
filter: blur(38px) saturate(1.4);
|
||
opacity: 0.32;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.rail-shade {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 0;
|
||
background: linear-gradient(
|
||
180deg,
|
||
transparent 0%,
|
||
rgba(24, 24, 24, 0.55) 55%,
|
||
rgba(24, 24, 24, 0.92) 100%
|
||
);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.xp-rail > :not(.rail-glow):not(.rail-shade) {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.rail-art {
|
||
--rail-art-opacity: 1;
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 8px;
|
||
object-fit: cover;
|
||
display: block;
|
||
box-shadow: 0 18px 38px -18px rgba(0, 0, 0, 0.7);
|
||
}
|
||
|
||
.rail-art-incoming {
|
||
animation: rail-art-fade-in 500ms ease both;
|
||
}
|
||
|
||
.rail-art-outgoing {
|
||
animation: rail-art-fade-out 500ms ease both;
|
||
pointer-events: none;
|
||
}
|
||
|
||
@keyframes rail-art-fade-in {
|
||
from { opacity: 0; }
|
||
to { opacity: var(--rail-art-opacity, 1); }
|
||
}
|
||
|
||
@keyframes rail-art-fade-out {
|
||
from { opacity: var(--rail-art-opacity, 1); }
|
||
to { opacity: 0; }
|
||
}
|
||
|
||
.rail-art-stage {
|
||
width: 100%;
|
||
aspect-ratio: 1;
|
||
}
|
||
|
||
.rail-art.placeholder {
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
|
||
.rail-title {
|
||
margin: 0;
|
||
font-size: 16.5px;
|
||
font-weight: 800;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
.rail-meta {
|
||
margin: -7px 0 0;
|
||
font-size: 12.5px;
|
||
color: var(--text-secondary);
|
||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
.rail-cta {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.rail-cta .pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.pill.grow {
|
||
flex: 1;
|
||
}
|
||
|
||
/* Clamped description dissolves at its bottom edge; the quiet text button
|
||
below expands it, its chevron rotating. */
|
||
.rail-desc {
|
||
margin: 0;
|
||
font-size: 12.5px;
|
||
color: var(--text-secondary);
|
||
line-height: 1.55;
|
||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||
}
|
||
|
||
.rail-desc.clamped {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 4;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
-webkit-mask-image: linear-gradient(180deg, #000 62%, transparent 100%);
|
||
mask-image: linear-gradient(180deg, #000 62%, transparent 100%);
|
||
}
|
||
|
||
.rail-more {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
align-self: flex-start;
|
||
margin-top: -6px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: var(--text-secondary);
|
||
padding: 2px 0;
|
||
}
|
||
|
||
.rail-more:hover {
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.rail-more :global(svg) {
|
||
transition: transform 200ms ease;
|
||
}
|
||
|
||
.rail-more.open :global(svg) {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.rail-eds {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.rail-lab {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--text-muted);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.rail-ed {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 7px 8px;
|
||
border-radius: 6px;
|
||
border: 1px solid transparent;
|
||
text-align: left;
|
||
}
|
||
|
||
.rail-ed:hover {
|
||
background: rgba(255, 255, 255, 0.06);
|
||
}
|
||
|
||
.rail-ed.on {
|
||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.rail-ed-art {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 4px;
|
||
object-fit: cover;
|
||
flex: none;
|
||
}
|
||
|
||
.rail-ed-art.placeholder {
|
||
display: grid;
|
||
place-items: center;
|
||
background: var(--bg-highlight);
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.rail-ed-copy {
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.rail-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;
|
||
}
|
||
|
||
.rail-ed-copy .y {
|
||
display: block;
|
||
margin-top: 1px;
|
||
font-size: 11.5px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.rail-ed .own {
|
||
color: var(--accent);
|
||
flex: none;
|
||
}
|
||
|
||
.xp-tracks {
|
||
padding: 14px 16px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.disc-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 14px 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);
|
||
}
|
||
|
||
.tick {
|
||
display: inline-block;
|
||
vertical-align: -1px;
|
||
color: var(--accent);
|
||
margin-left: 6px;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.notch,
|
||
.rail-more :global(svg),
|
||
.hoverplay {
|
||
transition-duration: 1ms;
|
||
}
|
||
|
||
.notch {
|
||
animation-duration: 1ms;
|
||
animation-delay: 0ms;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.xp-body {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.xp-rail {
|
||
border-right: 0;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||
}
|
||
}
|
||
|
||
/* Mock-exact rows: number / title / duration, with the per-track play and
|
||
save controls revealed on hover in place of the duration. */
|
||
.trow {
|
||
position: relative;
|
||
display: grid;
|
||
grid-template-columns: 26px minmax(0, 1fr) auto;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 7px 10px;
|
||
border-radius: 6px;
|
||
font-size: 13.5px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.trow:hover {
|
||
background: var(--bg-highlight);
|
||
}
|
||
|
||
.mini-btn,
|
||
.mini-get {
|
||
position: absolute;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
display: grid;
|
||
place-items: center;
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 50%;
|
||
opacity: 0;
|
||
transition: opacity 0.12s ease;
|
||
}
|
||
|
||
.mini-btn.play {
|
||
right: 36px;
|
||
color: var(--accent-hover);
|
||
}
|
||
|
||
.mini-get {
|
||
right: 8px;
|
||
}
|
||
|
||
.trow:hover .mini-btn,
|
||
.trow:hover .mini-get,
|
||
.trow:focus-within .mini-btn,
|
||
.trow:focus-within .mini-get {
|
||
opacity: 1;
|
||
}
|
||
|
||
.trow:hover .tdur,
|
||
.trow:focus-within .tdur {
|
||
opacity: 0;
|
||
}
|
||
|
||
.trow.locked {
|
||
cursor: default;
|
||
}
|
||
.trow.locked:hover {
|
||
background: transparent;
|
||
}
|
||
.trow.locked .ttitle {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.tag-unreleased {
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.02em;
|
||
color: var(--text-muted);
|
||
border: 1px solid var(--border, #3a3a3a);
|
||
border-radius: 4px;
|
||
padding: 1px 6px;
|
||
}
|
||
|
||
.tno {
|
||
text-align: right;
|
||
color: var(--text-muted);
|
||
font-size: 12.5px;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.ttitle {
|
||
min-width: 0;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.tdur {
|
||
color: var(--text-muted);
|
||
font-size: 12.5px;
|
||
font-variant-numeric: tabular-nums;
|
||
transition: opacity 0.12s ease;
|
||
}
|
||
|
||
.mini-btn,
|
||
.mini-get {
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.mini-btn:hover,
|
||
.mini-get:hover {
|
||
color: var(--accent-hover);
|
||
}
|
||
|
||
.mini-btn:disabled,
|
||
.mini-get:disabled {
|
||
color: var(--text-muted);
|
||
opacity: 0.4;
|
||
cursor: default;
|
||
}
|
||
.mini-btn:disabled:hover,
|
||
.mini-get:disabled:hover {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
@media (max-width: 720px) {
|
||
.hero-zone {
|
||
aspect-ratio: 2;
|
||
min-height: 240px;
|
||
max-height: 52vh;
|
||
}
|
||
|
||
.hero-copy {
|
||
padding: 0 0 18px;
|
||
}
|
||
|
||
.artist-logo {
|
||
max-height: 150px;
|
||
}
|
||
|
||
.artist-logo.mask {
|
||
width: min(calc(var(--logo-aspect, 3) * var(--logo-h, 200px) * 0.75), 92%);
|
||
}
|
||
|
||
.results-head {
|
||
padding: 0 20px;
|
||
}
|
||
|
||
.hero-art-btn span,
|
||
.hero-art-notice {
|
||
display: none;
|
||
}
|
||
|
||
.hero-art-btn {
|
||
width: 28px;
|
||
padding: 0;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* The spacer is the source of truth for the row's resting height —
|
||
heroScroll() reads --row-h off it, and the pinned row follows. */
|
||
.actions-gap {
|
||
height: 66px;
|
||
}
|
||
|
||
.popular-row {
|
||
grid-template-columns: 24px 36px minmax(0, 1fr) 38px 22px;
|
||
gap: 8px;
|
||
}
|
||
|
||
.track-cover {
|
||
width: 36px;
|
||
height: 36px;
|
||
}
|
||
|
||
.track-album {
|
||
display: none;
|
||
}
|
||
}
|
||
</style>
|