forked from Keelrleek/SoulStream
The glass bar was copied into two views and each carried its own contextual
back link — "Results" on the artist page, the artist's name on the album page.
It is a shared component now (src/components/GlassBar.svelte), and it carries
the app's navigation: back/forward arrows, and the trail to where you are.
**History and the trail are different things, and the file keeps them apart.**
History is a SEQUENCE — every place visited, in order — and it drives the
arrows. The trail is a HIERARCHY, derived from the current place, never from
history. That is why switching between two editions of a record adds a history
step but does not add a crumb: a breadcrumb built out of history would read
`Search › Weezer › Blue Album › Deluxe Edition`, which says the deluxe sits
inside the original. It does not.
Crumbs improve in place. An artist page opens on an id and learns its name and
portrait a moment later, so the crumb starts as a placeholder and is refreshed
through ONE call site (`rememberArtistSeed`), guarded so remembering a seed for
a row in some list can never rewrite where you are. Search results are the
journey's root rather than a bare "Search" — the crumb reads “weezer”, which
is the thing you actually want to get back to.
Arriving crumbs slide in from the right; only genuinely new ones animate, so an
ancestor that was already there does not re-run its entrance when the tail
changes. Chosen from four motions mocked against a real navigation stack.
Every navigation now goes through a recorder. A bare `ui.view = "…"` is a
destination the back arrow can never return to, so the sidebar routes through
`goToView` and the catalog openers record on arrival.
**Scroll position is remembered.** Going back returns you to the paragraph you
left; arriving somewhere new starts at its top. The difference is exactly
whether the move came from the arrows/trail or from a fresh click. Two details
that mattered: the offset is captured at the START of a navigation, because the
scroll pane outlives the view swap and by the time a page has been replaced its
scrollTop already belongs to its successor; and restoring is not one assignment
— the artist page paints a skeleton, then an overview, then the full
discography, so a single `scrollTop = 900` clamps to whatever height exists in
that frame and lands near the top. It re-applies until the target is reachable
and gives up the instant the reader touches wheel, key or trackpad. The album
page never reset its scroll at all, so a record opened from half-way down an
artist page began half-way down; it does now.
**The artwork lightbox serves any cover.** It was hardwired to the playing
queue item; `ui.fullscreenArt` (a boolean) is now `ui.artwork` (a descriptor),
so Now Playing, the artist page's expander rail and the album hero all open the
same viewer with their own `{src, local, title, subtitle}`. Each passes the
UPGRADED cover rather than the sized variant it paints — the point of the
viewer is the master resolution.
Hovering artwork offers it: the whole element lifts under a light wash with the
expand mark centred. `transform`, not a width change — on the album page the
copy column is sized to end level with the artwork, so a reflowing hover would
twitch the entire hero. The three numbers (--art-grow 1.035, --art-motion
160ms, --art-wash) live in global.css rather than being typed into three files.
Now Playing's corner expand disc is gone: one gesture for "open this artwork"
everywhere instead of a disc in one place and a lift in the others.
On the artist page the expander's album NAME now opens that record's page.
Also removed: the album page's scroll-triggered title takeover. The breadcrumb
names the record permanently, which made it redundant — the build was already
flagging its CSS as dead.
**And the fix this session actually turned on.** Kyle reported the album
tracklist's counts as "heavily glitched": Pinkerton showed El Scorcho at 636
listeners beside Tired Of Sex at 606,324. Switching the column from plays to
listeners changed nothing, because both were wrong for the same reason.
`artist.gettoptracks` returns a popular song many times over — once as itself,
then again for every "(Album Version)", live cut and remaster — listeners
descending. The fold meant to keep the first entry, and its comment said so,
but it was written `if by_key.insert(k, v).is_some() { continue }`.
`HashMap::insert` returns the previous value AND STILL OVERWRITES, so the guard
correctly skipped the ranked push while letting every later duplicate win the
map the album tracklist reads. The last re-listing — the most obscure pressing
— is what reached the screen.
Measured against the live API for Weezer: Buddy Holly is rank 1 at 2,351,642
listeners and rank 679 as "(Album Version)" at 1,919; the page printed 1,919.
My Name Is Jonas 1,100,251 → 870. El Scorcho 654,071 → 636. Pink Triangle
810,659 → 6,070. Only songs Last.fm lists exactly once — "Tired of Sex",
"Across the Sea" — were ever right, which is why the column looked plausible in
places and absurd in others. Twenty-four entries for "The Good Life", twenty
for "El Scorcho".
The fold is now `fold_lastfm_duplicates`, pinned by two tests using those real
figures. It also repairs Popular's plays and the album totals, which read the
same map. The tracklist column shows listeners in both places it appears — the
album page and the artist page's expander — because leaving one on plays would
have let one record disagree with itself.
152 Rust tests and 85 JS tests pass.
955 lines
34 KiB
Svelte
955 lines
34 KiB
Svelte
<script>
|
||
import { untrack } from "svelte";
|
||
import {
|
||
player,
|
||
ui,
|
||
toggleNowPlaying,
|
||
getArt,
|
||
getCatalogArt,
|
||
jumpToQueueIndex,
|
||
discovery,
|
||
loadDiscovery,
|
||
openCatalogArtist,
|
||
streamCatalogAlbum,
|
||
openCurrentArtist,
|
||
openCurrentAlbum,
|
||
openCurrentSong,
|
||
libraryStore,
|
||
artBroken,
|
||
markArtBroken,
|
||
playerArtSource,
|
||
} from "../lib/state.svelte.js";
|
||
import Icon from "./Icon.svelte";
|
||
import SaveMenu from "./SaveMenu.svelte";
|
||
import { floatScroll } from "../lib/scrollbar.js";
|
||
|
||
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
|
||
|
||
/** Hand the playing cover to the shared artwork lightbox. It takes an
|
||
* explicit descriptor now rather than reaching into the queue itself, so
|
||
* the same viewer serves an album's artwork anywhere else in the app. */
|
||
function openArtwork() {
|
||
const item = currentItem;
|
||
if (!item) return;
|
||
const src = item.art ? item.art : item.artUrl ? playerArtSource(item) : null;
|
||
if (!src) return;
|
||
ui.artwork = {
|
||
src,
|
||
local: Boolean(item.art),
|
||
title: item.title ?? "",
|
||
subtitle: item.sub ?? "",
|
||
};
|
||
}
|
||
// Gate stream-only sections (quality, source, Save) on the displayed
|
||
// item's own stream field — the source of truth for what's on screen.
|
||
// Library tracks never carry one, so they never show "Save to Library"
|
||
// (and a track already in the library always plays locally, never streams).
|
||
// player.track.source is derived and can lag across gapless boundaries.
|
||
const isStream = $derived(!!currentItem?.stream);
|
||
const quality = $derived(currentItem?.quality ?? null);
|
||
|
||
// Album context: the queue is a catalog album/continuation with >1 track.
|
||
const albumMode = $derived(
|
||
!!player.catalogRef.albumId && player.queue.length > 1,
|
||
);
|
||
|
||
// Artist name for the Save action: album items carry "artist — album".
|
||
const artistName = $derived.by(() => {
|
||
const sub = currentItem?.sub ?? "";
|
||
return sub.includes(" — ") ? sub.split(" — ")[0].trim() : sub;
|
||
});
|
||
// Artist / album split for the separate, clickable identity lines.
|
||
const metaParts = $derived.by(() => {
|
||
const sub = player.track?.sub ?? "";
|
||
return sub.includes(" — ")
|
||
? sub.split(" — ").map((s) => s.trim())
|
||
: [sub.trim(), ""];
|
||
});
|
||
|
||
const LOSSLESS = new Set(["FLAC", "WAV", "ALAC", "AIFF", "APE"]);
|
||
|
||
// Discovery: load more-from-artist / fans-also-like / about when the
|
||
// playing artist changes, but only while the panel is visible. The old
|
||
// always-mounted fetch could launch a full artist refinement in the
|
||
// background while the user was opening an unrelated artist page.
|
||
$effect(() => {
|
||
const name = artistName;
|
||
const visible = ui.nowPlaying;
|
||
if (name && visible) {
|
||
// loadDiscovery reads/writes its own reactive progress fields.
|
||
// Without this boundary a failed/empty lookup makes `loading=false`
|
||
// retrigger the effect indefinitely until Svelte aborts the flush.
|
||
untrack(() => loadDiscovery(name));
|
||
}
|
||
});
|
||
|
||
function openDiscoveryArtist() {
|
||
if (discovery.artistId == null) return;
|
||
openCatalogArtist(discovery.artistId, {
|
||
id: discovery.artistId,
|
||
name: artistName,
|
||
picture: discovery.picture,
|
||
fans: discovery.fans,
|
||
album_count: discovery.albumCount,
|
||
});
|
||
}
|
||
|
||
// Collapse the bio back to the clamped state whenever the artist changes.
|
||
let bioExpanded = $state(false);
|
||
$effect(() => {
|
||
discovery.artist;
|
||
bioExpanded = false;
|
||
});
|
||
|
||
// Keep the panel body mounted just long enough to finish the close slide,
|
||
// then release its reactive waveform work and filtered layers entirely.
|
||
// Reopening cancels the pending teardown and restores content immediately.
|
||
let panelContentVisible = $state(ui.nowPlaying);
|
||
$effect(() => {
|
||
if (ui.nowPlaying) {
|
||
panelContentVisible = true;
|
||
return;
|
||
}
|
||
const timer = setTimeout(() => (panelContentVisible = false), 300);
|
||
return () => clearTimeout(timer);
|
||
});
|
||
|
||
const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||
|
||
// "More from": full albums ahead of EPs/singles, newest first within each
|
||
// tier, drop the album that's playing now, cap at 6.
|
||
const typeRank = (t) => (t === "album" ? 0 : t === "ep" ? 1 : 2);
|
||
const moreAlbums = $derived.by(() => {
|
||
const here = norm(currentItem?.sub?.split(" — ")[1] ?? "");
|
||
return [...discovery.albums]
|
||
.filter((a) => norm(a.title) !== here)
|
||
.sort(
|
||
(a, b) =>
|
||
typeRank(a.record_type) - typeRank(b.record_type) ||
|
||
(b.release_date ?? "").localeCompare(a.release_date ?? ""),
|
||
)
|
||
.slice(0, 6);
|
||
});
|
||
|
||
const relatedArtists = $derived(discovery.related.slice(0, 4));
|
||
|
||
// How many of this artist's albums are already in the local library.
|
||
const inLibraryCount = $derived.by(() => {
|
||
const a = norm(artistName);
|
||
if (!a) return 0;
|
||
return libraryStore.albums.filter((x) => norm(x.artist) === a).length;
|
||
});
|
||
|
||
function albumYear(a) {
|
||
return a.release_date ? a.release_date.slice(0, 4) : "";
|
||
}
|
||
function fmtFans(n) {
|
||
if (!n) return "—";
|
||
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
|
||
if (n >= 1e3) return `${Math.round(n / 1e3)}k`;
|
||
return String(n);
|
||
}
|
||
|
||
// Album art (local path or catalog CDN) → data URL, reused for the glow.
|
||
let artUrl = $state(null);
|
||
$effect(() => {
|
||
// A closed panel should not duplicate PlayerBar's cold cover-art IPC or
|
||
// retain a large data URL that has no visible consumer.
|
||
const item = panelContentVisible ? currentItem : null;
|
||
artUrl = null;
|
||
if (!item) return;
|
||
let active = true;
|
||
if (item.art) {
|
||
getArt(item.art).then((u) => active && (artUrl = u));
|
||
} else if (item.artUrl) {
|
||
getCatalogArt(playerArtSource(item)).then((u) => active && (artUrl = u));
|
||
}
|
||
return () => (active = false);
|
||
});
|
||
|
||
const bufferPct = $derived(
|
||
player.streamTotal > 0
|
||
? Math.min(100, (player.streamReceived / player.streamTotal) * 100)
|
||
: 0,
|
||
);
|
||
const playedPct = $derived(
|
||
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
|
||
);
|
||
|
||
// Average dense source peaks into fixed, equally sized display bars. Using
|
||
// an average (rather than picking one peak) stops isolated transients from
|
||
// making random bars hit the ceiling.
|
||
// 78 × 2px bars plus 77 × 2px gaps = 310px. The sidebar's 312px
|
||
// waveform lane can center that exact integer footprint, avoiding the
|
||
// fractional gap rounding that made some neighboring bars look closer.
|
||
const BARS = 78;
|
||
const bars = $derived.by(() => {
|
||
const w = player.waveform;
|
||
if (!w || w.length === 0) return [];
|
||
const out = [];
|
||
for (let i = 0; i < BARS; i++) {
|
||
const from = Math.floor((i / BARS) * w.length);
|
||
const to = Math.max(from + 1, Math.floor(((i + 1) / BARS) * w.length));
|
||
let sum = 0;
|
||
for (let j = from; j < to; j++) sum += w[j] ?? 0;
|
||
out.push(sum / (to - from));
|
||
}
|
||
return out;
|
||
});
|
||
const playedBars = $derived((playedPct / 100) * bars.length);
|
||
|
||
function barHeight(value, index) {
|
||
const capped = Math.pow(Math.min(0.88, value * 1.18), 0.8);
|
||
// Stable per-bar grain keeps a dense loud passage from flattening
|
||
// into a ruler of identical maximum-height bars. It is deterministic
|
||
// (no visual flicker as the playhead moves) and only matters near the
|
||
// cap, where source peaks otherwise converge on the same value.
|
||
const grain = ((Math.sin((index + 1) * 12.9898) * 43758.5453) % 1 + 1) % 1;
|
||
const peakFactor = Math.max(0, (capped - 0.58) / 0.3);
|
||
const level = Math.max(0, Math.min(0.88, capped + (grain - 0.5) * 0.12 * peakFactor));
|
||
return 14 + level * 72; // preserve headroom; never peak against the top
|
||
}
|
||
function barFill(index) {
|
||
return Math.max(0, Math.min(1, playedBars - index));
|
||
}
|
||
|
||
function fmtTime(s) {
|
||
if (s == null || !isFinite(s)) return "–:––";
|
||
s = Math.max(0, Math.floor(s));
|
||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||
}
|
||
function fmtSize(bytes) {
|
||
if (!bytes) return null;
|
||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||
}
|
||
function fmtRate(q) {
|
||
if (!q?.sampleRate) return null;
|
||
const khz = (q.sampleRate / 1000).toFixed(1).replace(/\.0$/, "");
|
||
return q.bitDepth ? `${khz} kHz · ${q.bitDepth}-bit` : `${khz} kHz`;
|
||
}
|
||
</script>
|
||
|
||
<aside class="nowplaying" class:closed={!ui.nowPlaying}>
|
||
<!-- Frosted-glass header: it overlays the scrolling content and blurs
|
||
whatever sits beneath it, so the album art's colors seep through and
|
||
shift as you scroll the art up under the glass. -->
|
||
<header class="np-head" data-scrollhead>
|
||
<span class="np-title">Now playing</span>
|
||
<button class="np-icon" title="Hide panel" onclick={toggleNowPlaying}>
|
||
<Icon name="chevron-left" size={16} />
|
||
</button>
|
||
</header>
|
||
|
||
<div class="np-scroll" use:floatScroll>
|
||
{#if panelContentVisible}
|
||
{#if currentItem}
|
||
<!-- glowing cover art -->
|
||
<div class="hero">
|
||
{#if artUrl}
|
||
<img class="glow" src={artUrl} alt="" aria-hidden="true" draggable="false" />
|
||
<button
|
||
class="cover-wrap"
|
||
title="View artwork"
|
||
aria-label="View album artwork full screen"
|
||
onclick={() => openArtwork()}
|
||
>
|
||
<img class="cover" src={artUrl} alt="" draggable="false" />
|
||
<span class="mark" aria-hidden="true"><Icon name="expand" size={18} /></span>
|
||
</button>
|
||
{:else}
|
||
<div class="cover placeholder"><Icon name="music" size={40} /></div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- identity: song / artist / album, each its own link -->
|
||
<div class="idn">
|
||
<div class="idn-text">
|
||
<button class="tt link" onclick={openCurrentSong} title={player.track?.title}>
|
||
{player.track?.title ?? "—"}
|
||
</button>
|
||
{#if metaParts[0]}
|
||
<button class="ar link" onclick={openCurrentArtist} title={metaParts[0]}>
|
||
{metaParts[0]}
|
||
</button>
|
||
{/if}
|
||
{#if metaParts[1]}
|
||
<button class="ar link album" onclick={openCurrentAlbum} title={metaParts[1]}>
|
||
{metaParts[1]}
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- mini waveform + times -->
|
||
{#if bars.length}
|
||
<div class="wave" aria-hidden="true">
|
||
{#each bars as v, i}
|
||
<i
|
||
style="height:{barHeight(v, i)}%; --fill:{barFill(i)}"
|
||
></i>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
<div class="times">
|
||
<span>{fmtTime(player.position)}</span>
|
||
<span>{fmtTime(player.duration)}</span>
|
||
</div>
|
||
|
||
<!-- quality chips -->
|
||
{#if quality}
|
||
<div class="chips">
|
||
{#if quality.format}
|
||
<span class="chip" class:loss={LOSSLESS.has(quality.format)}>
|
||
{#if LOSSLESS.has(quality.format)}<span class="dot"></span>{/if}
|
||
{quality.format}
|
||
</span>
|
||
{/if}
|
||
{#if quality.bitrate}
|
||
<span class="chip" class:loss={LOSSLESS.has(quality.format)}>
|
||
{quality.bitrate} kbps{quality.vbr ? " VBR" : ""}
|
||
</span>
|
||
{/if}
|
||
{#if fmtRate(quality)}<span class="chip">{fmtRate(quality)}</span>{/if}
|
||
{#if fmtSize(quality.size)}<span class="chip">{fmtSize(quality.size)}</span>{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- source card (streaming only) -->
|
||
{#if isStream && currentItem.stream}
|
||
<div class="src">
|
||
<div class="src-lab">Streaming from</div>
|
||
<div class="peer">
|
||
<div class="av">{currentItem.stream.username.slice(0, 1).toUpperCase()}</div>
|
||
<div class="who" title={currentItem.stream.username}>{currentItem.stream.username}</div>
|
||
</div>
|
||
{#if player.streamTotal > 0}
|
||
<div class="buf">
|
||
<div class="bar"><i style="width:{bufferPct}%"></i></div>
|
||
<div class="meta">
|
||
<span>Buffered {(player.streamReceived / 1048576).toFixed(1)} / {(player.streamTotal / 1048576).toFixed(1)} MB</span>
|
||
<span>{Math.round(bufferPct)}%</span>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- save the file(s) you're already streaming (no re-search) -->
|
||
<div class="actions-row">
|
||
<SaveMenu align="down" />
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- queue / album tracklist -->
|
||
{#if player.queue.length > 1}
|
||
<div class="section">
|
||
<div class="lab">{albumMode ? "From the album" : "Queue"}</div>
|
||
<div class="list">
|
||
{#each player.queue as item, i}
|
||
<button class="row" class:now={i === player.queueIndex} onclick={() => jumpToQueueIndex(i)}>
|
||
<span class="n">
|
||
{#if i === player.queueIndex}
|
||
<span
|
||
class="eq"
|
||
class:active={ui.nowPlaying && player.status === "playing" && ui.windowActive}
|
||
><b></b><b></b><b></b></span>
|
||
{:else}{i + 1}{/if}
|
||
</span>
|
||
<span class="nm" title={item.title}>{item.title}</span>
|
||
<span class="du">{fmtTime(item.duration)}</span>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Discovery: more from the artist -->
|
||
{#if moreAlbums.length}
|
||
<div class="section">
|
||
<div class="lab divlab">More from {artistName}</div>
|
||
<div class="grid2">
|
||
{#each moreAlbums as al}
|
||
<button class="mini" title="Play {al.title}" onclick={() => streamCatalogAlbum(al)}>
|
||
<div class="art">
|
||
{#if al.cover && !artBroken[al.cover]}<img src={al.cover} alt="" loading="lazy" draggable="false" onerror={() => markArtBroken(al.cover)} />{/if}
|
||
</div>
|
||
<div class="mt">{al.title}</div>
|
||
<div class="my">{albumYear(al)}{al.record_type && al.record_type !== "album" ? ` · ${al.record_type}` : ""}</div>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Discovery: fans also like -->
|
||
{#if relatedArtists.length}
|
||
<div class="section">
|
||
<div class="lab divlab">Fans also like</div>
|
||
<div class="fans">
|
||
{#each relatedArtists as ar}
|
||
<button class="fan" title={ar.name} onclick={() => openCatalogArtist(ar.id, ar)}>
|
||
<div class="av">
|
||
{#if ar.picture}<img src={ar.picture} alt="" loading="lazy" draggable="false" />{/if}
|
||
</div>
|
||
<div class="fn">{ar.name}</div>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Discovery: about the artist -->
|
||
{#if discovery.artistId && (discovery.picture || discovery.albumCount)}
|
||
<div class="section">
|
||
<div class="lab divlab">About the artist</div>
|
||
<div class="aboutcard">
|
||
<button class="bandphoto" title="Open {artistName}" onclick={openDiscoveryArtist}>
|
||
{#if discovery.picture}
|
||
<img src={discovery.picture} alt="" loading="lazy" draggable="false" />
|
||
{:else}
|
||
<span class="ph"><Icon name="music" size={28} /></span>
|
||
{/if}
|
||
</button>
|
||
<div class="about-text">
|
||
<button class="nm" onclick={openDiscoveryArtist}>{artistName}</button>
|
||
{#if discovery.bio}
|
||
<div class="bio">
|
||
<p class:clamped={!bioExpanded}>{discovery.bio}</p>
|
||
<button class="more" onclick={() => (bioExpanded = !bioExpanded)}>
|
||
{bioExpanded ? "Show less" : "Read more"}
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
<div class="about-body">
|
||
<div class="st"><b>{discovery.albumCount ?? "—"}</b>releases</div>
|
||
<div class="st"><b>{fmtFans(discovery.fans)}</b>fans</div>
|
||
<div class="st"><b>{inLibraryCount}</b>in library</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{:else}
|
||
<div class="empty">
|
||
<Icon name="music" size={30} />
|
||
<span>Nothing playing</span>
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
</aside>
|
||
|
||
<style>
|
||
.nowplaying {
|
||
grid-area: nowplaying;
|
||
background: var(--bg-player);
|
||
border-radius: var(--radius);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
min-height: 0;
|
||
position: relative;
|
||
/* Fixed width so the panel keeps its shape and slides off-screen when
|
||
the grid column collapses to 0, rather than squishing its content.
|
||
(The grid column uses --nowplaying-width, which animates 340→0.) */
|
||
width: 340px;
|
||
justify-self: start;
|
||
visibility: visible;
|
||
transition:
|
||
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||
opacity 0.24s ease,
|
||
visibility 0s linear;
|
||
}
|
||
.nowplaying.closed {
|
||
transform: translateX(calc(100% + 8px));
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
pointer-events: none;
|
||
transition:
|
||
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||
opacity 0.24s ease,
|
||
visibility 0s linear 0.28s;
|
||
}
|
||
|
||
/* Frosted-glass header floating over the scrolling content: backdrop-filter
|
||
blurs whatever is beneath it, so the album colors seep through and shift
|
||
as the art scrolls up under the glass. */
|
||
.np-head {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 46px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 14px;
|
||
z-index: 5;
|
||
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.04);
|
||
}
|
||
.np-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||
}
|
||
.np-icon {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 6px;
|
||
color: var(--text-secondary);
|
||
}
|
||
.np-icon:hover { background: var(--bg-highlight); color: var(--text-primary); }
|
||
|
||
.np-scroll {
|
||
overflow-y: auto;
|
||
overflow-anchor: none;
|
||
overscroll-behavior: none;
|
||
/* Clear the 46px glass header so the art isn't overlapped at rest; the
|
||
art still slides under the glass (tinting it) as you scroll. */
|
||
padding: 54px 14px 18px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
/* Keep every section at its natural height and let the panel scroll —
|
||
a flex column shrinks children by default, which collapsed the
|
||
percentage-height waveform bars on shorter windows. */
|
||
.np-scroll > * {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* glowing cover art (ambilight: a blurred copy of the same image) */
|
||
.hero { position: relative; padding: 6px 4px 10px; }
|
||
.glow {
|
||
position: absolute;
|
||
inset: -2% -2% 6%;
|
||
z-index: 0;
|
||
width: 104%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
border-radius: 30px;
|
||
filter: blur(38px) saturate(1.4);
|
||
opacity: 0.62;
|
||
transform: scale(1.02);
|
||
pointer-events: none;
|
||
}
|
||
/* The whole cover is the control now, with the artwork-hover treatment the
|
||
album page and the artist rail use (--art-* in global.css) — one gesture
|
||
for "open this artwork" wherever it appears, instead of a corner disc
|
||
here and a lift everywhere else. */
|
||
.cover-wrap {
|
||
position: relative;
|
||
z-index: 1;
|
||
display: block;
|
||
width: 100%;
|
||
padding: 0;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
transition: transform var(--art-motion);
|
||
}
|
||
.cover-wrap:hover,
|
||
.cover-wrap:focus-visible {
|
||
transform: scale(var(--art-grow));
|
||
}
|
||
.cover-wrap::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 2;
|
||
border-radius: 12px;
|
||
background: var(--art-wash);
|
||
opacity: 0;
|
||
transition: opacity var(--art-motion);
|
||
pointer-events: none;
|
||
}
|
||
.cover-wrap:hover::after,
|
||
.cover-wrap:focus-visible::after {
|
||
opacity: 1;
|
||
}
|
||
.mark {
|
||
position: absolute;
|
||
inset: 0;
|
||
margin: auto;
|
||
z-index: 3;
|
||
display: grid;
|
||
place-items: center;
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 50%;
|
||
background: rgba(12, 12, 14, 0.55);
|
||
backdrop-filter: blur(6px);
|
||
-webkit-backdrop-filter: blur(6px);
|
||
color: #fff;
|
||
opacity: 0;
|
||
transform: scale(0.82);
|
||
transition:
|
||
opacity var(--art-motion),
|
||
transform var(--art-motion);
|
||
}
|
||
.cover-wrap:hover .mark,
|
||
.cover-wrap:focus-visible .mark {
|
||
opacity: 1;
|
||
transform: none;
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.cover-wrap,
|
||
.mark {
|
||
transition-duration: 1ms;
|
||
}
|
||
.cover-wrap:hover,
|
||
.cover-wrap:focus-visible {
|
||
transform: none;
|
||
}
|
||
}
|
||
.cover {
|
||
position: relative;
|
||
z-index: 1;
|
||
width: 100%;
|
||
aspect-ratio: 1;
|
||
border-radius: 12px;
|
||
object-fit: cover;
|
||
background: var(--bg-highlight);
|
||
box-shadow: 0 18px 38px -18px rgba(0, 0, 0, 0.7);
|
||
display: block;
|
||
}
|
||
.cover.placeholder {
|
||
display: grid;
|
||
place-items: center;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.idn { display: flex; align-items: flex-start; gap: 10px; }
|
||
.idn-text { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; }
|
||
/* Each identity line is its own link — width-constrained so the hover
|
||
underline hugs the text, and ellipsis-truncated. */
|
||
.tt,
|
||
.ar {
|
||
max-width: 100%;
|
||
text-align: left;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
display: block;
|
||
}
|
||
.tt {
|
||
font-size: 21px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.01em;
|
||
line-height: 1.2;
|
||
}
|
||
.tt.link:hover { text-decoration: underline; }
|
||
.ar {
|
||
color: var(--text-secondary);
|
||
font-size: 13.5px;
|
||
margin-top: 3px;
|
||
}
|
||
.ar.link:hover { color: var(--text-primary); text-decoration: underline; }
|
||
.ar.album {
|
||
font-size: 12.5px;
|
||
color: var(--text-muted);
|
||
margin-top: 1px;
|
||
}
|
||
.ar.album.link:hover { color: var(--text-primary); }
|
||
|
||
/* mini waveform */
|
||
.wave {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: end;
|
||
gap: 2px;
|
||
height: 38px;
|
||
}
|
||
.wave i {
|
||
flex: 0 0 2px;
|
||
width: 2px;
|
||
border-radius: 1px;
|
||
background: color-mix(in srgb, var(--accent) calc(var(--fill) * 100%), var(--text-muted));
|
||
min-height: 2px;
|
||
}
|
||
.times {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 11.5px;
|
||
color: var(--text-muted);
|
||
font-variant-numeric: tabular-nums;
|
||
margin-top: -8px;
|
||
}
|
||
|
||
/* quality chips */
|
||
.chips { display: flex; flex-wrap: wrap; gap: 7px; }
|
||
.chip {
|
||
font-size: 12px;
|
||
padding: 4px 11px;
|
||
border-radius: 999px;
|
||
background: var(--bg-highlight);
|
||
color: var(--text-primary);
|
||
font-weight: 550;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.chip.loss { color: #4bd08a; }
|
||
.chip .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||
|
||
/* source card */
|
||
.src {
|
||
background: var(--bg-elevated);
|
||
border-radius: 10px;
|
||
padding: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.src-lab {
|
||
font-size: 11px;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
color: var(--text-muted);
|
||
font-weight: 600;
|
||
}
|
||
.peer { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||
.av {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 9px;
|
||
flex: 0 0 auto;
|
||
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
|
||
display: grid;
|
||
place-items: center;
|
||
font-weight: 700;
|
||
font-size: 13px;
|
||
color: #04222f;
|
||
}
|
||
.who {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.buf { display: flex; flex-direction: column; gap: 5px; }
|
||
.buf .bar { height: 6px; border-radius: 3px; background: var(--bg-highlight); overflow: hidden; }
|
||
.buf .bar i {
|
||
display: block;
|
||
height: 100%;
|
||
border-radius: 3px;
|
||
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
|
||
}
|
||
.buf .meta {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
/* save action row */
|
||
.actions-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* tracklist */
|
||
.section { display: flex; flex-direction: column; gap: 10px; }
|
||
.lab {
|
||
font-size: 11px;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
color: var(--text-muted);
|
||
font-weight: 600;
|
||
}
|
||
.list { display: flex; flex-direction: column; }
|
||
.row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 7px 8px;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
text-align: left;
|
||
width: 100%;
|
||
}
|
||
.row:hover { background: var(--bg-highlight); }
|
||
.row .n {
|
||
width: 16px;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
font-variant-numeric: tabular-nums;
|
||
flex: 0 0 auto;
|
||
}
|
||
.row .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.row .du { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; flex: 0 0 auto; }
|
||
.row.now { color: var(--accent); font-weight: 600; }
|
||
.row.now .n { color: var(--accent); }
|
||
|
||
.eq { display: inline-flex; align-items: flex-end; gap: 1.5px; width: 14px; height: 11px; }
|
||
.eq b { width: 2.5px; background: var(--accent); border-radius: 1px; }
|
||
/* A stepped equalizer keeps the playing cue alive without asking Chromium
|
||
to repaint the glass panel and artist page on every display frame. */
|
||
.eq.active b { animation: eq 1.2s steps(1, end) infinite; }
|
||
.eq b:nth-child(1) { height: 40%; animation-delay: -0.2s; }
|
||
.eq b:nth-child(2) { height: 90%; }
|
||
.eq b:nth-child(3) { height: 60%; animation-delay: -0.4s; }
|
||
@keyframes eq { 0%, 100% { transform: scaleY(0.4); } 50% { transform: scaleY(1); } }
|
||
@media (prefers-reduced-motion: reduce) { .eq.active b { animation: none; } }
|
||
|
||
/* discovery: more-from-artist grid */
|
||
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||
/* min-width:0 lets both columns stay an equal 1fr; without it a long
|
||
(nowrap) title forces its column wider and shrinks the other. */
|
||
.mini { text-align: left; min-width: 0; }
|
||
.mini .art {
|
||
width: 100%;
|
||
aspect-ratio: 1;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
background: var(--bg-highlight);
|
||
}
|
||
.mini .art img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||
.mini:hover .art { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||
.mini .mt {
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
margin-top: 6px;
|
||
line-height: 1.25;
|
||
overflow: hidden;
|
||
display: -webkit-box;
|
||
-webkit-box-orient: vertical;
|
||
-webkit-line-clamp: 6;
|
||
}
|
||
.mini .my { font-size: 11.5px; color: var(--text-muted); text-transform: capitalize; }
|
||
|
||
/* discovery: fans also like — a fixed row of 5 (no horizontal scroll,
|
||
which always left one avatar half-cut at the edge). Explicit square
|
||
avatars (aspect-ratio inside a flex row gets squished to an oval);
|
||
space-between fits all five within the panel with no cutoff. */
|
||
.fans { display: flex; justify-content: space-evenly; padding: 4px 0; }
|
||
.fan { width: 52px; text-align: center; }
|
||
.fan .av {
|
||
width: 52px;
|
||
height: 52px;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
background: var(--bg-highlight);
|
||
}
|
||
.fan .av img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||
.fan:hover .av { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||
.fan .fn {
|
||
font-size: 11px;
|
||
margin-top: 7px;
|
||
color: var(--text-secondary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* discovery: about the artist */
|
||
.aboutcard {
|
||
display: block;
|
||
width: 100%;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
background: var(--bg-elevated);
|
||
}
|
||
/* Full square Deezer image, uncropped (square img in a square box), with a
|
||
tall bottom fade to the card colour so the photo melts into the text. */
|
||
.bandphoto {
|
||
display: block;
|
||
position: relative;
|
||
width: 100%;
|
||
aspect-ratio: 1;
|
||
text-align: left;
|
||
}
|
||
.bandphoto img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||
.bandphoto .ph {
|
||
width: 100%;
|
||
aspect-ratio: 1;
|
||
display: grid;
|
||
place-items: center;
|
||
color: var(--text-muted);
|
||
}
|
||
.bandphoto::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
background: linear-gradient(
|
||
to top,
|
||
var(--bg-elevated) 6%,
|
||
color-mix(in srgb, var(--bg-elevated) 55%, transparent) 22%,
|
||
transparent 48%
|
||
);
|
||
}
|
||
|
||
/* Name + bio ride up into the faded bottom of the photo, so the text
|
||
reads as emerging from the image. */
|
||
.about-text {
|
||
position: relative;
|
||
z-index: 2;
|
||
margin-top: -52px;
|
||
padding: 0 13px;
|
||
}
|
||
.about-text .nm {
|
||
display: block;
|
||
text-align: left;
|
||
font-size: 19px;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.75);
|
||
margin-bottom: 8px;
|
||
}
|
||
.about-text .nm:hover { text-decoration: underline; }
|
||
|
||
.bio { padding: 0 0 2px; }
|
||
.bio p {
|
||
margin: 0;
|
||
font-size: 12.5px;
|
||
line-height: 1.5;
|
||
color: var(--text-secondary);
|
||
}
|
||
.bio p.clamped {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 3;
|
||
line-clamp: 3;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
.bio .more {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--accent-hover);
|
||
}
|
||
.bio .more:hover { text-decoration: underline; }
|
||
|
||
.about-body { display: flex; gap: 18px; padding: 12px 13px 13px; }
|
||
.about-body .st { font-size: 11px; color: var(--text-muted); }
|
||
.about-body .st b {
|
||
display: block;
|
||
color: var(--text-primary);
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 60px 0;
|
||
color: var(--text-muted);
|
||
font-size: 13px;
|
||
}
|
||
</style>
|