forked from Keelrleek/SoulStream
Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
commit
a667b4b28e
161 files changed
+53170
No files matched your search
@@ -0,0 +1,902 @@
|
||||
<script>
|
||||
import { untrack } from "svelte";
|
||||
import {
|
||||
player,
|
||||
ui,
|
||||
toggleNowPlaying,
|
||||
getArt,
|
||||
getCatalogArt,
|
||||
jumpToQueueIndex,
|
||||
discovery,
|
||||
loadDiscovery,
|
||||
openCatalogArtist,
|
||||
streamCatalogAlbum,
|
||||
openCurrentArtist,
|
||||
openCurrentAlbum,
|
||||
openCurrentSong,
|
||||
libraryStore,
|
||||
artBroken,
|
||||
markArtBroken,
|
||||
playerArtSource,
|
||||
} from "../lib/state.svelte.js";
|
||||
import Icon from "./Icon.svelte";
|
||||
import SaveMenu from "./SaveMenu.svelte";
|
||||
import { floatScroll } from "../lib/scrollbar.js";
|
||||
|
||||
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
|
||||
// Gate stream-only sections (quality, source, Save) on the displayed
|
||||
// item's own stream field — the source of truth for what's on screen.
|
||||
// Library tracks never carry one, so they never show "Save to Library"
|
||||
// (and a track already in the library always plays locally, never streams).
|
||||
// player.track.source is derived and can lag across gapless boundaries.
|
||||
const isStream = $derived(!!currentItem?.stream);
|
||||
const quality = $derived(currentItem?.quality ?? null);
|
||||
|
||||
// Album context: the queue is a catalog album/continuation with >1 track.
|
||||
const albumMode = $derived(
|
||||
!!player.catalogRef.albumId && player.queue.length > 1,
|
||||
);
|
||||
|
||||
// Artist name for the Save action: album items carry "artist — album".
|
||||
const artistName = $derived.by(() => {
|
||||
const sub = currentItem?.sub ?? "";
|
||||
return sub.includes(" — ") ? sub.split(" — ")[0].trim() : sub;
|
||||
});
|
||||
// Artist / album split for the separate, clickable identity lines.
|
||||
const metaParts = $derived.by(() => {
|
||||
const sub = player.track?.sub ?? "";
|
||||
return sub.includes(" — ")
|
||||
? sub.split(" — ").map((s) => s.trim())
|
||||
: [sub.trim(), ""];
|
||||
});
|
||||
|
||||
const LOSSLESS = new Set(["FLAC", "WAV", "ALAC", "AIFF", "APE"]);
|
||||
|
||||
// Discovery: load more-from-artist / fans-also-like / about when the
|
||||
// playing artist changes, but only while the panel is visible. The old
|
||||
// always-mounted fetch could launch a full artist refinement in the
|
||||
// background while the user was opening an unrelated artist page.
|
||||
$effect(() => {
|
||||
const name = artistName;
|
||||
const visible = ui.nowPlaying;
|
||||
if (name && visible) {
|
||||
// loadDiscovery reads/writes its own reactive progress fields.
|
||||
// Without this boundary a failed/empty lookup makes `loading=false`
|
||||
// retrigger the effect indefinitely until Svelte aborts the flush.
|
||||
untrack(() => loadDiscovery(name));
|
||||
}
|
||||
});
|
||||
|
||||
function openDiscoveryArtist() {
|
||||
if (discovery.artistId == null) return;
|
||||
openCatalogArtist(discovery.artistId, {
|
||||
id: discovery.artistId,
|
||||
name: artistName,
|
||||
picture: discovery.picture,
|
||||
fans: discovery.fans,
|
||||
album_count: discovery.albumCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Collapse the bio back to the clamped state whenever the artist changes.
|
||||
let bioExpanded = $state(false);
|
||||
$effect(() => {
|
||||
discovery.artist;
|
||||
bioExpanded = false;
|
||||
});
|
||||
|
||||
// Keep the panel body mounted just long enough to finish the close slide,
|
||||
// then release its reactive waveform work and filtered layers entirely.
|
||||
// Reopening cancels the pending teardown and restores content immediately.
|
||||
let panelContentVisible = $state(ui.nowPlaying);
|
||||
$effect(() => {
|
||||
if (ui.nowPlaying) {
|
||||
panelContentVisible = true;
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => (panelContentVisible = false), 300);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
|
||||
// "More from": full albums ahead of EPs/singles, newest first within each
|
||||
// tier, drop the album that's playing now, cap at 6.
|
||||
const typeRank = (t) => (t === "album" ? 0 : t === "ep" ? 1 : 2);
|
||||
const moreAlbums = $derived.by(() => {
|
||||
const here = norm(currentItem?.sub?.split(" — ")[1] ?? "");
|
||||
return [...discovery.albums]
|
||||
.filter((a) => norm(a.title) !== here)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
typeRank(a.record_type) - typeRank(b.record_type) ||
|
||||
(b.release_date ?? "").localeCompare(a.release_date ?? ""),
|
||||
)
|
||||
.slice(0, 6);
|
||||
});
|
||||
|
||||
const relatedArtists = $derived(discovery.related.slice(0, 4));
|
||||
|
||||
// How many of this artist's albums are already in the local library.
|
||||
const inLibraryCount = $derived.by(() => {
|
||||
const a = norm(artistName);
|
||||
if (!a) return 0;
|
||||
return libraryStore.albums.filter((x) => norm(x.artist) === a).length;
|
||||
});
|
||||
|
||||
function albumYear(a) {
|
||||
return a.release_date ? a.release_date.slice(0, 4) : "";
|
||||
}
|
||||
function fmtFans(n) {
|
||||
if (!n) return "—";
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
|
||||
if (n >= 1e3) return `${Math.round(n / 1e3)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// Album art (local path or catalog CDN) → data URL, reused for the glow.
|
||||
let artUrl = $state(null);
|
||||
$effect(() => {
|
||||
// A closed panel should not duplicate PlayerBar's cold cover-art IPC or
|
||||
// retain a large data URL that has no visible consumer.
|
||||
const item = panelContentVisible ? currentItem : null;
|
||||
artUrl = null;
|
||||
if (!item) return;
|
||||
let active = true;
|
||||
if (item.art) {
|
||||
getArt(item.art).then((u) => active && (artUrl = u));
|
||||
} else if (item.artUrl) {
|
||||
getCatalogArt(playerArtSource(item)).then((u) => active && (artUrl = u));
|
||||
}
|
||||
return () => (active = false);
|
||||
});
|
||||
|
||||
const bufferPct = $derived(
|
||||
player.streamTotal > 0
|
||||
? Math.min(100, (player.streamReceived / player.streamTotal) * 100)
|
||||
: 0,
|
||||
);
|
||||
const playedPct = $derived(
|
||||
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
|
||||
);
|
||||
|
||||
// Average dense source peaks into fixed, equally sized display bars. Using
|
||||
// an average (rather than picking one peak) stops isolated transients from
|
||||
// making random bars hit the ceiling.
|
||||
// 78 × 2px bars plus 77 × 2px gaps = 310px. The sidebar's 312px
|
||||
// waveform lane can center that exact integer footprint, avoiding the
|
||||
// fractional gap rounding that made some neighboring bars look closer.
|
||||
const BARS = 78;
|
||||
const bars = $derived.by(() => {
|
||||
const w = player.waveform;
|
||||
if (!w || w.length === 0) return [];
|
||||
const out = [];
|
||||
for (let i = 0; i < BARS; i++) {
|
||||
const from = Math.floor((i / BARS) * w.length);
|
||||
const to = Math.max(from + 1, Math.floor(((i + 1) / BARS) * w.length));
|
||||
let sum = 0;
|
||||
for (let j = from; j < to; j++) sum += w[j] ?? 0;
|
||||
out.push(sum / (to - from));
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const playedBars = $derived((playedPct / 100) * bars.length);
|
||||
|
||||
function barHeight(value, index) {
|
||||
const capped = Math.pow(Math.min(0.88, value * 1.18), 0.8);
|
||||
// Stable per-bar grain keeps a dense loud passage from flattening
|
||||
// into a ruler of identical maximum-height bars. It is deterministic
|
||||
// (no visual flicker as the playhead moves) and only matters near the
|
||||
// cap, where source peaks otherwise converge on the same value.
|
||||
const grain = ((Math.sin((index + 1) * 12.9898) * 43758.5453) % 1 + 1) % 1;
|
||||
const peakFactor = Math.max(0, (capped - 0.58) / 0.3);
|
||||
const level = Math.max(0, Math.min(0.88, capped + (grain - 0.5) * 0.12 * peakFactor));
|
||||
return 14 + level * 72; // preserve headroom; never peak against the top
|
||||
}
|
||||
function barFill(index) {
|
||||
return Math.max(0, Math.min(1, playedBars - index));
|
||||
}
|
||||
|
||||
function fmtTime(s) {
|
||||
if (s == null || !isFinite(s)) return "–:––";
|
||||
s = Math.max(0, Math.floor(s));
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
function fmtSize(bytes) {
|
||||
if (!bytes) return null;
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
function fmtRate(q) {
|
||||
if (!q?.sampleRate) return null;
|
||||
const khz = (q.sampleRate / 1000).toFixed(1).replace(/\.0$/, "");
|
||||
return q.bitDepth ? `${khz} kHz · ${q.bitDepth}-bit` : `${khz} kHz`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="nowplaying" class:closed={!ui.nowPlaying}>
|
||||
<!-- Frosted-glass header: it overlays the scrolling content and blurs
|
||||
whatever sits beneath it, so the album art's colors seep through and
|
||||
shift as you scroll the art up under the glass. -->
|
||||
<header class="np-head" data-scrollhead>
|
||||
<span class="np-title">Now playing</span>
|
||||
<button class="np-icon" title="Hide panel" onclick={toggleNowPlaying}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="np-scroll" use:floatScroll>
|
||||
{#if panelContentVisible}
|
||||
{#if currentItem}
|
||||
<!-- glowing cover art -->
|
||||
<div class="hero">
|
||||
{#if artUrl}
|
||||
<img class="glow" src={artUrl} alt="" aria-hidden="true" draggable="false" />
|
||||
<div class="cover-wrap">
|
||||
<img class="cover" src={artUrl} alt="" draggable="false" />
|
||||
<button
|
||||
class="expand"
|
||||
title="View artwork"
|
||||
aria-label="View album artwork full screen"
|
||||
onclick={() => (ui.fullscreenArt = true)}
|
||||
>
|
||||
<Icon name="expand" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="cover placeholder"><Icon name="music" size={40} /></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- identity: song / artist / album, each its own link -->
|
||||
<div class="idn">
|
||||
<div class="idn-text">
|
||||
<button class="tt link" onclick={openCurrentSong} title={player.track?.title}>
|
||||
{player.track?.title ?? "—"}
|
||||
</button>
|
||||
{#if metaParts[0]}
|
||||
<button class="ar link" onclick={openCurrentArtist} title={metaParts[0]}>
|
||||
{metaParts[0]}
|
||||
</button>
|
||||
{/if}
|
||||
{#if metaParts[1]}
|
||||
<button class="ar link album" onclick={openCurrentAlbum} title={metaParts[1]}>
|
||||
{metaParts[1]}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- mini waveform + times -->
|
||||
{#if bars.length}
|
||||
<div class="wave" aria-hidden="true">
|
||||
{#each bars as v, i}
|
||||
<i
|
||||
style="height:{barHeight(v, i)}%; --fill:{barFill(i)}"
|
||||
></i>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="times">
|
||||
<span>{fmtTime(player.position)}</span>
|
||||
<span>{fmtTime(player.duration)}</span>
|
||||
</div>
|
||||
|
||||
<!-- quality chips -->
|
||||
{#if quality}
|
||||
<div class="chips">
|
||||
{#if quality.format}
|
||||
<span class="chip" class:loss={LOSSLESS.has(quality.format)}>
|
||||
{#if LOSSLESS.has(quality.format)}<span class="dot"></span>{/if}
|
||||
{quality.format}
|
||||
</span>
|
||||
{/if}
|
||||
{#if quality.bitrate}
|
||||
<span class="chip" class:loss={LOSSLESS.has(quality.format)}>
|
||||
{quality.bitrate} kbps{quality.vbr ? " VBR" : ""}
|
||||
</span>
|
||||
{/if}
|
||||
{#if fmtRate(quality)}<span class="chip">{fmtRate(quality)}</span>{/if}
|
||||
{#if fmtSize(quality.size)}<span class="chip">{fmtSize(quality.size)}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- source card (streaming only) -->
|
||||
{#if isStream && currentItem.stream}
|
||||
<div class="src">
|
||||
<div class="src-lab">Streaming from</div>
|
||||
<div class="peer">
|
||||
<div class="av">{currentItem.stream.username.slice(0, 1).toUpperCase()}</div>
|
||||
<div class="who" title={currentItem.stream.username}>{currentItem.stream.username}</div>
|
||||
</div>
|
||||
{#if player.streamTotal > 0}
|
||||
<div class="buf">
|
||||
<div class="bar"><i style="width:{bufferPct}%"></i></div>
|
||||
<div class="meta">
|
||||
<span>Buffered {(player.streamReceived / 1048576).toFixed(1)} / {(player.streamTotal / 1048576).toFixed(1)} MB</span>
|
||||
<span>{Math.round(bufferPct)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- save the file(s) you're already streaming (no re-search) -->
|
||||
<div class="actions-row">
|
||||
<SaveMenu align="down" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- queue / album tracklist -->
|
||||
{#if player.queue.length > 1}
|
||||
<div class="section">
|
||||
<div class="lab">{albumMode ? "From the album" : "Queue"}</div>
|
||||
<div class="list">
|
||||
{#each player.queue as item, i}
|
||||
<button class="row" class:now={i === player.queueIndex} onclick={() => jumpToQueueIndex(i)}>
|
||||
<span class="n">
|
||||
{#if i === player.queueIndex}
|
||||
<span
|
||||
class="eq"
|
||||
class:active={ui.nowPlaying && player.status === "playing" && ui.windowActive}
|
||||
><b></b><b></b><b></b></span>
|
||||
{:else}{i + 1}{/if}
|
||||
</span>
|
||||
<span class="nm" title={item.title}>{item.title}</span>
|
||||
<span class="du">{fmtTime(item.duration)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Discovery: more from the artist -->
|
||||
{#if moreAlbums.length}
|
||||
<div class="section">
|
||||
<div class="lab divlab">More from {artistName}</div>
|
||||
<div class="grid2">
|
||||
{#each moreAlbums as al}
|
||||
<button class="mini" title="Play {al.title}" onclick={() => streamCatalogAlbum(al)}>
|
||||
<div class="art">
|
||||
{#if al.cover && !artBroken[al.cover]}<img src={al.cover} alt="" loading="lazy" draggable="false" onerror={() => markArtBroken(al.cover)} />{/if}
|
||||
</div>
|
||||
<div class="mt">{al.title}</div>
|
||||
<div class="my">{albumYear(al)}{al.record_type && al.record_type !== "album" ? ` · ${al.record_type}` : ""}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Discovery: fans also like -->
|
||||
{#if relatedArtists.length}
|
||||
<div class="section">
|
||||
<div class="lab divlab">Fans also like</div>
|
||||
<div class="fans">
|
||||
{#each relatedArtists as ar}
|
||||
<button class="fan" title={ar.name} onclick={() => openCatalogArtist(ar.id, ar)}>
|
||||
<div class="av">
|
||||
{#if ar.picture}<img src={ar.picture} alt="" loading="lazy" draggable="false" />{/if}
|
||||
</div>
|
||||
<div class="fn">{ar.name}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Discovery: about the artist -->
|
||||
{#if discovery.artistId && (discovery.picture || discovery.albumCount)}
|
||||
<div class="section">
|
||||
<div class="lab divlab">About the artist</div>
|
||||
<div class="aboutcard">
|
||||
<button class="bandphoto" title="Open {artistName}" onclick={openDiscoveryArtist}>
|
||||
{#if discovery.picture}
|
||||
<img src={discovery.picture} alt="" loading="lazy" draggable="false" />
|
||||
{:else}
|
||||
<span class="ph"><Icon name="music" size={28} /></span>
|
||||
{/if}
|
||||
</button>
|
||||
<div class="about-text">
|
||||
<button class="nm" onclick={openDiscoveryArtist}>{artistName}</button>
|
||||
{#if discovery.bio}
|
||||
<div class="bio">
|
||||
<p class:clamped={!bioExpanded}>{discovery.bio}</p>
|
||||
<button class="more" onclick={() => (bioExpanded = !bioExpanded)}>
|
||||
{bioExpanded ? "Show less" : "Read more"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="about-body">
|
||||
<div class="st"><b>{discovery.albumCount ?? "—"}</b>releases</div>
|
||||
<div class="st"><b>{fmtFans(discovery.fans)}</b>fans</div>
|
||||
<div class="st"><b>{inLibraryCount}</b>in library</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<Icon name="music" size={30} />
|
||||
<span>Nothing playing</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.nowplaying {
|
||||
grid-area: nowplaying;
|
||||
background: var(--bg-player);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
/* Fixed width so the panel keeps its shape and slides off-screen when
|
||||
the grid column collapses to 0, rather than squishing its content.
|
||||
(The grid column uses --nowplaying-width, which animates 340→0.) */
|
||||
width: 340px;
|
||||
justify-self: start;
|
||||
visibility: visible;
|
||||
transition:
|
||||
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.24s ease,
|
||||
visibility 0s linear;
|
||||
}
|
||||
.nowplaying.closed {
|
||||
transform: translateX(calc(100% + 8px));
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.24s ease,
|
||||
visibility 0s linear 0.28s;
|
||||
}
|
||||
|
||||
/* Frosted-glass header floating over the scrolling content: backdrop-filter
|
||||
blurs whatever is beneath it, so the album colors seep through and shift
|
||||
as the art scrolls up under the glass. */
|
||||
.np-head {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 14px;
|
||||
z-index: 5;
|
||||
background: rgba(20, 20, 24, 0.28);
|
||||
backdrop-filter: blur(20px) saturate(1.7);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.7);
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.np-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.np-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.np-icon:hover { background: var(--bg-highlight); color: var(--text-primary); }
|
||||
|
||||
.np-scroll {
|
||||
overflow-y: auto;
|
||||
overflow-anchor: none;
|
||||
overscroll-behavior: none;
|
||||
/* Clear the 46px glass header so the art isn't overlapped at rest; the
|
||||
art still slides under the glass (tinting it) as you scroll. */
|
||||
padding: 54px 14px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Keep every section at its natural height and let the panel scroll —
|
||||
a flex column shrinks children by default, which collapsed the
|
||||
percentage-height waveform bars on shorter windows. */
|
||||
.np-scroll > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* glowing cover art (ambilight: a blurred copy of the same image) */
|
||||
.hero { position: relative; padding: 6px 4px 10px; }
|
||||
.glow {
|
||||
position: absolute;
|
||||
inset: -2% -2% 6%;
|
||||
z-index: 0;
|
||||
width: 104%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 30px;
|
||||
filter: blur(38px) saturate(1.4);
|
||||
opacity: 0.62;
|
||||
transform: scale(1.02);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cover-wrap { position: relative; z-index: 1; }
|
||||
.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;
|
||||
}
|
||||
/* Corner expand control, Spotify-style: a dark glass disc pinned inside
|
||||
the art's top-right corner, revealed on hover (or keyboard focus). */
|
||||
.expand {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 2;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
background: rgba(10, 10, 14, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
opacity: 0;
|
||||
transform: translateY(-3px) scale(0.9);
|
||||
transition: opacity 0.16s ease, transform 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
.cover-wrap:hover .expand,
|
||||
.expand:focus-visible {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.expand:hover { background: rgba(10, 10, 14, 0.85); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.expand { transition: opacity 0.16s ease; transform: none; }
|
||||
}
|
||||
.cover.placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.idn { display: flex; align-items: flex-start; gap: 10px; }
|
||||
.idn-text { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; }
|
||||
/* Each identity line is its own link — width-constrained so the hover
|
||||
underline hugs the text, and ellipsis-truncated. */
|
||||
.tt,
|
||||
.ar {
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
.tt {
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.tt.link:hover { text-decoration: underline; }
|
||||
.ar {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13.5px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.ar.link:hover { color: var(--text-primary); text-decoration: underline; }
|
||||
.ar.album {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.ar.album.link:hover { color: var(--text-primary); }
|
||||
|
||||
/* mini waveform */
|
||||
.wave {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: end;
|
||||
gap: 2px;
|
||||
height: 38px;
|
||||
}
|
||||
.wave i {
|
||||
flex: 0 0 2px;
|
||||
width: 2px;
|
||||
border-radius: 1px;
|
||||
background: color-mix(in srgb, var(--accent) calc(var(--fill) * 100%), var(--text-muted));
|
||||
min-height: 2px;
|
||||
}
|
||||
.times {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
/* quality chips */
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.chip {
|
||||
font-size: 12px;
|
||||
padding: 4px 11px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-primary);
|
||||
font-weight: 550;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.chip.loss { color: #4bd08a; }
|
||||
.chip .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
|
||||
/* source card */
|
||||
.src {
|
||||
background: var(--bg-elevated);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.src-lab {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.peer { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.av {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
flex: 0 0 auto;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: #04222f;
|
||||
}
|
||||
.who {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.buf { display: flex; flex-direction: column; gap: 5px; }
|
||||
.buf .bar { height: 6px; border-radius: 3px; background: var(--bg-highlight); overflow: hidden; }
|
||||
.buf .bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
|
||||
}
|
||||
.buf .meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* save action row */
|
||||
.actions-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* tracklist */
|
||||
.section { display: flex; flex-direction: column; gap: 10px; }
|
||||
.lab {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.list { display: flex; flex-direction: column; }
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.row:hover { background: var(--bg-highlight); }
|
||||
.row .n {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.row .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.row .du { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; flex: 0 0 auto; }
|
||||
.row.now { color: var(--accent); font-weight: 600; }
|
||||
.row.now .n { color: var(--accent); }
|
||||
|
||||
.eq { display: inline-flex; align-items: flex-end; gap: 1.5px; width: 14px; height: 11px; }
|
||||
.eq b { width: 2.5px; background: var(--accent); border-radius: 1px; }
|
||||
/* A stepped equalizer keeps the playing cue alive without asking Chromium
|
||||
to repaint the glass panel and artist page on every display frame. */
|
||||
.eq.active b { animation: eq 1.2s steps(1, end) infinite; }
|
||||
.eq b:nth-child(1) { height: 40%; animation-delay: -0.2s; }
|
||||
.eq b:nth-child(2) { height: 90%; }
|
||||
.eq b:nth-child(3) { height: 60%; animation-delay: -0.4s; }
|
||||
@keyframes eq { 0%, 100% { transform: scaleY(0.4); } 50% { transform: scaleY(1); } }
|
||||
@media (prefers-reduced-motion: reduce) { .eq.active b { animation: none; } }
|
||||
|
||||
/* discovery: more-from-artist grid */
|
||||
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
/* min-width:0 lets both columns stay an equal 1fr; without it a long
|
||||
(nowrap) title forces its column wider and shrinks the other. */
|
||||
.mini { text-align: left; min-width: 0; }
|
||||
.mini .art {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.mini .art img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.mini:hover .art { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.mini .mt {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
margin-top: 6px;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 6;
|
||||
}
|
||||
.mini .my { font-size: 11.5px; color: var(--text-muted); text-transform: capitalize; }
|
||||
|
||||
/* discovery: fans also like — a fixed row of 5 (no horizontal scroll,
|
||||
which always left one avatar half-cut at the edge). Explicit square
|
||||
avatars (aspect-ratio inside a flex row gets squished to an oval);
|
||||
space-between fits all five within the panel with no cutoff. */
|
||||
.fans { display: flex; justify-content: space-evenly; padding: 4px 0; }
|
||||
.fan { width: 52px; text-align: center; }
|
||||
.fan .av {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.fan .av img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.fan:hover .av { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.fan .fn {
|
||||
font-size: 11px;
|
||||
margin-top: 7px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* discovery: about the artist */
|
||||
.aboutcard {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
/* Full square Deezer image, uncropped (square img in a square box), with a
|
||||
tall bottom fade to the card colour so the photo melts into the text. */
|
||||
.bandphoto {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.bandphoto img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.bandphoto .ph {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.bandphoto::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
var(--bg-elevated) 6%,
|
||||
color-mix(in srgb, var(--bg-elevated) 55%, transparent) 22%,
|
||||
transparent 48%
|
||||
);
|
||||
}
|
||||
|
||||
/* Name + bio ride up into the faded bottom of the photo, so the text
|
||||
reads as emerging from the image. */
|
||||
.about-text {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: -52px;
|
||||
padding: 0 13px;
|
||||
}
|
||||
.about-text .nm {
|
||||
display: block;
|
||||
text-align: left;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.75);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.about-text .nm:hover { text-decoration: underline; }
|
||||
|
||||
.bio { padding: 0 0 2px; }
|
||||
.bio p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.bio p.clamped {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bio .more {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
.bio .more:hover { text-decoration: underline; }
|
||||
|
||||
.about-body { display: flex; gap: 18px; padding: 12px 13px 13px; }
|
||||
.about-body .st { font-size: 11px; color: var(--text-muted); }
|
||||
.about-body .st b {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 60px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user