Files
SoulStream/src/components/NowPlaying.svelte
T
KeelrLeek 5e2ad18269 the periscopes never built on this machine: bounded idle gate, and the fog rides the scroller's timeline instead of chasing it
THE HEADLINE, and it invalidates a lot of earlier reasoning about the glass
bar: all three periscopes were gated on an idle callback granting a >=15ms
slice, and with any frame continuously pending this 60Hz laptop caps
timeRemaining at ~13.5ms. The gate NEVER passed — traced hundreds of
consecutive refusals — so the fog never built, on any pane, all session. What
the bar had been showing all along was no fog at all: a thin tint over the raw
page. That is why Kyle read the top bar as "so light compared to the left and
right sidebar" (on album pages the raw page there is the banner, deliberately
bled up bright), and why the Now Playing blur "goes away whenever the next
track plays and its glitchy" — every track change clears that pane's fog for
rebuild, and the rebuild starved forever. The skip is now bounded: after eight
starved retries (~1-2s of honest waiting) it builds anyway. Same three lines in
GlassBar, Sidebar and NowPlaying — the pattern is copied, so a fix to one is
not a fix.

THE BAND'S TINT, once there was a fog to judge. The bar wore
rgba(20,20,24,0.28): a non-black colour that adds ~6 luma of its own light, at
a net 0.676 against the panes' 0.7075, so it read lighter than the panes by
construction. It now wears the panes' exact frost — pure black 0.35 over the
0.55 canvas — applied as .peri::after, because the bar's own background paints
UNDER .peri and cannot tint the fog (measured: an 0.85 background moved the
band 34.6 -> 33.5). Same material as the sidebar and Now Playing bands;
brightness then follows the wallpaper for all of them alike, which is why they
measure 20-36 across one screen and no absolute number could have matched.

THE FOG RIDES THE COMPOSITOR NOW. It chased scroll events on the main thread
and landed frames late ("the content behind the glass bar is delayed with
scrolling"). .pane-shell publishes the scroller as a named timeline
(--pane-scroll) and the clone animates against it, so the ride equals -scrollTop
with the main thread nowhere in the loop. Verified as a live ScrollTimeline
animation tracking scrollTop exactly. The rules live in global.css on purpose:
Svelte's -global- keyframes prefix stripping desynced animation-name from
@keyframes when they lived in the component, and getAnimations() showed it dead.
The old event sync stays as the no-timeline-support fallback.

THE SEARCH WASH reaches the top of the bar again. Its contract is that "the bar
frosts over colour rather than over black", but the frost veil dimmed the whole
fog and left a dark step where bright wash met the band; and the blur kernel at
the band's top edge sampled past the wash's own top edge into nothing, fading
the colour out over the first ~20px — the dark strip Kyle photographed twice.
The cloned wash is boosted to compensate for the veil exactly, and bled into the
fog's 48px overscan so the kernel always has colour to sample. The album banner
never showed this because its box already bleeds -70px above the page.

Judged on OS-level captures throughout (CopyFromScreen): CDP screenshots cannot
see the acrylic and led every wrong turn in this file's history. 98/98 tests.
2026-08-31 05:53:42 -05:00

3180 lines
129 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { untrack } from "svelte";
import {
player,
ui,
toggleNowPlaying,
getArt,
getCatalogArt,
jumpToQueueIndex,
peekNextIndex,
streamSong,
discovery,
loadDiscovery,
openCatalogArtist,
openCatalogAlbum,
openCurrentArtist,
openCurrentAlbum,
openCurrentSong,
seekPlayer,
libraryStore,
artBroken,
markArtBroken,
playerArtSource,
coverUpgrades,
resolveCovers,
setNowPlayingWidth,
NP_WIDTH_DEFAULT,
NP_WIDTH_MIN,
NP_WIDTH_MAX,
} from "../lib/state.svelte.js";
import Icon from "./Icon.svelte";
import SaveMenu from "./SaveMenu.svelte";
import LyricsPane from "./LyricsPane.svelte";
import PlaylistMenu from "./PlaylistMenu.svelte";
import * as api from "../lib/api.js";
import { floatScroll, markAppScroll, appScrollIdleFor } from "../lib/scrollbar.js";
import { splitDescription } from "../lib/editions.js";
import { accentFromImage } from "../lib/color.js";
import {
BAR_PITCH,
BAR_W,
waveLevels,
RISE_CADENCE_MS,
ARRIVE_MS,
ARRIVE_HOLD_MS,
PENDING_ALPHA,
} from "../lib/waveshape.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. */
async function openArtwork() {
const item = currentItem;
if (!item) return;
const shot = (src, local) => ({
src,
local,
title: item.title ?? "",
subtitle: item.sub ?? "",
});
if (item.art) {
ui.artwork = shot(item.art, true);
return;
}
const url = item.artUrl;
if (!url) return;
// The MASTER, the same thing the artist and album pages open — not the
// provider URL rewritten to 1000x1000, which is all playerArtSource
// can do on its own. This panel never ran the cover-upgrade pass those
// pages run over their grids, so the playing record's master had simply
// never been looked up and the viewer topped out at 1000px here while
// the very same record opened at full resolution from a page.
const known = coverUpgrades[url];
const opened = known ?? playerArtSource(item);
ui.artwork = shot(opened, false);
if (known) return;
// Resolve, then swap in place. Guarded on the viewer still showing
// exactly what we opened, so closing it — or opening a different
// record's art meanwhile — is never overwritten from under the user.
await resolveCovers([{ cover: url, title: albumTitle }], artistName);
const upgraded = coverUpgrades[url];
if (upgraded && upgraded !== opened && ui.artwork?.src === opened) {
ui.artwork = shot(upgraded, false);
}
}
// 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.
// Knowing the record is enough. It used to also require a queue longer than
// one, which meant the album section could not appear until the background
// fill had sourced a SECOND track — so playing a single track showed
// nothing, then a partial list.
const albumMode = $derived(!!player.catalogRef.albumId);
// Artist / album split for the separate, clickable identity lines.
// Per field, like the player bar's: the item's own artist/album win when
// present (the continuation fill writes them beside a bare-artist `sub`),
// and each half falls back to the `sub` split on its own — a library play
// carries only `artist`, with the record's name living inside `sub`.
const metaParts = $derived.by(() => {
const sub = player.track?.sub ?? "";
const split = sub.includes(" — ")
? sub.split(" — ").map((s) => s.trim())
: [sub.trim(), ""];
return [
currentItem?.artist ?? split[0] ?? "",
currentItem?.album ?? split[1] ?? "",
];
});
// The artist everything else in this panel is ABOUT — discovery, the
// chip, "More from", the in-library count, the Save action.
//
// It used to split `sub` on its own, which is only "artist — album" for
// album playback. A playlist item's `sub` is the PLAYLIST's name, so
// playing out of one loaded the whole Artist and About panes for an
// artist called "Modern Electro Ghetto Tech Mix" while the identity
// lines two rows above correctly read Harka. The item's own `artist`
// field is the answer, and `metaParts` already applies that rule.
const artistName = $derived(metaParts[0]);
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;
});
// The panel used to slide off-screen when closed, so its body was torn down
// after the slide to release the waveform work and the large art data URL.
// It no longer leaves — closing collapses it to the rail, which shows the
// artwork and the progress line — so the art must stay loaded either way.
// Only the expanded body is conditional now.
const panelContentVisible = $derived(ui.nowPlaying);
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(() => {
// The record playing now, by the same per-field rule — `sub` is the
// playlist's name, not "artist — album", whenever one is playing.
const here = norm(metaParts[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);
});
// Eight, not four: as name-sized pills that wrap they cost a fraction of
// the height the fixed row of circles did, so the section can carry twice
// the artists in less space.
const relatedArtists = $derived(discovery.related.slice(0, 8));
// 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) : "";
}
/* ── The full discography, filed ──────────────────────────────────────
"Show all releases" used to be one flat date-sorted grid, so a
sixty-eight release artist arrived as an undifferentiated wall. These
are the artist page's own ten categories in its own order, empty ones
dropped, exactly as its TAB_DEFS does.
⚠ The page classifies with evidence this panel does not have: the album
description hints and the MusicBrainz type map, both built by the artist
page's background harvest. Here it is the flags the release itself
carries. A record whose kind is only knowable from its description (a
compilation innocently titled "Death to False Metal") will file as an
album here and as a compilation there until those hints are routed
through — a known, deliberate gap, not a bug to rediscover. */
const RELEASE_CATEGORIES = [
["upcoming", "Upcoming"],
["album", "Albums"],
["mixtape", "Mixtapes"],
["ep", "EPs"],
["single", "Singles"],
["live", "Live"],
["compilation", "Compilations"],
["soundtrack", "Soundtracks"],
["remix", "Remixes"],
["other", "Other"],
];
const isFutureRelease = (date) => {
if (!date) return true; // undated announcements are not yet released
const at = Date.parse(date);
return Number.isNaN(at) || at > Date.now();
};
function releaseCategory(album, byId, depth = 0) {
// An edition files WITH its base release, one hop, like the page.
if (depth < 2 && album.edition_group != null && album.id !== album.edition_group) {
const base = byId.get(album.edition_group);
if (base) return releaseCategory(base, byId, depth + 1);
}
const type = (album.record_type ?? "").toLowerCase();
if (type === "album" && isFutureRelease(album.release_date)) return "upcoming";
if (album.is_mixtape) return "mixtape";
if (album.is_soundtrack) return "soundtrack";
if (album.is_live) return "live";
const title = (album.title ?? "").toLowerCase();
if (/\bremix(es)?\b/.test(title)) return "remix";
if (/\bgreatest hits\b|\bbest of\b|\banthology\b/.test(title)) return "compilation";
if (/\bsessions?\b|\bdemos?\b|\bpractice\b|\brehearsal\b/.test(title)) return "other";
return ["album", "ep", "single"].includes(type) ? type : "other";
}
/** Sections of dense rows, each row flagged with the year label it should
* carry. The label is printed only where the year CHANGES going down, so a
* run of singles from one year reads as one dated block instead of
* repeating itself on every line. */
const releaseSections = $derived.by(() => {
const all = discovery.albums ?? [];
if (!all.length) return [];
const byId = new Map(all.map((a) => [a.id, a]));
// Editions fold into their base, so a record's pressings are one row.
const folded = all.filter((a) => a.edition_group == null || a.id === a.edition_group);
const buckets = new Map(RELEASE_CATEGORIES.map(([key]) => [key, []]));
for (const album of folded) {
buckets.get(releaseCategory(album, byId))?.push(album);
}
return RELEASE_CATEGORIES.flatMap(([key, label]) => {
const items = (buckets.get(key) ?? []).sort((a, b) =>
(b.release_date ?? "").localeCompare(a.release_date ?? ""),
);
if (!items.length) return [];
let previous = null;
const rows = items.map((album) => {
const year = key === "upcoming" ? "Upcoming" : albumYear(album) || "—";
// A divider that only repeats the section it sits under is
// noise: Upcoming headed its rows with the word "Upcoming"
// again, directly below a heading already reading Upcoming.
const heading =
year === previous || year.toLowerCase() === label.toLowerCase()
? null
: year;
previous = year;
return { album, heading };
});
return [{ key, label, count: items.length, rows }];
});
});
/** The artwork this row is already painting, handed to the album page so
* its skeleton opens with the record's cover rather than a grey block. */
function albumSeed(album) {
if (!album?.cover || artBroken[album.cover]) return null;
return {
cover: album.cover,
title: album.title ?? null,
artist: album.artist ?? discovery?.name ?? null,
kind: album.record_type ?? null,
tracks: album.track_count ?? null,
};
}
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(() => {
// Loaded whenever something is playing, collapsed or not: the rail
// shows this same artwork as its cover and as the bleed behind its
// glass bar, so gating it on the panel being open blanked both.
const item = currentItem;
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);
});
// ── the head periscope (dark glass only) — the sidebar's pattern ────
// Under glass the head's backdrop-filter black-flattens the moment the
// wash scrolls out from under it (frost backdrop = opaque black slab;
// see Sidebar.svelte for the measurements). So under glass: the head
// drops backdrop-filter, the scroller's originals are masked out of the
// head band, and a cloneNode copy under a NORMAL blur paints them back
// as fog — wash included, so the head keeps its record tint at rest and
// the colors still seep and shift as you scroll. Canvas children clone
// blank (the waveform leaves no fog) — accepted.
let npGlassy = $state(document.documentElement.classList.contains("glass"));
let npScrollEl = $state(null);
let npPeriEl = $state(null);
$effect(() => {
const mo = new MutationObserver(() => {
npGlassy = document.documentElement.classList.contains("glass");
});
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => mo.disconnect();
});
$effect(() => {
if (!npGlassy || !npScrollEl || !npPeriEl) return;
const view = npScrollEl;
const peri = npPeriEl;
let pending = 0;
let recloneGen = 0;
let lastRecloneMs = 4;
// Consecutive idle callbacks refused for a too-thin slice — see the
// bounded gate in queueReclone.
let thinSlices = 0;
const sync = () => {
markAppScroll();
const c = peri.firstElementChild?.firstElementChild;
if (c) c.style.transform = `translateY(${-view.scrollTop}px)`;
};
const reclone = () => {
// The blur lives on an OVERSCAN wrapper extending 48px past the
// clip on every side: a gaussian fades toward transparent at its
// element's bounds (~2× the radius), and with the bounds out
// there the falloff is clipped away instead of vignetting the
// band edges (Kyle's catch; the wash img does the same job with
// scale(1.08)). The clone is pinned back inside by margins at
// its measured width, so layout and alignment are unchanged.
// height 142 = overscan + band + overscan: the filter rasters
// this box, never the full cloned scroller.
// BUILD, detached, measured — the duration feeds the next
// debounce delay (see GlassBar; the old node census existed
// only to guess it).
const t0 = performance.now();
const gen = ++recloneGen;
const wrap = document.createElement("div");
wrap.style.cssText =
"position:absolute;top:-48px;left:-48px;right:-48px;height:142px;" +
"overflow:hidden;filter:blur(17px) saturate(1.3);will-change:transform;" +
"pointer-events:none;";
const c = view.cloneNode(true);
c.style.position = "relative";
c.style.margin = "48px 0 0 48px";
c.style.width = view.clientWidth + "px";
c.style.height = "auto";
c.style.overflow = "visible";
c.querySelectorAll("canvas").forEach((n) => n.remove());
// The overscan clears the FILTER's falloff, but the blur still
// mixes with emptiness wherever the CONTENT ends at the clip
// line — and the wash is the one full-bleed layer, so its edges
// vignetted the band (Kyle's second catch). Stretch the cloned
// wash into the overscan margins so the band edge samples wash
// against wash. Rows keep their own padding; nothing else
// reaches the edge.
const bleed = c.querySelector(".scroll-bleed");
if (bleed) {
bleed.style.top = "-48px";
bleed.style.left = "-48px";
bleed.style.right = "-48px";
}
wrap.appendChild(c);
lastRecloneMs = performance.now() - t0;
// ATTACH in its own frame (see GlassBar): the style+layout
// flush of the whole clone never shares a task with its
// construction; stale builds and resumed scrolling requeue.
requestAnimationFrame(() => {
if (gen !== recloneGen) return;
if (!appScrollIdleFor(1000)) return queueReclone();
const fresh = !peri.firstElementChild;
if (fresh) {
wrap.style.opacity = "0";
wrap.style.transition = "opacity 160ms ease";
}
peri.replaceChildren(wrap);
if (fresh) requestAnimationFrame(() => (wrap.style.opacity = "1"));
sync();
});
};
// Trailing debounce; no `class` in the filter — hover flips were
// re-cloning per mouse move, and the fog doesn't read hover states.
// Delay scales with pane size and the clone runs at idle (see the
// GlassBar note: big pages were janking frames with reclone tasks).
const queueReclone = () => {
clearTimeout(pending);
const delay = Math.min(600, 120 + lastRecloneMs * 3);
pending = setTimeout(() => {
// Never clone mid-scroll, never while hidden (see GlassBar).
// APP-WIDE scroll recency (scrollbar.js note), a full second
// of it, re-checked inside the idle callback with no forcing
// timeout, and the callback yields on a thin idle slice:
// this pane's playback churn (queue rows, lyric lines, art)
// used to queue a full-pane clone gated only on the NP
// scroller's own last-scroll time, so it landed exactly when
// the user paused scrolling the CONTENT pane.
if (document.hidden) return;
if (!appScrollIdleFor(1000)) return queueReclone();
if ("requestIdleCallback" in window) {
requestIdleCallback((deadline) => {
if (!appScrollIdleFor(1000)) return queueReclone();
// Bounded, same fix as GlassBar (2026-08-30): with a
// frame continuously pending, this machine's idle
// slices cap at ~13.5ms and a hard <15 never passes —
// this pane's fog died at the first content churn
// (every track change) and never rebuilt.
if (deadline && deadline.timeRemaining() < 15 && ++thinSlices < 8) {
return queueReclone();
}
thinSlices = 0;
reclone();
});
} else {
reclone();
}
}, delay);
};
const mo = new MutationObserver((records) => {
for (const r of records) {
if (r.target === view && (r.addedNodes.length || r.removedNodes.length)) {
// Invalidate any built-but-unattached clone (GlassBar note).
recloneGen++;
peri.replaceChildren();
break;
}
}
queueReclone();
});
mo.observe(view, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["src"],
});
const ro = new ResizeObserver(queueReclone);
ro.observe(view);
const onVis = () => {
if (!document.hidden) queueReclone();
};
document.addEventListener("visibilitychange", onVis);
view.addEventListener("scroll", sync, { passive: true });
reclone();
return () => {
recloneGen++;
mo.disconnect();
ro.disconnect();
document.removeEventListener("visibilitychange", onVis);
view.removeEventListener("scroll", sync);
clearTimeout(pending);
peri.replaceChildren();
};
});
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,
);
// The COUNT is derived from the panel's width so the lane reads at one
// density everywhere. It used to be a flat 78 bars on a 2px pitch — a
// 310px block that overflowed the 272px panel and left a third of the
// 560px one empty, which is precisely what looked wrong at both ends.
// One bar per BAR_PITCH px instead; the fold and shaping live in
// waveshape.js, shared with the player bar so the two lanes cannot drift.
// .np-scroll's horizontal padding, both sides — the lane is what is left.
const LANE_PAD = 28;
const BARS = $derived(
Math.max(24, Math.floor((ui.nowPlayingWidth - LANE_PAD + BAR_W) / BAR_PITCH)),
);
// The rise frontier is the WAVEFORM's own coverage — the audio actually
// decoded, reported by the backend with the peaks — never the live byte
// counter, whose fresher number stretched the drawn shape between
// refreshes. A partial waveform folds across exactly the covered fraction
// of the lane; the rest holds its place as pending dots (nulls below).
const railCoverage = $derived(Math.max(0, Math.min(1, player.waveformCoverage)));
const railCovered = $derived(Math.max(1, Math.floor(BARS * railCoverage)));
const bars = $derived.by(() =>
waveLevels(player.waveform, railCoverage < 1 ? railCovered : BARS),
);
// Bar i always maps to slice i/BARS of the whole track, covered or not.
const playedBars = $derived((playedPct / 100) * BARS);
/** The lane is 38px of the panel's vertical rhythm whether or not peaks
* have arrived yet. `player.waveform` is emptied the instant a track
* changes, and a partial-FLAC stream decode can take seconds to refill it,
* so unmounting the lane collapsed the times, the chips and the whole
* segmented control below it and then shoved them back down — a jolt on
* every track change. It holds its space as a flat baseline instead.
*
* Same bar COUNT either way, so peaks arriving restyle the existing
* elements rather than replacing them. */
const FLAT_BARS = $derived(Array(BARS).fill(0));
const wavePending = $derived(bars.length === 0);
const waveBars = $derived.by(() => {
if (wavePending) {
// Mid-hold the outgoing shape is still the thing on screen.
if (railHolding && lastRailBars?.length) {
return lastRailBars.length >= BARS
? lastRailBars
: [...lastRailBars, ...Array(BARS - lastRailBars.length).fill(null)];
}
return FLAT_BARS;
}
if (bars.length >= BARS) return bars;
return [...bars, ...Array(BARS - bars.length).fill(null)];
});
// Streaming rise — the playbar's S2, mirrored: bars stand up on the shared
// RISE_CADENCE_MS rhythm behind the download instead of at whatever rate
// bytes arrive; the CSS `rising` transition is the rail's half of RISE_MS.
// Reset when the queue item changes.
let railRevealed = $state(0);
let railRiseKey = null;
$effect(() => {
const key = currentItem ? `${currentItem.path ?? currentItem.title}` : null;
if (key !== railRiseKey) {
railRiseKey = key;
railRevealed = 0;
}
});
// Rising while the waveform is still partial, and until the queue stands
// the last chunk up. A cached replay or a local file arrives with
// coverage 1 on its FIRST decode and never rises — it pops in complete.
const railRising = $derived(
player.track?.source === "stream" &&
(railCoverage < 1 || (railRevealed > 0 && railRevealed < railCovered)),
);
const railRevealDone = $derived(!railRising || railRevealed >= railCovered);
$effect(() => {
if (railRevealDone) return;
const timer = setInterval(() => {
// Untracked reads: the interval IS the clock, not the reactivity.
// Played bars are never hidden behind the queue.
railRevealed = Math.min(
Math.max(railRevealed + 1, Math.ceil(playedBars)),
railCovered,
);
}, RISE_CADENCE_MS);
return () => clearInterval(timer);
});
// Track-change hold — the playbar's ARRIVE_HOLD_MS, mirrored: the
// outgoing bars stay up briefly so a quick arrival transitions
// shape→shape instead of dropping to the floor first. The hold renders in
// full-opacity muted (the new track owns the transport, nothing played);
// a slow load falls to the pending dots when the timer expires.
let lastRailBars = null;
let railHolding = $state(false);
let railHoldTimer = null;
$effect(() => {
if (bars.length > 0) {
lastRailBars = bars;
railHolding = false;
clearTimeout(railHoldTimer);
} else if (player.wavePending && lastRailBars?.length) {
railHolding = true;
clearTimeout(railHoldTimer);
railHoldTimer = setTimeout(() => (railHolding = false), ARRIVE_HOLD_MS);
}
});
$effect(() => () => clearTimeout(railHoldTimer));
function railBarHeight(v, i) {
if (v == null) return 0;
if (wavePending) return railHolding ? barHeight(v) : 0;
if (railRising && i >= railRevealed) return 0;
return barHeight(v);
}
function railBarDim(v, i) {
if (v == null) return true;
if (wavePending) return !railHolding;
return railRising && i >= railRevealed;
}
// The arrival morph — the playbar's M2, in CSS: when a COMPLETE waveform
// lands (local track, cached stream, a stream's final decode), the
// `arriving` class puts an ARRIVE_MS height transition on every bar, so
// the whole lane eases from whatever it showed into the new shape at
// once. Svelte reuses the <i> nodes across the swap, which is what makes
// the transition run from the old inline heights.
let railArriving = $state(false);
let railArrivePeaks = null;
let railArriveTimer = null;
$effect(() => {
const w = player.waveform;
if (w.length > 0 && railCoverage >= 1 && w !== railArrivePeaks) {
railArrivePeaks = w;
railArriving = true;
clearTimeout(railArriveTimer);
railArriveTimer = setTimeout(() => (railArriving = false), ARRIVE_MS + 80);
} else if (w.length === 0) {
railArrivePeaks = null;
}
});
$effect(() => () => clearTimeout(railArriveTimer));
/* `bars` is already shaped 0..1 by waveshape.js (which also explains, with
measurements, why the old gain-and-clamp rule here rendered half the
lane at one height and had to be propped up with per-bar noise). This
only maps that level onto the rail's vertical rhythm: a 14% baseline,
86% ceiling — headroom above the bars is part of the panel's look. */
function barHeight(level) {
return 14 + level * 72;
}
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`;
}
// ---- transfer status ---------------------------------------------------
// One line for the glass header, in both the expanded panel and the rail's
// bar. Deliberately absent for a library track: a local file has no peer
// and no transfer, and showing zeroes for it would read as a stalled one.
//
// There is no "queued at peer" state here. The download path reports a
// queue place; the streaming path does not, so the panel cannot honestly
// claim to know one.
const transferring = $derived(
isStream && player.streamTotal > 0 && player.streamReceived < player.streamTotal,
);
const cached = $derived(
isStream && player.streamTotal > 0 && player.streamReceived >= player.streamTotal,
);
/** Bytes/sec, only once measured — see player.streamSpeed. */
const rateText = $derived(
player.streamSpeed > 0 ? `${(player.streamSpeed / 1048576).toFixed(1)} MB/s` : null,
);
const etaText = $derived.by(() => {
if (!transferring || player.streamSpeed <= 0) return null;
const secs = (player.streamTotal - player.streamReceived) / player.streamSpeed;
if (!isFinite(secs) || secs < 1) return null;
return secs >= 60
? `${Math.floor(secs / 60)}m ${Math.round(secs % 60)}s left`
: `${Math.round(secs)}s left`;
});
const peerName = $derived(currentItem?.stream?.username ?? null);
const statusLine = $derived.by(() => {
if (!isStream) return null;
if (cached) return `Cached · ${fmtSize(player.streamTotal) ?? ""}`.trim();
if (transferring) {
return [peerName, `${Math.round(bufferPct)}%`, rateText, etaText]
.filter(Boolean)
.join(" · ");
}
return peerName ? `Streaming from ${peerName}` : null;
});
/** What the status line is ABOUT, as opposed to what it currently reads.
* The header's entry animation is keyed on this rather than on the text:
* while transferring, the text carries a percentage, a rate and an ETA, so
* keying on it replayed the 300ms slide on every progress event — several
* times a second — and the header flashed. Peer and transfer state are what
* an arrival should announce, and they change a handful of times a track. */
const statusPhase = $derived(
!isStream
? "none"
: cached
? "cached"
: transferring
? `transfer:${peerName ?? ""}`
: `open:${peerName ?? ""}`,
);
/** The hairline has no percentage to show until bytes start arriving. */
const hairlineSeeking = $derived(isStream && player.streamTotal === 0);
// The hover-revealed transfer popover is gone. It showed the peer, the
// rate, a percentage and a second progress bar — every one of which the
// status line and the hairline beside it already carry — so it was a
// duplicate that had to be hovered for. What was unique to it now lives in
// the bar: the peer as a monogram, and the save action. Deleted with it:
// the reveal action, its 250ms close grace, and the `.np-statuswrap` hit
// area, all of which existed only to make a popover reachable across the
// 25px gap between the status line and itself.
// ---- drag to resize ----------------------------------------------------
// Pointer capture, like the player bar's scrubber: the pointer keeps
// delivering moves after it leaves the 9px grip, which a mousemove
// listener on the element alone would not.
let gripEl = $state(null);
// On `ui` rather than local, because App.svelte's grid column has to leave
// the ease at the same instant this panel does.
const dragging = $derived(ui.nowPlayingDragging);
let dragRaf = 0;
let dragStartX = 0;
let dragStartW = 0;
let pendingWidth = 0;
/** Shown while dragging so the number being chosen is visible. */
const atDefault = $derived(ui.nowPlayingWidth === NP_WIDTH_DEFAULT);
function onGripDown(e) {
ui.nowPlayingDragging = true;
dragStartX = e.clientX;
dragStartW = ui.nowPlayingWidth;
gripEl?.setPointerCapture?.(e.pointerId);
e.preventDefault();
}
function onGripMove(e) {
if (!dragging) return;
// The panel's left edge moves left to widen, so the delta is inverted.
let w = dragStartW - (e.clientX - dragStartX);
w = Math.max(NP_WIDTH_MIN, Math.min(NP_WIDTH_MAX, w));
// A 10px magnet at the default, so getting back to stock is easy
// without the drag feeling sticky anywhere else.
if (Math.abs(w - NP_WIDTH_DEFAULT) < 10) w = NP_WIDTH_DEFAULT;
pendingWidth = w;
if (dragRaf) return;
dragRaf = requestAnimationFrame(() => {
dragRaf = 0;
setNowPlayingWidth(pendingWidth);
});
}
/** Ends the drag. Bound to lostpointercapture as well as pointerup and
* pointercancel: a release the grip never sees — let go outside the window,
* the panel re-rendering mid-drag, the webview cancelling the gesture —
* would otherwise leave the app root pinned at `transition: none` and the
* collapse animation dead for the rest of the session. However capture
* ends, lostpointercapture fires, so the flag cannot outlive the gesture. */
function onGripUp(e) {
if (!dragging) return;
ui.nowPlayingDragging = false;
if (dragRaf) {
cancelAnimationFrame(dragRaf);
dragRaf = 0;
setNowPlayingWidth(pendingWidth);
}
// Guarded: releasing a pointer this element does not hold throws, and
// lostpointercapture arrives when it has already been given up.
if (e?.pointerId != null && gripEl?.hasPointerCapture?.(e.pointerId)) {
gripEl.releasePointerCapture(e.pointerId);
}
}
function resetWidth() {
setNowPlayingWidth(NP_WIDTH_DEFAULT);
}
// ---- rail ---------------------------------------------------------------
const queueLeft = $derived(Math.max(0, player.queue.length - player.queueIndex - 1));
// ---- the record this queue belongs to -----------------------------------
const albumTitle = $derived(metaParts[1] || currentItem?.album || "");
// Editorial description / label / genre for the About pane — the same
// `catalog_album_info` the album page credits, so the two never disagree
// about a record. Keyed on artist+album, and only fetched while expanded:
// the rail has nowhere to show it.
let albumInfo = $state(null);
let albumInfoKey = null;
$effect(() => {
const artist = artistName;
const title = albumTitle;
const id = discovery.artistId;
const key = artist && title ? `${artist}::${title}` : null;
if (!ui.nowPlaying || !key) return;
if (key === albumInfoKey) return;
albumInfoKey = key;
albumInfo = null;
let stale = false;
api.catalogAlbumInfo(id ?? null, artist, title)
.then((info) => {
if (!stale && albumInfoKey === key) albumInfo = info ?? null;
})
.catch(() => {});
return () => {
stale = true;
};
});
/** This record's row in the local library, when it is owned. */
const localAlbum = $derived.by(() => {
const a = norm(artistName);
const t = norm(albumTitle);
if (!a || !t) return null;
return libraryStore.albums.find((x) => norm(x.artist) === a && norm(x.title) === t) ?? null;
});
// Which of this record's songs are already saved locally, ticked in the
// queue. The library keeps albums rather than songs, so the owned tracklist
// has to be read from the matching local album — the album page does the
// same thing for the same reason.
let libraryTicks = $state(new Set());
let ticksFor = null;
$effect(() => {
const id = localAlbum?.id ?? null;
if (id === ticksFor) return;
ticksFor = id;
libraryTicks = new Set();
if (id == null) return;
let stale = false;
api.albumTracks(id)
.then((rows) => {
if (!stale && ticksFor === id) {
libraryTicks = new Set(rows.map((r) => norm(r.title || "")));
}
})
.catch(() => {});
return () => {
stale = true;
};
});
// The record's catalog payload, when the queue came from one. It supplies
// two things nothing else here has: per-track Last.fm listeners (queue
// items carry only a provider `trackId`) and the release date / track count
// / runtime the About pane's credits block needs. A local album has no
// catalog id, so both simply do not appear.
let catalogPage = $state(null);
let catalogFor = null;
$effect(() => {
const albumId = player.catalogRef.albumId ?? null;
if (albumId === catalogFor) return;
catalogFor = albumId;
catalogPage = null;
if (albumId == null || !ui.nowPlaying) return;
let stale = false;
api.catalogAlbum(albumId)
.then((page) => {
if (!stale && catalogFor === albumId) catalogPage = page ?? null;
})
.catch(() => {});
return () => {
stale = true;
};
});
const listenersById = $derived.by(() => {
const map = new Map();
for (const t of catalogPage?.tracks ?? []) {
if (t.listeners != null) map.set(String(t.id), t.listeners);
}
return map;
});
const showListeners = $derived(listenersById.size > 0 && ui.nowPlayingWidth >= 396);
const listenersOf = (item) =>
item?.trackId != null ? (listenersById.get(String(item.trackId)) ?? null) : null;
/** The RECORD's tracklist, not the queue's progress through it.
*
* This section rendered `player.queue` directly, and the queue is grown one
* track at a time by the album fill — each row appeared only once its
* Soulseek source had resolved. So a nine-track record read "5 tracks"
* while you watched the rest arrive one by one. `catalogPage.tracks` is the
* whole record in a single fetch, and it now lands early (see the
* `catalogRef` assignment in `streamSong`), so the list is complete
* immediately and the queue is demoted to what it actually is: playback
* state layered on top.
*
* A row with no queue item yet is still playable — clicking it resolves a
* source on demand, exactly as the fill would have. Falls back to the queue
* whenever the record is unknown (a local album, a plain queue). */
const albumRows = $derived.by(() => {
const queue = player.queue ?? [];
const tracks = catalogPage?.tracks ?? [];
if (!albumMode || !tracks.length) {
return queue.map((item, index) => ({
key: `q${index}`,
title: item.title,
duration: item.duration ?? null,
listeners: listenersOf(item),
queueIndex: index,
trackId: item.trackId ?? null,
}));
}
// Match by provider id first; a title match is the fallback for items
// queued before an id was known.
const byTrackId = new Map();
const byTitle = new Map();
queue.forEach((item, index) => {
if (item.trackId != null) byTrackId.set(String(item.trackId), index);
const key = norm(item.title);
if (key && !byTitle.has(key)) byTitle.set(key, index);
});
return tracks.map((t, i) => ({
key: `t${t.id ?? i}`,
title: t.title,
duration: t.duration_secs ?? t.duration ?? null,
listeners: t.listeners ?? null,
queueIndex:
(t.id != null ? byTrackId.get(String(t.id)) : undefined)
?? byTitle.get(norm(t.title))
?? -1,
trackId: t.id ?? null,
}));
});
/** Play a row. Already sourced → jump to it; not yet → resolve it now. */
function playAlbumRow(row) {
if (row.queueIndex >= 0) {
jumpToQueueIndex(row.queueIndex);
return;
}
streamSong(artistName, row.title, {
cover: catalogPage?.album?.cover ?? currentItem?.artUrl ?? null,
trackId: row.trackId,
albumId: player.catalogRef.albumId,
album: albumTitle || null,
expectSecs: row.duration ?? null,
});
}
// ---- About pane facts ---------------------------------------------------
// Everything below is re-laid-out from data already in hand — the record's
// catalog payload, its editorial info, and the artist's own releases. None
// of it costs a request the panel was not already making.
/** "My Bloody Valentine · 1991 · 11 tracks", under the record's name. */
const recordSub = $derived.by(() => {
const year = catalogPage?.album?.release_date?.slice(0, 4) ?? null;
const n = catalogPage?.tracks?.length ?? 0;
return [artistName, year, n ? `${n} track${n === 1 ? "" : "s"}` : null]
.filter(Boolean)
.join(" · ");
});
/** Lede + body, from the shared splitter — the album page and the artist
* page's expander promote their descriptions with the same function. */
const descParts = $derived(splitDescription(albumInfo?.description));
const descLede = $derived(descParts.lede);
const descRest = $derived(descParts.rest);
/** Year / tracks / runtime / listeners, from the record's own payload. */
const recordTiles = $derived.by(() => {
const tiles = [];
const year = catalogPage?.album?.release_date?.slice(0, 4);
if (year) tiles.push({ label: "Released", value: year });
const n = catalogPage?.tracks?.length ?? 0;
if (n) tiles.push({ label: "Tracks", value: String(n) });
const secs = (catalogPage?.tracks ?? []).reduce(
(sum, t) => sum + (t.duration_secs ?? t.duration ?? 0),
0,
);
if (secs) {
const mins = Math.round(secs / 60);
tiles.push({
label: "Runtime",
value: mins >= 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`,
});
}
const listeners = catalogPage?.album_listeners ?? null;
if (listeners) tiles.push({ label: "Listeners", value: fmtListeners(listeners) });
return tiles;
});
/** "3 November 1991" for the pill row. */
const releasedOn = $derived.by(() => {
const value = catalogPage?.album?.release_date;
if (!value) return "";
const date = new Date(value);
return isNaN(date)
? value
: date.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
});
/** "1988–2013" — the span of the artist's own releases, nothing fetched. */
const activeYears = $derived.by(() => {
const years = (discovery.albums ?? [])
.map((a) => Number(a.release_date?.slice(0, 4)))
.filter((y) => Number.isFinite(y) && y > 1900);
if (!years.length) return "";
const first = Math.min(...years);
const last = Math.max(...years);
if (first === last) return String(first);
return last >= new Date().getFullYear() - 1 ? `${first}–present` : `${first}–${last}`;
});
// Width-responsive: the pill's stats spread rather than bunch, and the
// release grid gains a column, once there is room for them.
const statsSpread = $derived(ui.nowPlayingWidth >= 372);
const releaseCols = $derived(ui.nowPlayingWidth >= 430 ? 3 : 2);
/** The artist page's own social tints, so the chips match it exactly. */
const SOCIAL_META = {
website: { icon: "globe", label: "Official site", 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" },
};
let showAllReleases = $state(false);
let descExpanded = $state(false);
$effect(() => {
// A new record collapses both long texts and the release grid again.
void albumInfoKey;
showAllReleases = false;
descExpanded = false;
});
const releaseList = $derived(showAllReleases ? discovery.albums : moreAlbums);
/* Hover highlight in the RECORD's own colour rather than the playing
* track's. Sampling is lazy — only the card actually under the pointer —
* and memoised per cover URL, so a grid of sixty-eight releases costs
* nothing until you point at one. Until a colour lands the card falls back
* to the global accent, so there is no flash of "no highlight". */
const albumAccents = new Map(); // cover URL -> hex, or null once sampled dry
const accentPending = new Set();
async function tintFromCover(event, album) {
const cover = album?.cover;
if (!cover) return;
const el = event.currentTarget;
if (albumAccents.has(cover)) {
const known = albumAccents.get(cover);
if (known) el.style.setProperty("--album-accent", known);
return;
}
if (accentPending.has(cover)) return;
accentPending.add(cover);
try {
const dataUrl = await getCatalogArt(cover);
const colors = dataUrl ? await accentFromImage(dataUrl) : null;
// The brighter of the pair: this is a 2px line over artwork, and
// the resting accent disappears against a dark cover.
const hex = colors?.hover ?? colors?.accent ?? null;
albumAccents.set(cover, hex);
if (hex) el.style.setProperty("--album-accent", hex);
} catch {
albumAccents.set(cover, null);
} finally {
accentPending.delete(cover);
}
}
// What actually plays next — shuffle included: peekNextIndex reads the
// predetermined order, so this card, the preloader and the advance all
// name the same track.
const upNextIndex = $derived(peekNextIndex());
const upNext = $derived(upNextIndex >= 0 ? (player.queue[upNextIndex] ?? null) : null);
// The NEXT track's own artwork — the card wore the CURRENT track's cover
// for a while, which read as "The Medic" having the Jets to Brazil sleeve.
let upNextArt = $state(null);
$effect(() => {
const item = upNext;
upNextArt = null;
if (!item) return;
let active = true;
if (item.art) {
getArt(item.art, 96).then((u) => active && (upNextArt = u)).catch(() => {});
} else if (item.artUrl) {
getCatalogArt(playerArtSource(item)).then((u) => active && (upNextArt = u)).catch(() => {});
}
return () => (active = false);
});
const fmtListeners = (n) => (n == null ? null : n.toLocaleString());
// ---- the waveform, as a scrubber ---------------------------------------
// It was decorative — a picture of the song with no way to move through it,
// while the player bar carried the only scrubber. Same seek semantics as
// that one (75ms throttle while dragging, exact seek on release) so the two
// cannot disagree about where a click lands.
let waveEl = $state(null);
// $state, not a plain let: it drives `class:scrubbing` in the markup, and
// without it that class never applied while dragging (the compiler warns
// on every build — non_reactive_update).
let waveDragging = $state(false);
let lastWaveSeekAt = 0;
function waveTarget(e) {
if (!player.duration || !waveEl) return null;
const rect = waveEl.getBoundingClientRect();
const f = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
return f * player.duration;
}
function waveDown(e) {
const target = waveTarget(e);
if (target == null) return;
waveDragging = true;
player.scrubbing = true;
waveEl.setPointerCapture(e.pointerId);
player.position = target;
e.preventDefault();
}
function waveMove(e) {
if (!waveDragging) return;
const target = waveTarget(e);
if (target == null) return;
player.position = target; // instant feedback; the seek is throttled
const now = performance.now();
if (now - lastWaveSeekAt >= 75) {
lastWaveSeekAt = now;
seekPlayer(target);
}
}
function waveUp(e) {
if (!waveDragging) return;
waveDragging = false;
player.scrubbing = false;
const target = waveTarget(e);
if (target != null) seekPlayer(target);
try {
waveEl.releasePointerCapture(e.pointerId);
} catch {
// The pointer may already be gone; the seek above still stands.
}
}
// ---- panes --------------------------------------------------------------
// The context below the record is three shallow panes rather than one long
// scroll. Lyrics sits between Queue and Artist when enabled — it has no
// source yet and shows a placeholder.
const PANE_LABEL = { queue: "Queue", lyrics: "Lyrics", artist: "Artist", about: "About" };
const panes = ["queue", "lyrics", "artist", "about"];
let pane = $state("queue");
/** Which way the incoming pane slides in from, so a switch reads as travel
* rather than a flicker — forward comes from the right, back from the
* left, the same rule the glass bar's breadcrumb uses. */
let paneDir = $state(10);
function selectPane(next) {
if (next === pane) return;
paneDir = panes.indexOf(next) >= panes.indexOf(pane) ? 10 : -10;
pane = next;
// The scroll position is deliberately NOT reset: `.np-scroll` survives
// the pane swap, so the browser keeps its offset and only clamps it if
// the arriving pane is shorter. The sticky control above means you can
// always switch back from wherever that leaves you.
}
/** A rail dot both expands the panel and lands on the pane it names. */
function openPane(name) {
selectPane(name);
if (!ui.nowPlaying) toggleNowPlaying();
}
</script>
<!-- The artist as one pill: portrait, name, and the three figures the album
hero already states this way. It is a block-level button so it tracks the
panel's width, and the stats spread across it once there is room rather
than bunching at the left. Shown in both the Artist and About panes. -->
{#snippet artistChip()}
<button class="achip" onclick={openDiscoveryArtist} title="Open {artistName}">
{#if discovery.picture}
<img src={discovery.picture} alt="" loading="lazy" draggable="false" />
{:else}
<span class="achip-ph"><Icon name="music" size={24} /></span>
{/if}
<span class="achip-body">
<span class="achip-name">{artistName || "Unknown artist"}</span>
<span class="achip-stats" class:spread={statsSpread}>
<span class="st"><b>{discovery.albumCount ?? "—"}</b><i>releases</i></span>
<span class="st"><b>{fmtFans(discovery.fans)}</b><i>fans</i></span>
<span class="st"><b>{inLibraryCount}</b><i>in library</i></span>
</span>
</span>
</button>
{/snippet}
<aside class="nowplaying" class:collapsed={!ui.nowPlaying} class:dragging>
<!-- The record washing the RAIL (the expanded view carries its own
.scroll-bleed inside the scroller, so the wash rides the scroll away
like the album page's hero banner). Blurred hard, because it is a
backdrop for text and icons — not a second showing of the artwork. -->
{#if artUrl}
<div class="bleed" aria-hidden="true">
<img src={artUrl} alt="" draggable="false" />
<span class="bleed-fade"></span>
</div>
{/if}
<!-- ================================ COLLAPSED: the rail ============ -->
<div class="rail-view" inert={ui.nowPlaying || undefined}>
<header class="glass rail-bar">
<button class="np-icon" title="Expand panel" aria-label="Expand Now Playing" onclick={toggleNowPlaying}>
<Icon name="chevron-left" size={16} />
</button>
{#if isStream}
<span class="hairline" class:seek={hairlineSeeking} class:done={cached} style="--buf:{bufferPct}%"><i></i></span>
{/if}
</header>
<!-- Playback down the rail's own edge: the one value worth a whole
dimension, and it costs no content width. -->
<span class="rail-edge" style="--p:{playedPct}%" aria-hidden="true"><i></i></span>
<div class="rail-body">
{#if artUrl}
<button class="rail-cover" title="View artwork" aria-label="View album artwork" onclick={() => openArtwork()}>
<img src={artUrl} alt="" draggable="false" />
</button>
{/if}
{#if player.status === "playing" && ui.windowActive}
<span class="eq active" aria-hidden="true"><b></b><b></b><b></b></span>
{/if}
<div class="rail-dots">
<button class="rail-dot" title="Queue" onclick={() => openPane("queue")}>
<Icon name="layers" size={15} />
{#if queueLeft > 0}<span class="rail-badge">{queueLeft}</span>{/if}
</button>
<button class="rail-dot" title="More from {artistName}" onclick={() => openPane("artist")}>
<Icon name="disc" size={15} />
{#if discovery.albumCount}<span class="rail-badge">{discovery.albumCount}</span>{/if}
</button>
<button class="rail-dot" title="About" onclick={() => openPane("about")}>
<Icon name="users" size={15} />
</button>
</div>
<span class="rail-spacer"></span>
{#if player.track?.title}
<div class="rail-title" title={player.track.title}>
<b>{player.track.title}</b>{metaParts[0] ? ` — ${metaParts[0]}` : ""}
</div>
{/if}
</div>
</div>
<!-- ================================ EXPANDED ======================= -->
<div class="full-view" inert={!ui.nowPlaying || undefined}>
<!-- 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. It names what you are
playing FROM and carries the transfer as a hairline along its bottom
edge — the panel is self-evidently "now playing", so saying so was a
waste of the only permanently visible line it has. -->
<div class="headzone">
<header class="np-head glass" data-scrollhead>
{#if npGlassy}
<div class="peri" bind:this={npPeriEl} aria-hidden="true"></div>
{/if}
<button class="np-icon" title="Collapse panel" aria-label="Collapse Now Playing" onclick={toggleNowPlaying}>
<Icon name="chevron-right" size={16} />
</button>
<div class="np-headmid">
{#if statusLine}
{#key statusPhase}
<span class="np-status">{statusLine}</span>
{/key}
{/if}
<span class="np-from" title={metaParts[1] || player.track?.title}>
{metaParts[1] || player.track?.title || "Nothing playing"}
</span>
</div>
{#if currentItem}
<PlaylistMenu
compact
align="right"
iconSize={15}
title="Add this track to a playlist"
tracks={() => [
{
title: currentItem.title,
artist: currentItem.artist ?? metaParts[0] ?? null,
album: currentItem.album ?? metaParts[1] ?? null,
duration_secs: currentItem.duration ?? null,
// A stream item's path names a file on the PEER's
// machine — the row goes in as a stream row that
// plays through the resolver, not a fake local
// file. Its catalog id and cover ride along.
path: currentItem.stream ? null : currentItem.path,
art_path: currentItem.stream ? null : (currentItem.art ?? null),
trackId: currentItem.trackId ?? null,
artUrl: currentItem.stream ? (currentItem.artUrl ?? null) : null,
},
]}
/>
{/if}
<!-- Saving what you are streaming lived in the popover. It is the
only ACTION that did, so it comes up here rather than being
lost with it. -->
{#if isStream && currentItem?.stream}
<SaveMenu align="down" />
{/if}
{#if isStream}
<span class="hairline" class:seek={hairlineSeeking} class:done={cached} style="--buf:{bufferPct}%"><i></i></span>
{/if}
</header>
</div>
<div class="np-scroll" use:floatScroll bind:this={npScrollEl}>
<!-- The record's wash, anchored to the CONTENT: it rides the scroll
away like the album page's hero banner (Kyle, 2026-08-26). The
pane-level .bleed above now backgrounds the RAIL alone. -->
{#if artUrl}
<div class="scroll-bleed" aria-hidden="true">
<img src={artUrl} alt="" draggable="false" />
<span class="bleed-fade"></span>
</div>
{/if}
{#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. Always mounted — see `wavePending`. -->
<div
class="wave"
class:scrubbing={waveDragging}
class:pending={wavePending}
class:loading={wavePending && player.wavePending && !railHolding}
class:rising={railRising}
class:arriving={railArriving}
style="--bar-gap: {BAR_PITCH - BAR_W}px; --bars: {waveBars.length}"
role="slider"
tabindex="0"
aria-label="Seek"
aria-valuemin="0"
aria-valuemax={Math.round(player.duration ?? 0)}
aria-valuenow={Math.round(player.position ?? 0)}
bind:this={waveEl}
onpointerdown={waveDown}
onpointermove={waveMove}
onpointerup={waveUp}
onpointercancel={waveUp}
>
{#each waveBars as v, i}
<i
style="--i:{i}; height:{railBarHeight(v, i)}%; --fill:{railBarDim(v, i)
? 0
: barFill(i)}; opacity:{railBarDim(v, i) ? PENDING_ALPHA : 1}"
></i>
{/each}
</div>
<div class="times">
<span>{fmtTime(player.position)}</span>
<!-- Remaining, not total: the total is already implied by the
bar's own length, and what a listener wants from a second
number is how much is left. -->
<span>-{fmtTime(Math.max(0, (player.duration ?? 0) - (player.position ?? 0)))}</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}
<!-- Context below the record: shallow panes instead of one long
scroll, so the queue is never four sections away. An ordinary
element in the flow — it is painted where it sits and scrolls
away with everything else. -->
<div class="segwrap">
<div class="seg" role="tablist">
<span
class="seg-thumb"
style="width: calc((100% - 6px) / {panes.length}); transform: translateX({panes.indexOf(pane) * 100}%)"
aria-hidden="true"
></span>
{#each panes as p (p)}
<button
class="seg-btn"
class:on={pane === p}
role="tab"
aria-selected={pane === p}
onclick={() => selectPane(p)}
>{PANE_LABEL[p]}</button>
{/each}
</div>
</div>
{#key pane}
<div class="pane" style="--dir: {paneDir}px">
{#if pane === "queue"}
{#if upNext}
<button class="upnext" onclick={() => jumpToQueueIndex(upNextIndex)}>
{#if upNextArt}
<img src={upNextArt} alt="" draggable="false" />
{:else}
<span class="un-fallback"><Icon name="music" size={15} /></span>
{/if}
<span class="un-body">
<span class="un-lab">Up next</span>
<span class="un-title" title={upNext.title}>{upNext.title}</span>
</span>
<span class="un-dur">{fmtTime(upNext.duration)}</span>
</button>
{/if}
{#if albumRows.length > 1}
<div class="lab">
<span>{albumMode ? "From the album" : "Queue"}</span>
<span class="cnt">{albumRows.length} tracks</span>
</div>
<div class="list">
{#each albumRows as row, i (row.key)}
{@const isNow =
row.queueIndex >= 0 && row.queueIndex === player.queueIndex}
<button
class="row"
class:now={isNow}
class:wide={showListeners}
onclick={() => playAlbumRow(row)}
>
<span class="n">
{#if isNow}
<span
class="eq"
class:active={player.status === "playing" && ui.windowActive}
><b></b><b></b><b></b></span>
{:else}{i + 1}{/if}
</span>
<span class="nm" title={row.title}>
{row.title}
{#if libraryTicks.has(norm(row.title))}
<span class="own" title="Saved in your library"><Icon name="check" size={11} /></span>
{/if}
</span>
{#if showListeners}
<span class="lis">{fmtListeners(row.listeners) ?? "—"}</span>
{/if}
<span class="du">{fmtTime(row.duration)}</span>
</button>
{/each}
</div>
{:else}
<p class="pane-empty">Nothing queued after this one.</p>
{/if}
{:else if pane === "lyrics"}
<LyricsPane />
{:else if pane === "artist"}
{@render artistChip()}
{#if discovery.socials.length}
<div class="socials">
{#each discovery.socials as link (link.kind)}
{@const meta = SOCIAL_META[link.kind] ?? SOCIAL_META.website}
<a
class="soc"
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={15} fill={meta.solid ?? false} /></a>
{/each}
</div>
{/if}
{#if releaseList.length}
<div class="lab">
<span>Releases</span>
{#if discovery.albumCount}<span class="cnt">{discovery.albumCount} total</span>{/if}
</div>
{#if showAllReleases}
<!-- The whole discography, filed by category and
dated only where the year changes. -->
{#each releaseSections as section (section.key)}
<div class="rel-sec">
<span>{section.label}</span>
<span class="rel-rule"></span>
<span class="n">{section.count}</span>
</div>
<div class="rel-rows">
{#each section.rows as row (row.album.id)}
{#if row.heading}
<div class="rel-year">{row.heading}</div>
{/if}
<button
class="rel-row"
title={row.album.title}
onclick={() => openCatalogAlbum(row.album.id, { seed: albumSeed(row.album) })}
>
<span class="rel-th">
{#if row.album.cover && !artBroken[row.album.cover]}
<img src={row.album.cover} alt="" loading="lazy" draggable="false" onerror={() => markArtBroken(row.album.cover)} />
{/if}
</span>
<span class="rel-t">{row.album.title}</span>
</button>
{/each}
</div>
{/each}
{:else}
<div class="grid2" style="--cols: {releaseCols}">
{#each releaseList as al (al.id)}
<button
class="mini"
title={al.title}
onclick={() => openCatalogAlbum(al.id, { seed: albumSeed(al) })}
onpointerenter={(e) => tintFromCover(e, 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>
{/if}
{#if discovery.albums.length > moreAlbums.length}
<button class="showall" onclick={() => (showAllReleases = !showAllReleases)}>
{showAllReleases ? "Show fewer" : "Show all " + discovery.albums.length + " releases"}
</button>
{/if}
{/if}
{#if relatedArtists.length}
<div class="lab"><span>Fans also like</span></div>
<div class="fans">
{#each relatedArtists as ar}
<button class="fan" title={ar.name} onclick={() => openCatalogArtist(ar.id, ar)}>
<span class="av2">
{#if ar.picture}<img src={ar.picture} alt="" loading="lazy" draggable="false" />{/if}
</span>
<span class="fn">{ar.name}</span>
</button>
{/each}
</div>
{/if}
{:else}
<div class="lab"><span>About this record</span></div>
{#if albumTitle}
<!-- The record names itself, so the copy below does
not have to carry the identity as well. -->
<div class="rechead">
{#if artUrl}
<img class="cv" src={artUrl} alt="" draggable="false" />
{/if}
<div class="rh-copy">
<div class="rt">{albumTitle}</div>
{#if recordSub}<div class="rs">{recordSub}</div>{/if}
</div>
</div>
{/if}
{#if albumInfo?.description}
<!-- The lede is the description's own FIRST SENTENCE,
set larger — not a written hook. "Loveless is the
second studio album by…" already reads like one,
and splitting it costs no source and invents
nothing. The remainder clamps as before. -->
{#if descLede}
<p class="lede">{descLede}</p>
{/if}
{#if descRest}
<p class="bio" class:clamped={!descExpanded}>{descRest}</p>
<button class="more" class:open={descExpanded} onclick={() => (descExpanded = !descExpanded)}>
{descExpanded ? "Show less" : "Show more"}
<Icon name="chevron-down" size={13} />
</button>
{/if}
{/if}
{#if recordTiles.length}
<div class="tiles">
{#each recordTiles as tile (tile.label)}
<div class="tile"><b>{tile.value}</b><i>{tile.label}</i></div>
{/each}
</div>
{/if}
{#if albumInfo?.genre || albumInfo?.label || releasedOn}
<div class="pills">
{#if albumInfo?.genre}<span class="pill accent">{albumInfo.genre}</span>{/if}
{#if albumInfo?.label}<span class="pill">{albumInfo.label}</span>{/if}
{#if releasedOn}<span class="pill">{releasedOn}</span>{/if}
</div>
{/if}
<div class="lab"><span>About the artist</span></div>
{@render artistChip()}
{#if activeYears}
<div class="pills"><span class="pill">Active {activeYears}</span></div>
{/if}
{#if discovery.bio}
<p class="bio" class:clamped={!bioExpanded}>{discovery.bio}</p>
<button class="more" class:open={bioExpanded} onclick={() => (bioExpanded = !bioExpanded)}>
{bioExpanded ? "Show less" : "Read more"}
<Icon name="chevron-down" size={13} />
</button>
{/if}
{#if !albumInfo?.description && !discovery.bio}
<p class="pane-empty">Nothing written about this record yet.</p>
{/if}
<!-- The pane asserts dates, counts and prose from three
different providers; saying which is whose costs a
line and matches how carefully the rest of the app
anchors what it claims. -->
{#if albumInfo?.description || discovery.bio || recordTiles.length}
<p class="srcs">
{#if albumInfo?.description || discovery.bio}<b>Words</b> Wikipedia · {/if}
{#if recordTiles.length}<b>Counts</b> Last.fm · {/if}
<b>Catalogue</b> Deezer + MusicBrainz
</p>
{/if}
{/if}
</div>
{/key}
{:else}
<div class="empty">
<Icon name="music" size={30} />
<span>Nothing playing</span>
</div>
{/if}
{/if}
</div>
</div>
<!-- Drag the panel's left edge to widen it. Only while expanded: the rail
is a fixed size, and a resize handle on it would promise otherwise. -->
{#if ui.nowPlaying}
<div
class="grip"
class:on={dragging}
role="separator"
aria-orientation="vertical"
aria-label="Resize Now Playing panel"
title="Drag to resize · double-click to reset"
bind:this={gripEl}
onpointerdown={onGripDown}
onpointermove={onGripMove}
onpointerup={onGripUp}
onpointercancel={onGripUp}
onlostpointercapture={onGripUp}
ondblclick={resetWidth}
></div>
{#if dragging}
<span class="wtag" class:snap={atDefault}>
{ui.nowPlayingWidth}{atDefault ? " · default" : ""}
</span>
{/if}
{/if}
</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;
/* The panel now COLLAPSES to a rail rather than sliding off-screen, so
its width tracks the grid column at every point instead of staying
fixed while the column animates to zero underneath it. The column and
this share one clock (App.svelte, 0.28s) so nothing lags. */
width: var(--nowplaying-width);
justify-self: start;
transition: width 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
/* A drag must track the pointer exactly, not trail an easing curve. */
.nowplaying.dragging {
transition: none;
}
/* The record, washing the whole panel behind the glass. Blurred hard: this
is a backdrop for text, not a second showing of the artwork. */
.bleed {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
/* NOT a typo, and never round it up to 1: group opacity forces the
img and the fade to FLATTEN into one surface before the panel's
rounded clip touches them. Without it the compositor may render
them as separate layers (a per-session choice — big overlay mounts
like the artwork viewer flip it), and then each layer's partial
edge coverage composites separately at the corner arcs and STACKS:
the corners grow a light rim of wash colour, brighter than fill
and gap both (measured 40-53 over a 24 fill — impossible for any
single-pass blend; the 2026-08-25 saga). Flattened, the arc is
antialiased once and the wash runs full-bleed to the very edge —
no inset, no mask, no visible cost (the 1% dim is nothing). */
opacity: 0.99;
transition: opacity 0.28s ease;
}
/* Expanded, the pane-level wash yields to the scroller's own (rail keeps
this one). 0 ← 0.99, never up — the flatten note above stands. */
.nowplaying:not(.collapsed) .bleed {
opacity: 0;
}
.bleed img {
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(17px) saturate(1.25);
opacity: 0.85;
transform: scale(1.08);
}
.bleed-fade {
position: absolute;
inset: 0;
background: linear-gradient(
rgba(24, 24, 24, 0.15) 0,
rgba(24, 24, 24, 0.75) 38%,
var(--bg-player) 78%
);
}
/* The expanded view's wash, anchored to the scroller's CONTENT so it
rides the scroll away — the album page's hero-banner grammar. Height
tracks the pane width (the hero is width-driven), the fade span above
lands on solid --bg-player before the box ends, so solid mode is
seamless against the pane ground. Same flatten discipline as .bleed. */
.scroll-bleed {
position: absolute;
top: 0;
left: 0;
right: 0;
aspect-ratio: 3 / 5;
z-index: -1; /* np-scroll is a stacking context (z-index: 1) */
overflow: hidden;
pointer-events: none;
opacity: 0.99;
}
.scroll-bleed img {
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(17px) saturate(1.25);
opacity: 0.85;
transform: scale(1.08);
}
/* Dark glass: the pane joins the frost. The base thins like the sidebar
(nets ~0.71 over the .app canvas tint), and the bleed's fade thins
ONLY in its tail — it lands on solid --bg-player, which would keep
the lower panel opaque. The upper stops stay IDENTICAL to solid mode:
thinning them brightened the wash behind the glass header and the
cover read straight through it (Kyle, 2026-08-26 — "don't change the
glass topbar", the fix belongs to the wash, not the bar). Tail 0.85
is the mini Wash vocabulary (its fades end near 0.92). Stops stay
inside the flattened .bleed group, so the corner-arc story above is
untouched. */
:global(:root.glass) .nowplaying {
background: rgba(0, 0, 0, 0.35);
}
:global(:root.glass) .bleed-fade {
background: linear-gradient(
rgba(24, 24, 24, 0.15) 0,
rgba(24, 24, 24, 0.75) 38%,
rgba(24, 24, 24, 0.85) 78%
);
}
/* The wash ends the way the album page's hero banner does (Kyle,
2026-08-26): art and fade dissolve together below the hero zone and
the lower pane is pure frost, the same 0.35 ground the sidebar and
playbar stand on. Solid mode needs none of this: its fade already
lands on opaque --bg-player. Masking a flattened group is one alpha
op on one surface, so the corner-arc story above is untouched.
(.bleed = the rail's wash, pane-relative %; .scroll-bleed = the
expanded wash, self-relative — it also must reach nothing at its box
end, where the glass fade tail would otherwise seam against frost.) */
:global(:root.glass) .bleed {
mask-image: linear-gradient(to bottom, #000 45%, transparent 72%);
-webkit-mask-image: linear-gradient(to bottom, #000 45%, transparent 72%);
}
:global(:root.glass) .scroll-bleed {
mask-image: linear-gradient(to bottom, #000 50%, transparent 96%);
-webkit-mask-image: linear-gradient(to bottom, #000 50%, transparent 96%);
}
/* ── the chrome standing ON the frost (2026-08-27) ──────────────────
Below the wash's dissolve the panel is bare frost, and everything on
it was still painting opaque --bg-highlight: the quality chips, every
queue and release row, the icon buttons, the art wells. See the ladder
in global.css for why these elevate with white rather than more black.
Solid mode keeps every original recipe. */
:global(:root.glass) .chip {
background: var(--glass-surface);
box-shadow: var(--glass-ring);
}
:global(:root.glass) .np-icon:hover,
:global(:root.glass) .row:hover,
:global(:root.glass) .np-head :global(.plm-trigger:hover) {
background: var(--glass-hover);
}
/* Artwork wells: what is visible while a cover loads, or in place of one
that never arrives. */
:global(:root.glass) .cover,
:global(:root.glass) .un-fallback,
:global(:root.glass) .achip-ph,
:global(:root.glass) .av2,
:global(:root.glass) .rel-th,
:global(:root.glass) .mini .art {
background: var(--glass-surface);
}
/* The segmented control's thumb was `color-mix(--accent 28%,
--bg-highlight)` — accent-tinted but fully OPAQUE, so the one piece of
chrome that marks which tab you are on read as a solid brown slab on
the frost. This is the artist page's own tab recipe (.disco-tab.on),
copied deliberately: the two controls do the same job and should not
have two vocabularies. Nets 0.34 + 0.66x0.5 = 0.67 alpha. */
:global(:root.glass) .seg-thumb {
background: color-mix(in srgb, var(--accent) 34%, rgba(10, 10, 12, 0.5));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent);
}
/* The COLLAPSED rail's header wears the same .glass recipe as .np-head and
had the same flatten, but none of the periscope machinery — and it needs
none: the rail is a fixed column (header, edge, cover, dots), nothing
scrolls beneath this bar, so the filter had nothing to blur in the first
place. Dropping it removes a flattened slab and a backdrop pass. */
:global(:root.glass) .rail-bar {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* Hairlines. --border is opaque #2a2a2a, which on frost reads as a drawn
grey line rather than an edge. These five all pair it with a translucent
fill they already had, so only the border was out of step. */
:global(:root.glass) .upnext,
:global(:root.glass) .achip,
:global(:root.glass) .fan,
:global(:root.glass) .showall,
:global(:root.glass) .seg {
border-color: var(--glass-hairline);
}
/* The queue-count badge punches a 2px ring of opaque --bg-player around
itself to separate it from the icon underneath. On frost that ring is a
solid dot of #181818; the same separation comes from the frost itself. */
:global(:root.glass) .rail-badge {
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35);
}
/* NOTE, deliberately NOT changed: .segwrap keeps its backdrop-filter.
It is a bar that has to hide the rows scrolling under it, and over
frost a backdrop sample black-flattens (the measurement lives in
Sidebar.svelte). The shipped answer for a bar in that position is a
periscope clone, not a thinner fill — three of those exist already and
a fourth is a structural change, not a paint one. Left as a known
slab; revisit with the periscope pattern if it starts to grate. */
/* ── the head periscope, dark glass only (see the script note) ──────
The head drops its backdrop-filter (over frost it black-flattens
into a slab — Sidebar.svelte holds the measurements), the scroller's
originals are masked out of the 46px band (direct-child selector:
the clone inside .peri also wears .np-scroll and must not inherit
it), and the clone paints the band as fog — wash included, so the
record tint survives at rest and shifts as you scroll. */
:global(:root.glass) .np-head {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* clip-path, not mask: rectangle clips ride the compositor's fast
path (see App.svelte's band-cut note). */
:global(:root.glass) .full-view > .np-scroll {
clip-path: inset(46px 0 0 0);
}
.peri {
position: absolute;
inset: 0;
overflow: hidden;
z-index: 0;
pointer-events: none;
}
/* The clone's filter and geometry are inline on its overscan wrapper —
built in the reclone() above; the band mask can't reach the clone
(direct-child selector), so no cancel rule is needed here. */
/* The head's real content rides above the fog; the hairline is already
absolute and only needs the lift. */
.np-head > :global(:not(.peri):not(.hairline)) {
position: relative;
z-index: 1;
}
.np-head .hairline {
z-index: 1;
}
/* Both views stack in the same box and cross-fade, on the left sidebar's
own label clock — the collapse itself is the width animation above. */
.rail-view,
.full-view {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
flex-direction: column;
min-height: 0;
transition: opacity 0.15s ease;
}
.rail-view {
opacity: 0;
pointer-events: none;
}
.nowplaying.collapsed .full-view {
opacity: 0;
pointer-events: none;
}
.nowplaying.collapsed .rail-view {
opacity: 1;
pointer-events: auto;
}
/* ---- the shared glass recipe, same numbers as GlassBar.svelte ---- */
.glass {
background: rgba(20, 20, 24, 0.28);
backdrop-filter: blur(12px) saturate(1.7);
-webkit-backdrop-filter: blur(12px) saturate(1.7);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
}
/* The transfer, as a hairline along the glass bar's bottom edge — the same
object in the rail and in the expanded header, so a download reads the
same whichever state the panel is in. */
.hairline {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: rgba(255, 255, 255, 0.07);
overflow: hidden;
}
.hairline i {
display: block;
height: 100%;
width: var(--buf, 0%);
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
/* linear, not eased: it tracks real progress rather than overshooting
it and easing back. */
transition: width 240ms linear, opacity 400ms ease;
}
/* No byte count yet, so there is no percentage to be honest about. */
.hairline.seek i {
width: 38%;
animation: hairsweep 1.5s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes hairsweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(365%); }
}
/* Done is not "full forever" — it leaves. */
.hairline.done i {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.hairline.seek i {
animation: none;
}
}
/* ---- the rail ---- */
.rail-bar {
position: relative;
height: 46px;
flex: none;
display: grid;
place-items: center;
z-index: 5;
}
.rail-edge {
position: absolute;
left: 3px;
top: 52px;
bottom: 10px;
width: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.09);
overflow: hidden;
z-index: 4;
}
.rail-edge i {
position: absolute;
top: 0;
left: 0;
right: 0;
height: var(--p, 0%);
border-radius: 2px;
background: var(--accent-hover);
transition: height 240ms linear;
}
.rail-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 10px 0 13px;
position: relative;
z-index: 2;
}
.rail-cover {
width: calc(var(--nowplaying-rail) - 20px);
aspect-ratio: 1;
border-radius: 6px;
overflow: hidden;
flex: none;
padding: 0;
transition: transform var(--art-motion);
}
.rail-cover:hover,
.rail-cover:focus-visible {
transform: scale(var(--art-grow));
}
.rail-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.rail-dots {
display: flex;
flex-direction: column;
gap: 7px;
align-items: center;
}
.rail-dot {
position: relative;
width: 30px;
height: 30px;
border-radius: 8px;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.55);
transition: background 120ms ease, color 120ms ease;
}
.rail-dot:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.rail-badge {
position: absolute;
top: -3px;
right: -3px;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 8.5px;
font-weight: 700;
display: grid;
place-items: center;
/* Reads as a badge ON the icon rather than a dot beside it. */
box-shadow: 0 0 0 2px var(--bg-player);
}
.rail-spacer {
flex: 1;
}
.rail-title {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 10.5px;
font-weight: 600;
color: var(--text-secondary);
max-height: 190px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
}
.rail-title b {
color: #fff;
font-weight: 700;
}
/* ---- drag handle ---- */
.grip {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 9px;
z-index: 30;
cursor: col-resize;
display: flex;
align-items: center;
justify-content: center;
touch-action: none;
}
.grip::before {
content: "";
width: 3px;
height: 34px;
border-radius: 2px;
background: transparent;
transition: background 150ms ease, height 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.grip:hover::before,
.grip.on::before {
background: color-mix(in srgb, var(--accent-hover) 75%, transparent);
height: 62px;
}
.wtag {
position: absolute;
left: 14px;
top: 56px;
z-index: 31;
padding: 3px 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(8px);
font-size: 10.5px;
font-weight: 700;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.wtag.snap {
color: var(--accent-hover);
}
/* 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;
gap: 9px;
padding: 0 11px;
z-index: 5;
}
.np-headmid {
min-width: 0;
flex: 1;
overflow: hidden;
}
/* The status swaps with the glass bar's own breadcrumb motion, so a change
of peer or state reads as an arrival rather than a flicker. Keyed on
`statusPhase`, NOT on the text: the text carries the live percentage, so
keying on it replayed this animation on every progress event. */
.np-status {
display: block;
font-size: 10.5px;
font-weight: 600;
color: rgba(255, 255, 255, 0.55);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-variant-numeric: tabular-nums;
animation: statusin 300ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes statusin {
from { opacity: 0; transform: translateX(11px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.np-status {
animation: none;
}
}
.np-from {
display: block;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
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); }
/* The add-to-playlist trigger wears the header-icon look exactly. */
.np-head :global(.plm-trigger) {
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--text-secondary);
}
.np-head :global(.plm-trigger:hover) {
background: var(--bg-highlight);
color: var(--text-primary);
}
.np-scroll {
/* Fills the absolutely-positioned .full-view rather than sizing to its
content, so the pane scrolls inside the panel and the glass header
keeps overlaying it. */
flex: 1;
min-height: 0;
overflow-y: auto;
/* Never sideways. Nothing in this pane is meant to be reached by
scrolling horizontally — a stray wide row is a bug, and a scrollbar
along the bottom of the panel is not the way to find out about it. */
overflow-x: hidden;
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;
/* Written from BAR_PITCH - BAR_W so the gap cannot drift out of step
with the bar count derived from the same pitch. */
gap: var(--bar-gap, 4px);
height: 38px;
/* It is a control now, not a picture of one. */
cursor: pointer;
touch-action: none;
}
.wave:focus-visible {
outline: 2px solid var(--accent-hover);
outline-offset: 3px;
border-radius: 2px;
}
.wave.scrubbing {
cursor: grabbing;
}
/* Waiting on peaks: the bars rest on their 2px floor, so the lane reads as
a quiet baseline holding its place rather than as a hole in the panel. */
.wave.pending i {
opacity: 0.4;
}
.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;
}
/* Loading swell — the playbar's L2, phase-staggered per bar so one crest
travels the lane left→right; the negative delay starts the pattern
mid-flight instead of all-flat. Timing pairs with waveshape.js: 2000ms
sweep + 500ms rest = the 2500ms period, and the 30% crest is the 7px
playbar lift at the rail's 1.6× scale. CSS cannot read those constants —
change them together. */
.wave.loading i {
animation: rail-swell 2500ms linear infinite;
animation-delay: calc(-2500ms + 2000ms * var(--i) / var(--bars, 60));
}
@keyframes rail-swell {
0%, 26%, 100% { height: 2px; opacity: 0.45; }
13% { height: 30%; opacity: 0.85; }
}
/* Streaming rise — the playbar's S2: a released bar stands up over the
same RISE_MS (2000ms), on the house ease. */
.wave.rising i {
transition:
height 2000ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 400ms linear;
}
/* Arrival morph — the playbar's M2: a complete waveform eases in as one
motion, every bar at once. 1000ms is ARRIVE_MS in waveshape.js — change
them together. After `.rising` so a stream's final decode arrives on
this ease even while the rise class lingers. */
.wave.arriving i {
transition:
height 1000ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 400ms linear;
}
.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 */
/* save action row */
/* tracklist */
/* Every heading in this panel rides the blurred record, not a flat dark
page: `.bleed-fade` is only rgba(24,24,24,0.15) at the top and does not
reach solid until 78% down. `--text-muted` and #5c5c5c were chosen
against --bg-base and simply vanish into a bright cover. Each heading
carries its own ground instead — a dark plate fading out to the right,
so the label end is always backed and the row end still shows the
artwork through. */
.lab {
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.74);
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 7px;
margin-inline: -8px;
padding: 4px 8px;
border-radius: 5px;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.14));
}
.lab .cnt {
letter-spacing: 0;
color: rgba(255, 255, 255, 0.5);
}
/* Section spacing belongs to the label, not to whatever happens to sit
above it — an artist with no social links used to leave the releases
heading jammed against the pill, because the gap was living on the
chip row that was not rendered. */
.pane .lab {
margin-top: 18px;
}
.pane > .lab:first-child {
margin-top: 0;
}
/* ---- segmented control ---- */
.segwrap {
/* Deliberately NOT sticky. Sticky does not mean "pinned": it scrolls
normally until it reaches its stick point, so sitting below the art,
title, waveform and chips it rode 530px up the panel before pinning,
which read as the bar sliding around. It is painted where it sits
and scrolls away like any other element. */
z-index: 4;
margin: 0 -14px;
padding: 7px 14px 8px;
/* Glass, not a solid fill: an opaque strip here painted straight over
the album bleed and read as a grey band across the artwork. This
tints with whatever is behind it exactly as the header does, while
still hiding the rows that scroll under it. */
background: rgba(20, 20, 24, 0.55);
backdrop-filter: blur(12px) saturate(1.7);
-webkit-backdrop-filter: blur(12px) saturate(1.7);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04);
}
.seg {
position: relative;
display: flex;
background: rgba(0, 0, 0, 0.28);
border: 1px solid var(--border);
border-radius: 8px;
padding: 3px;
}
.seg-thumb {
position: absolute;
top: 3px;
bottom: 3px;
left: 3px;
border-radius: 6px;
background: color-mix(in srgb, var(--accent) 28%, var(--bg-highlight));
transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1),
width 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.seg-btn {
position: relative;
z-index: 1;
flex: 1;
text-align: center;
font-size: 11.5px;
font-weight: 700;
padding: 6px 4px;
border-radius: 6px;
color: var(--text-muted);
transition: color 160ms ease;
}
.seg-btn:hover { color: var(--text-secondary); }
.seg-btn.on { color: #fff; }
.pane {
display: flex;
flex-direction: column;
animation: panein 300ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes panein {
from { opacity: 0; transform: translateX(var(--dir, 10px)); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.pane { animation: none; }
.seg-thumb { transition-duration: 1ms; }
}
.pane-empty {
color: var(--text-muted);
font-size: 12px;
padding: 10px 2px;
}
/* ---- up next ---- */
.upnext {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px;
margin-bottom: 12px;
border-radius: 7px;
background: rgba(0, 0, 0, 0.24);
border: 1px solid var(--border);
transition: border-color 160ms ease, background 160ms ease;
}
.upnext:hover {
background: rgba(0, 0, 0, 0.34);
border-color: color-mix(in srgb, var(--accent-hover) 45%, var(--border));
}
.upnext img { width: 34px; height: 34px; border-radius: 4px; flex: none; object-fit: cover; }
.un-fallback {
width: 34px;
height: 34px;
flex: none;
display: grid;
place-items: center;
border-radius: 6px;
background: var(--bg-highlight);
color: var(--text-tertiary);
}
.un-body { min-width: 0; flex: 1; display: block; }
.un-lab {
display: block;
font-size: 9.5px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
font-weight: 700;
}
.un-title {
display: block;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.un-dur {
font-size: 11px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
flex: none;
}
/* ---- queue extras ---- */
.row .own {
color: var(--accent-hover);
margin-left: 5px;
display: inline-flex;
vertical-align: -1px;
}
.row .lis {
font-size: 10.5px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
padding: 1px 6px;
border-radius: 3px;
background: color-mix(in srgb, var(--accent) 16%, transparent);
flex: none;
}
/* ---- artist pill ---- */
.achip {
display: flex;
/* Block-level, so it tracks the panel width instead of shrinking to
its own content at every size. */
width: 100%;
/* A <button> centres its text. The stats row underneath is a flex row
and ignores that, so the artist's name alone sat centred over the
figures — reading as if it had drifted off to the right. */
text-align: left;
gap: 11px;
align-items: center;
padding: 10px;
border-radius: var(--radius);
background: rgba(0, 0, 0, 0.24);
border: 1px solid var(--border);
transition: border-color 160ms ease, background 160ms ease;
}
.achip:hover {
background: rgba(0, 0, 0, 0.34);
border-color: color-mix(in srgb, var(--accent-hover) 45%, var(--border));
}
.achip img,
.achip-ph {
width: 54px;
height: 54px;
border-radius: 50%;
object-fit: cover;
flex: none;
transition: transform var(--art-motion);
}
.achip-ph {
display: grid;
place-items: center;
background: var(--bg-highlight);
color: var(--text-muted);
}
.achip:hover img { transform: scale(var(--art-grow)); }
.achip-body { min-width: 0; flex: 1; display: block; }
.achip-name {
display: block;
font-weight: 700;
font-size: 13.5px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.achip-stats {
display: flex;
gap: 13px;
margin-top: 3px;
}
/* Once the panel is wide enough the figures use the room instead of
huddling against the portrait. */
.achip-stats.spread {
justify-content: space-between;
gap: 8px;
padding-right: 4px;
}
.achip-stats .st { display: flex; flex-direction: column; }
.achip-stats .st b {
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.1;
color: var(--text-primary);
}
.achip-stats .st i {
font-size: 9.5px;
font-style: normal;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.07em;
}
/* ---- social links: the artist page's own --soc tint model ---- */
.socials {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 10px;
}
.soc {
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;
}
.soc:hover {
background: rgba(var(--soc, 160, 168, 177), 0.13);
transform: translateY(-1px);
}
@media (prefers-reduced-motion: reduce) {
.soc { transition-duration: 1ms; }
}
.showall {
width: 100%;
text-align: center;
font-size: 11.5px;
font-weight: 700;
padding: 8px;
border-radius: 7px;
margin-top: 10px;
border: 1px solid var(--border);
color: var(--text-secondary);
transition: color 150ms ease, border-color 150ms ease, background 150ms ease;
}
.showall:hover {
color: #fff;
border-color: color-mix(in srgb, var(--accent-hover) 50%, var(--border));
background: rgba(0, 0, 0, 0.3);
}
/* Fans also like: pills sized to the name, wrapping like tags.
(V4 of research/nowplaying-fans-waveform/mockup.html.)
It replaced a fixed row of four circles spaced `space-evenly`, which had
no good width: every name truncated to "The Sma…" however wide the panel
got, because the circles stayed 64px and only the GAPS between them grew.
Sized to their content and wrapped, the pills use whatever width there
is at any panel size, and carry twice the artists in less height. */
.av2 {
width: 22px;
height: 22px;
border-radius: 50%;
overflow: hidden;
flex: none;
background: var(--bg-highlight);
}
.av2 img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform var(--art-motion);
}
.fan:hover .av2 img { transform: scale(var(--art-grow)); }
.fn {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
p.bio {
margin: 8px 0 0;
font-size: 12.5px;
line-height: 1.55;
color: var(--text-secondary);
}
p.bio.clamped {
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pane .more {
display: flex;
align-items: center;
gap: 4px;
margin-top: 5px;
font-size: 11.5px;
font-weight: 700;
color: var(--accent-hover);
}
.pane .more :global(svg) {
transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.pane .more.open :global(svg) {
transform: rotate(180deg);
}
/* ---- About pane ---- */
/* The record naming itself, so the copy below need not carry the identity. */
.rechead { display: flex; gap: 11px; margin-bottom: 9px; }
.rechead .cv {
width: 62px;
height: 62px;
border-radius: 6px;
flex: none;
object-fit: cover;
box-shadow: 0 8px 18px -8px rgba(0, 0, 0, 0.8);
}
.rh-copy { min-width: 0; }
.rechead .rt {
font-size: 14px;
font-weight: 800;
color: #fff;
line-height: 1.2;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
}
.rechead .rs {
font-size: 11px;
color: rgba(255, 255, 255, 0.6);
margin-top: 3px;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
}
/* The description's own first sentence, promoted — see `descLede`. */
.lede {
font-size: 13.5px;
line-height: 1.5;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
margin: 0 0 8px;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
}
/* Auto-fit, because this pane is resizable: the row goes two-up rather than
crushing four tiles into 272px. */
.tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(74px, 1fr));
gap: 6px;
margin: 10px 0;
}
.tile {
padding: 8px 9px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.045);
border: 1px solid rgba(255, 255, 255, 0.055);
}
.tile b {
display: block;
font-size: 14px;
font-weight: 800;
color: #fff;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.tile i {
font-style: normal;
display: block;
margin-top: 3px;
font-size: 8.5px;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-muted);
font-weight: 700;
}
.pills { display: flex; flex-wrap: wrap; gap: 5px; margin: 8px 0 2px; }
.pill {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 3px 9px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
color: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(255, 255, 255, 0.07);
}
.pill.accent {
background: color-mix(in srgb, var(--accent) 30%, rgba(10, 10, 12, 0.4));
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
color: #fff;
}
.srcs {
margin-top: 14px;
font-size: 9.5px;
line-height: 1.6;
color: #5e5e5e;
}
.srcs b { color: #7a7a7a; font-weight: 700; }
.headzone {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 6;
}
.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 */
/* Gains a third column once the panel is dragged wide enough to carry one
(--cols is set per render from the panel width). */
.grid2 {
display: grid;
grid-template-columns: repeat(var(--cols, 2), minmax(0, 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. */
/* align-self, because a <button> centres its content vertically: in a row
where one card's title wrapped to four lines, the short card's whole
stack — artwork included — was centred against it and sat visibly lower.
Not stretching the card removes the taller box that centring needs. */
.mini { text-align: left; min-width: 0; align-self: start; }
.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; }
/* The record's own colour, sampled on hover; the global accent until one
arrives, and for a cover we could not read. */
.mini:hover .art {
outline: 2px solid var(--album-accent, 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: 2;
/* Two lines ALWAYS — clamped down from six, and reserved even for a
one-word title. The card's height stops depending on its title, so
every row starts at the same offset and the artwork lines up down the
whole grid. The full title is on the button's tooltip. */
min-height: calc(2 * 1.25em);
}
.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. */
/* ---- the full discography, expanded ----
Dense rows rather than a grid: at the panel's widths a row fits a whole
title where a card clamps it to two lines, and twice as many releases
fit on a screen. E4 of research/nowplaying-releases/dividers.html. */
/* The app has exactly one section heading and this is now it: 11px,
uppercase, 0.1em, weight 800, muted, with a hairline running out to the
edge — `.disc-head` on both catalog pages, and a sibling of this panel's
own `.lab`. It used to be 13px Title Case in full white on an opaque
full-bleed bar, the only heading in the app shaped that way, which is
why it read as a design of its own.
It is no longer sticky. The bar existed only to hide rows sliding under
a pinned heading; painted where it sits it scrolls away like every other
element, which is the same call the segmented control above it got. */
.rel-sec {
display: flex;
align-items: center;
gap: 8px;
margin: 18px -8px 7px;
padding: 4px 8px;
border-radius: 5px;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.14));
font-size: 11px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.74);
}
/* 0.07 is the hairline's value on a flat page. Over a bright record it is
not a faint line, it is no line — this one has to survive the cover. */
.rel-rule {
flex: 1;
height: 1px;
background: rgba(255, 255, 255, 0.14);
}
.rel-sec .n { letter-spacing: 0; font-weight: 700; color: rgba(255, 255, 255, 0.5); }
/* Quieter than the section above it, so the two never read as the same
level of heading. A year that only repeats its section is dropped
upstream, in `releaseSections`.
No plate on this one: a year is a minor divider, and a third band of
dark under it made the list read as stripes. It takes the treatment the
album page already gives text standing on artwork — brighter ink and a
drop shadow (`.kind`, `.stat`, `.np-from` all do exactly this). */
.rel-year {
margin: 10px 2px 3px;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.72);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
font-variant-numeric: tabular-nums;
}
.rel-rows { display: flex; flex-direction: column; gap: 2px; }
.rel-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
/* min-width:0 the whole way down, so a long title ellipsises instead
of widening the pane into a horizontal scrollbar. */
min-width: 0;
padding: 6px;
border-radius: 6px;
text-align: left;
transition: background 140ms ease;
}
.rel-row:hover { background: rgba(255, 255, 255, 0.06); }
.rel-th {
width: 40px;
height: 40px;
flex: none;
border-radius: 5px;
overflow: hidden;
background: var(--bg-highlight);
}
.rel-th img { width: 100%; height: 100%; object-fit: cover; display: block; }
.rel-t {
min-width: 0;
flex: 1;
font-size: 12.5px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fans { display: flex; flex-wrap: wrap; gap: 7px; }
.fan {
display: flex;
align-items: center;
gap: 7px;
max-width: 100%;
min-width: 0;
padding: 4px 12px 4px 4px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.28);
border: 1px solid var(--border);
text-align: left;
transition: border-color 160ms ease, background 160ms ease;
}
.fan:hover {
background: rgba(0, 0, 0, 0.36);
border-color: color-mix(in srgb, var(--accent-hover) 45%, var(--border));
}
/* discovery: about the artist */
/* 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. */
/* Name + bio ride up into the faded bottom of the photo, so the text
reads as emerging from the image. */
.bio { padding: 0 0 2px; }
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 0;
color: var(--text-muted);
font-size: 13px;
}
</style>