forked from Keelrleek/SoulStream
The arrival morph honestly started from whatever the lane showed — and on a track change the lane always showed dots, because the waveform empties the moment the queue moves. Every switch therefore played the same grow-from-zero, when the picked motion was one waveform transforming into another. A track change now HOLDS the outgoing shape for ARRIVE_HOLD_MS (600ms), painted in unplayed grey since the new track owns the transport. A quick arrival — a local file, a cached stream — lands inside the hold and the morph runs directly from the old bars into the new ones; a slow stream load falls to the dots-and-swell story when the hold expires, exactly as before. The rail mirrors the hold with the same shared constant, and suppresses the loading swell while holding so the keyframes cannot fight the held heights. Measured across a cached track switch: the lane's mean height never left 16.9-17.4 (the dots sit at 2, the old dip hit 5.2) while the per-column fingerprint changed — the shape transformed without ever falling.
2728 lines
108 KiB
Svelte
2728 lines
108 KiB
Svelte
<script>
|
||
import { untrack } from "svelte";
|
||
import {
|
||
player,
|
||
ui,
|
||
toggleNowPlaying,
|
||
getArt,
|
||
getCatalogArt,
|
||
jumpToQueueIndex,
|
||
streamSong,
|
||
discovery,
|
||
loadDiscovery,
|
||
openCatalogArtist,
|
||
openCatalogAlbum,
|
||
openCurrentArtist,
|
||
openCurrentAlbum,
|
||
openCurrentSong,
|
||
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 * as api from "../lib/api.js";
|
||
import { floatScroll } 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 name for the Save action: album items carry "artist — album".
|
||
const artistName = $derived.by(() => {
|
||
const sub = currentItem?.sub ?? "";
|
||
return sub.includes(" — ") ? sub.split(" — ")[0].trim() : sub;
|
||
});
|
||
// Artist / album split for the separate, clickable identity lines.
|
||
const metaParts = $derived.by(() => {
|
||
const sub = player.track?.sub ?? "";
|
||
return sub.includes(" — ")
|
||
? sub.split(" — ").map((s) => s.trim())
|
||
: [sub.trim(), ""];
|
||
});
|
||
|
||
const LOSSLESS = new Set(["FLAC", "WAV", "ALAC", "AIFF", "APE"]);
|
||
|
||
// Discovery: load more-from-artist / fans-also-like / about when the
|
||
// playing artist changes, but only while the panel is visible. The old
|
||
// always-mounted fetch could launch a full artist refinement in the
|
||
// background while the user was opening an unrelated artist page.
|
||
$effect(() => {
|
||
const name = artistName;
|
||
const visible = ui.nowPlaying;
|
||
if (name && visible) {
|
||
// loadDiscovery reads/writes its own reactive progress fields.
|
||
// Without this boundary a failed/empty lookup makes `loading=false`
|
||
// retrigger the effect indefinitely until Svelte aborts the flush.
|
||
untrack(() => loadDiscovery(name));
|
||
}
|
||
});
|
||
|
||
function openDiscoveryArtist() {
|
||
if (discovery.artistId == null) return;
|
||
openCatalogArtist(discovery.artistId, {
|
||
id: discovery.artistId,
|
||
name: artistName,
|
||
picture: discovery.picture,
|
||
fans: discovery.fans,
|
||
album_count: discovery.albumCount,
|
||
});
|
||
}
|
||
|
||
// Collapse the bio back to the clamped state whenever the artist changes.
|
||
let bioExpanded = $state(false);
|
||
$effect(() => {
|
||
discovery.artist;
|
||
bioExpanded = false;
|
||
});
|
||
|
||
// 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(() => {
|
||
const here = norm(currentItem?.sub?.split(" — ")[1] ?? "");
|
||
return [...discovery.albums]
|
||
.filter((a) => norm(a.title) !== here)
|
||
.sort(
|
||
(a, b) =>
|
||
typeRank(a.record_type) - typeRank(b.record_type) ||
|
||
(b.release_date ?? "").localeCompare(a.release_date ?? ""),
|
||
)
|
||
.slice(0, 6);
|
||
});
|
||
|
||
// 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);
|
||
});
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
const upNext = $derived(player.queue[player.queueIndex + 1] ?? null);
|
||
|
||
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;
|
||
api.playerControl("seek", target);
|
||
}
|
||
}
|
||
function waveUp(e) {
|
||
if (!waveDragging) return;
|
||
waveDragging = false;
|
||
player.scrubbing = false;
|
||
const target = waveTarget(e);
|
||
if (target != null) api.playerControl("seek", 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 washes the whole panel, expanded or collapsed, and the glass
|
||
bars below sit on it exactly as the app's glass bar sits on a page hero.
|
||
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>
|
||
<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>
|
||
<!-- 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>
|
||
{#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(player.queueIndex + 1)}>
|
||
{#if artUrl}<img src={artUrl} alt="" draggable="false" />{/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"}
|
||
<p class="pane-empty">
|
||
Lyrics aren't wired up yet — this pane is a placeholder.
|
||
</p>
|
||
|
||
{: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;
|
||
}
|
||
.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%
|
||
);
|
||
}
|
||
|
||
/* 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); }
|
||
|
||
.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-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>
|