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:
KeelrLeek committed 2026-07-22 17:57:00 -05:00
commit a667b4b28e
161 files changed
+53170

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
<script>
import { getArt, ui } from "../lib/state.svelte.js";
import Icon from "./Icon.svelte";
let { album, onselect = null } = $props();
let art = $state(null);
$effect(() => {
art = null;
getArt(album.art_path).then((url) => (art = url));
});
function open() {
if (onselect) {
onselect(album);
return;
}
ui.view = "library";
ui.albumId = album.id;
}
</script>
<button class="card" type="button" onclick={open}>
{#if art}
<img class="art" src={art} alt="" draggable="false" />
{:else}
<div class="art placeholder"><Icon name="disc" size={40} /></div>
{/if}
<div class="card-title" title={album.title}>{album.title}</div>
<div class="card-sub">{album.year ? `${album.year} · ` : ""}{album.artist}</div>
</button>
<style>
.card {
display: flex;
flex-direction: column;
text-align: left;
background: var(--bg-elevated);
border-radius: var(--radius);
padding: 14px;
transition: background 0.2s;
cursor: pointer;
width: 100%;
/* Compact baseline for short titles; a five-line title is allowed to
grow beyond this instead of reserving its full height everywhere. */
min-height: 245px;
}
.card:hover {
background: var(--bg-highlight);
}
.art {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 6px;
margin-bottom: 12px;
}
.art.placeholder {
display: grid;
place-items: center;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
color: var(--text-muted);
font-size: 32px;
}
.card-title {
font-weight: 600;
line-height: 1.25;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 6;
}
.card-sub {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
+212
View File
@@ -0,0 +1,212 @@
<script>
// Full-window artwork viewer — a lightbox for the playing album's cover.
// Opened from the corner expand button on the Now Playing panel's art.
// No player chrome: the artwork itself, as large as the window allows,
// at the full resolution of the canonical cover (3000px-class for
// library albums). Click anywhere, Escape, or the corner button closes.
import { fade } from "svelte/transition";
import { player, ui, getArt, getCatalogArt, playerArtSource } from "../lib/state.svelte.js";
import Icon from "./Icon.svelte";
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
// Album art (local canonical file or catalog CDN) → data URL. The original
// stays untouched for the foreground; the ambient backdrop is baked once
// at 256px so opening the viewer does not create a viewport-scale blur
// surface in the compositor.
const AMBIENT_SIZE = 256;
const AMBIENT_BLUR = 18;
let artUrl = $state(null);
let ambientUrl = $state(null);
async function makeAmbient(src, isActive) {
const img = new Image();
img.decoding = "async";
img.src = src;
await img.decode();
if (!isActive()) return null;
const canvas = document.createElement("canvas");
canvas.width = AMBIENT_SIZE;
canvas.height = AMBIENT_SIZE;
const ctx = canvas.getContext("2d");
if (!ctx || !img.naturalWidth || !img.naturalHeight) return null;
// Match object-fit: cover for non-square artwork. The viewer's existing
// 112% overscan/scale crops away the small baked-blur edge just as it did
// for the old live CSS filter.
const scale = Math.max(
AMBIENT_SIZE / img.naturalWidth,
AMBIENT_SIZE / img.naturalHeight,
);
const width = img.naturalWidth * scale;
const height = img.naturalHeight * scale;
ctx.filter = `blur(${AMBIENT_BLUR}px) saturate(1.4) brightness(0.5)`;
ctx.drawImage(
img,
(AMBIENT_SIZE - width) / 2,
(AMBIENT_SIZE - height) / 2,
width,
height,
);
if (!isActive()) return null;
return canvas.toDataURL("image/jpeg", 0.82);
}
$effect(() => {
const open = ui.fullscreenArt;
const item = currentItem;
artUrl = null;
ambientUrl = null;
if (!open || !item) return;
let active = true;
const pending = item.art
? getArt(item.art)
: item.artUrl
? getCatalogArt(playerArtSource(item))
: Promise.resolve(null);
pending
.then(async (url) => {
if (!active || !url) return;
artUrl = url;
try {
const ambient = await makeAmbient(url, () => active);
if (active && ambient) ambientUrl = ambient;
} catch {
// The foreground remains fully usable if ambient baking
// fails for an unusual image payload.
}
})
.catch(() => {});
return () => (active = false);
});
// Nothing playing → nothing to view.
$effect(() => {
if (ui.fullscreenArt && !currentItem) ui.fullscreenArt = false;
});
function onKey(e) {
if (!ui.fullscreenArt) return;
if (e.key === "Escape") {
e.preventDefault();
ui.fullscreenArt = false;
}
}
</script>
<svelte:window onkeydown={onKey} />
{#if ui.fullscreenArt && currentItem}
<!-- The whole surface is one dismiss target, lightbox-style. -->
<div
class="viewer"
role="button"
tabindex="-1"
aria-label="Close artwork viewer"
transition:fade={{ duration: 160 }}
onclick={() => (ui.fullscreenArt = false)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") ui.fullscreenArt = false;
}}
>
{#if ambientUrl}
<img class="bg" src={ambientUrl} alt="" aria-hidden="true" draggable="false" />
{/if}
<div class="shade" aria-hidden="true"></div>
<button
class="close"
title="Close (Esc)"
onclick={(e) => {
e.stopPropagation();
ui.fullscreenArt = false;
}}
>
<Icon name="shrink" size={17} />
</button>
{#if artUrl}
<img class="art" src={artUrl} alt="Album artwork" draggable="false" />
{:else}
<div class="art placeholder"><Icon name="disc" size={72} /></div>
{/if}
</div>
{/if}
<style>
.viewer {
position: fixed;
inset: 0;
z-index: 100;
overflow: hidden;
background: #0b0b0e;
display: grid;
place-items: center;
cursor: zoom-out;
}
.bg {
position: absolute;
inset: -6%;
width: 112%;
height: 112%;
object-fit: cover;
transform: scale(1.06);
}
.shade {
position: absolute;
inset: 0;
background: radial-gradient(
120% 90% at 50% 45%,
transparent 40%,
rgba(6, 6, 9, 0.5) 100%
);
}
.close {
position: absolute;
top: 18px;
right: 18px;
z-index: 3;
width: 38px;
height: 38px;
border-radius: 50%;
display: grid;
place-items: center;
color: #fff;
background: rgba(10, 10, 14, 0.55);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
cursor: pointer;
transition: background 0.16s ease, transform 0.16s ease;
}
.close:hover { background: rgba(10, 10, 14, 0.85); transform: scale(1.06); }
/* The artwork owns the window: as large as the viewport allows with a
slim breathing margin, full native resolution behind it. */
.art {
position: relative;
z-index: 2;
width: min(94vh, 94vw);
aspect-ratio: 1;
object-fit: contain;
border-radius: 10px;
box-shadow: 0 48px 110px -34px rgba(0, 0, 0, 0.9);
background: transparent;
animation: settle 0.24s cubic-bezier(0.2, 0.7, 0.3, 1);
cursor: zoom-out;
}
.art.placeholder {
display: grid;
place-items: center;
color: var(--text-muted);
background: var(--bg-highlight);
}
@keyframes settle {
from { opacity: 0; transform: scale(0.965); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.art { animation: none; }
}
</style>
+255
View File
@@ -0,0 +1,255 @@
<script>
import { onDestroy, onMount } from "svelte";
import { getCatalogHeroGpuSource } from "../lib/state.svelte.js";
import { createHeroGpuRenderer } from "../lib/heroGpu.js";
let {
source = "",
scope = "",
plan = null,
active = true,
onloading = () => {},
onready = () => {},
onerror = () => {},
} = $props();
let canvas;
let bloomCanvas;
let previousCanvas;
let previousBloomCanvas;
let renderer = null;
let rendererError = null;
let rendererReady = $state(false);
let painted = $state(false);
let previousVisible = $state(false);
let previousFading = $state(false);
let generation = 0;
let transitionTimer;
let resizeObserver;
let renderedScope = null;
function reportFailure(error, failedSource = source) {
if (!failedSource) return;
onerror(failedSource, error instanceof Error ? error : new Error(String(error)));
}
onMount(() => {
try {
renderer = createHeroGpuRenderer(
canvas,
bloomCanvas,
previousCanvas,
previousBloomCanvas,
(error) => reportFailure(error),
);
resizeObserver = new ResizeObserver(() => renderer?.resize());
resizeObserver.observe(canvas);
rendererReady = true;
} catch (error) {
// Initialization often overlaps the art search, before `source`
// exists. Mark it settled so the first real source can trigger the
// normal fallback path instead of remaining on an empty canvas.
rendererError = error instanceof Error ? error : new Error(String(error));
rendererReady = true;
}
});
$effect(() => {
const selected = source;
const selectedScope = scope;
const cropPlan = plan;
const visible = active;
const ready = rendererReady;
// Start payload preparation even while WebGL programs are compiling.
// The keyed state cache makes the renderer-ready rerun share this work.
const payloadReady = selected && visible && (!ready || renderer)
? getCatalogHeroGpuSource(selected, cropPlan)
: null;
if (!selected || !visible) {
generation += 1;
clearTimeout(transitionTimer);
painted = false;
previousVisible = false;
previousFading = false;
if (ready && renderer) renderer.clear();
return;
}
if (!ready) return;
if (!renderer) {
reportFailure(rendererError ?? new Error("live GPU renderer unavailable"), selected);
return;
}
// Preserve carousel crossfades within one artist, but never hold the
// previous artist's finished frame while the next artist is processing.
if (renderedScope !== selectedScope) {
renderedScope = selectedScope;
generation += 1;
clearTimeout(transitionTimer);
painted = false;
previousVisible = false;
previousFading = false;
renderer.clear();
}
const request = ++generation;
let cancelled = false;
onloading(selected);
(async () => {
try {
const payload = await payloadReady;
if (!payload) throw new Error("live hero payload unavailable");
if (cancelled || request !== generation || source !== selected) return;
const result = renderer.render(payload);
if (cancelled || request !== generation || source !== selected) return;
canvas.dataset.heroGpu = "ready";
canvas.dataset.heroSource = selected;
canvas.dataset.heroRenderMs = result.renderMs.toFixed(2);
canvas.dataset.heroRenderer = result.renderer;
clearTimeout(transitionTimer);
if (result.hadPrevious) {
previousVisible = true;
previousFading = false;
requestAnimationFrame(() => {
if (request !== generation) return;
// Commit a style pass at opacity 1 before starting the
// fade: rAF callbacks run before style recalc, so
// without this forced reflow the visible+fading class
// changes collapse into one recalc (opacity 0 -> 0)
// and the 1.4s transition never fires.
void previousCanvas.offsetWidth;
previousFading = true;
});
transitionTimer = setTimeout(() => {
previousVisible = false;
previousFading = false;
}, 1_400);
}
painted = true;
onready(selected, result);
} catch (error) {
if (!cancelled && request === generation && source === selected) {
canvas.dataset.heroGpu = "failed";
reportFailure(error, selected);
}
}
})();
return () => {
cancelled = true;
};
});
onDestroy(() => {
generation += 1;
clearTimeout(transitionTimer);
resizeObserver?.disconnect();
renderer?.destroy();
});
</script>
<!-- Each `previous` canvas must come AFTER its `current` sibling: they share
a z-index, so DOM order decides stacking, and the outgoing frame has to
overlay the already-updated current canvas while it fades out. -->
<div class="gpu-hero" class:active={Boolean(source)} class:painted aria-hidden="true">
<canvas bind:this={bloomCanvas} class="bloom current"></canvas>
<canvas bind:this={previousBloomCanvas} class="bloom previous" class:fading={previousFading} class:visible={previousVisible}></canvas>
<canvas bind:this={canvas} class="banner current"></canvas>
<canvas bind:this={previousCanvas} class="banner previous" class:fading={previousFading} class:visible={previousVisible}></canvas>
</div>
<style>
.gpu-hero {
position: relative;
display: block;
z-index: 1;
/* The 70px header strip must be PADDING, not a margin on the canvas:
this box has no border, so a child margin-top would collapse through
it and drag the whole wrapper — including the top-0 bloom strip —
70px down. That left only the static seed gradient behind the
frosted results-head, and offset the previous canvas 70px below the
current one during crossfades. */
padding-top: 70px;
opacity: 0;
transition: opacity 180ms ease;
}
/* Keep the WebGL context/shaders warm while metadata is resolving without
contributing a second 16:9 box beside a provisional <img>. */
.gpu-hero:not(.active) {
position: absolute;
inset: 0;
opacity: 0;
}
.gpu-hero.painted {
opacity: 1;
}
.gpu-hero.painted:not(.active) {
opacity: 0;
}
.banner {
position: relative;
display: block;
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
max-height: 78vh;
z-index: 1;
}
.banner.previous {
position: absolute;
/* Matches the wrapper's 70px padding-top, so the outgoing frame sits
exactly over the in-flow current canvas. */
top: 70px;
left: 0;
opacity: 0;
pointer-events: none;
}
.banner.previous.visible {
opacity: 1;
}
.banner.previous.fading {
opacity: 0;
transition: opacity 1.4s ease;
}
.bloom {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 106px;
filter: blur(34px) saturate(1.6);
transform: scale(1.15);
opacity: 0.95;
z-index: 0;
}
.bloom.previous {
opacity: 0;
}
.bloom.previous.visible {
opacity: 0.95;
}
.bloom.previous.fading {
opacity: 0;
transition: opacity 1.4s ease;
}
@media (prefers-reduced-motion: reduce) {
.gpu-hero,
.banner.previous.fading,
.bloom.previous.fading {
transition-duration: 1ms;
}
}
</style>
+91
View File
@@ -0,0 +1,91 @@
<script>
// Lucide/Feather-style line icons, matching the TriSign app's look.
let { name, size = 20, strokeWidth = 2, fill = false } = $props();
// Icons drawn as solid shapes (playback transport).
const SOLID = new Set(["play", "pause", "skip-back", "skip-forward"]);
const solid = $derived(fill || SOLID.has(name));
const PATHS = {
home: '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
library: '<path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
minus: '<path d="M5 12h14"/>',
square: '<rect width="16" height="16" x="4" y="4" rx="2"/>',
restore: '<rect width="12" height="12" x="4" y="8" rx="2"/><path d="M8 4h10a2 2 0 0 1 2 2v10"/>',
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
"skip-back": '<polygon points="19 20 9 12 19 4 19 20"/><rect x="3" y="5" width="2.5" height="14" rx="1"/>',
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
pause: '<rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/>',
"skip-forward": '<polygon points="5 4 15 12 5 20 5 4"/><rect x="18.5" y="5" width="2.5" height="14" rx="1"/>',
volume: '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>',
"volume-low": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>',
"volume-mute": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="22" x2="16" y1="9" y2="15"/><line x1="16" x2="22" y1="9" y2="15"/>',
music: '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
disc: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="2"/>',
folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>',
"folder-open": '<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/>',
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
expand: '<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" x2="14" y1="3" y2="10"/><line x1="3" x2="10" y1="21" y2="14"/>',
shrink: '<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" x2="21" y1="10" y2="3"/><line x1="3" x2="10" y1="21" y2="14"/>',
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
// Lucide layers-2: its ink fills the viewBox symmetrically, so the
// glyph centers correctly inside small pills (the 3-layer variant
// rides high when trimmed).
layers: '<path d="m16.02 12 5.48 3.13a1 1 0 0 1 0 1.74L13 21.74a2 2 0 0 1-2 0l-8.5-4.87a1 1 0 0 1 0-1.74L7.98 12"/><path d="M13 13.74a2 2 0 0 1-2 0L2.5 8.87a1 1 0 0 1 0-1.74L11 2.26a2 2 0 0 1 2 0l8.5 4.87a1 1 0 0 1 0 1.74Z"/>',
refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
"list-plus": '<path d="M11 12H3"/><path d="M16 6H3"/><path d="M16 18H3"/><path d="M18 9v6"/><path d="M21 12h-6"/>',
shuffle: '<path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l6.1-8.6c.7-1.1 2-1.7 3.3-1.7H22"/><path d="m18 2 4 4-4 4"/><path d="M2 6h1.9c1.5 0 2.9.9 3.6 2.2"/><path d="M22 18h-5.9c-1.3 0-2.6-.7-3.3-1.8l-.5-.8"/><path d="m18 14 4 4-4 4"/>',
repeat: '<path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/>',
"repeat-one": '<path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/><path d="M11 10h1v4"/>',
"panel-right": '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/>',
// Preview-tracks launcher: a portrait card with a play mark. The reel
// variant (side rails either edge) muddies below ~16px, so the card
// outline and triangle are all the glyph keeps — it still has to be
// unmistakably NOT the transport triangle beside it.
"preview-reel": '<rect width="12" height="19" x="6" y="2.5" rx="2.5"/><path d="m10.6 8.8 4 3.2-4 3.2z"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
clock: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
trophy: '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><rect x="6" y="4" width="12" height="6" rx="1"/>',
tag: '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5"/>',
globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
"brand-instagram": '<rect width="20" height="20" x="2" y="2" rx="5" ry="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" x2="17.51" y1="6.5" y2="6.5"/>',
"brand-x": '<path d="M17.4 3H20l-6.9 7.9L21 21h-5.4l-4.2-5.5L6.5 21H4l7.4-8.4L3.5 3H9l3.8 5L17.4 3z"/>',
"brand-facebook": '<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/>',
"brand-tiktok": '<path d="M9 12a4 4 0 1 0 4 4V4a5 5 0 0 0 5 5"/>',
"brand-youtube": '<path d="M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.55 49.55 0 0 1-16.2 0A2 2 0 0 1 2.5 17"/><path d="m10 15 5-3-5-3z"/>',
"brand-bandcamp": '<path d="M22 7h-9.5L2 17h9.5L22 7z"/>',
"brand-soundcloud": '<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>',
"hard-drive": '<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>',
};
</script>
<svg
class="icon"
viewBox="0 0 24 24"
width={size}
height={size}
fill={solid ? "currentColor" : "none"}
stroke={solid ? "none" : "currentColor"}
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>{@html PATHS[name] ?? ""}</svg>
<style>
.icon {
display: block;
flex-shrink: 0;
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<script>
import { app, connect } from "../lib/state.svelte.js";
let username = $state("");
let password = $state("");
let remember = $state(true);
function submit(e) {
e.preventDefault();
if (!username || !password) return;
connect(username, password, remember);
}
</script>
{#if app.showLogin}
<div
class="backdrop"
onclick={(e) => { if (e.target === e.currentTarget) app.showLogin = false; }}
onkeydown={(e) => { if (e.key === "Escape") app.showLogin = false; }}
role="presentation"
>
<form class="modal" onsubmit={submit}>
<h2>Connect to Soulseek</h2>
<p class="hint">
Any username and password works — new accounts are created on
first login. Use something unique.
</p>
<label>
Username
<input bind:value={username} spellcheck="false" autocomplete="off" />
</label>
<label>
Password
<input type="password" bind:value={password} autocomplete="off" />
</label>
<label class="check">
<input type="checkbox" bind:checked={remember} />
Remember me on this computer
</label>
{#if app.loginError}
<p class="error">{app.loginError}</p>
{/if}
<button class="go" disabled={app.connection === "connecting"}>
{app.connection === "connecting" ? "Connecting…" : "Connect"}
</button>
</form>
</div>
{/if}
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: grid;
place-items: center;
z-index: 100;
}
.modal {
width: 360px;
background: var(--bg-elevated);
border-radius: 12px;
padding: 28px;
display: flex;
flex-direction: column;
gap: 14px;
}
h2 {
font-size: 20px;
}
.hint {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
label input:not([type="checkbox"]) {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-base);
color: var(--text-primary);
outline: none;
}
label input:focus {
border-color: var(--text-secondary);
}
.check {
flex-direction: row;
align-items: center;
gap: 8px;
font-weight: 400;
}
.error {
color: #f15e6c;
font-size: 12px;
}
.go {
margin-top: 6px;
padding: 12px;
border-radius: 24px;
background: var(--accent);
color: #000;
font-weight: 700;
transition: background 0.15s;
}
.go:hover:not(:disabled) {
background: var(--accent-hover);
}
.go:disabled {
opacity: 0.6;
cursor: default;
}
</style>
+902
View File
@@ -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>
+813
View File
@@ -0,0 +1,813 @@
<script>
import {
player,
theme,
ui,
nextTrack,
prevTrack,
toggleShuffle,
cycleRepeat,
toggleNowPlaying,
openCurrentArtist,
openCurrentAlbum,
openCurrentSong,
getArt,
getCatalogArt,
playerArtSource,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
import SaveMenu from "./SaveMenu.svelte";
const pct = $derived(
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
);
const bufferPct = $derived(
player.streamTotal > 0
? Math.min(100, (player.streamReceived / player.streamTotal) * 100)
: 0,
);
const hasWave = $derived(player.waveform.length > 0);
const idle = $derived(player.status === "idle");
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
// The native scratch engine needs a complete local file. A network stream
// keeps the reliable seek-on-drag behavior until it has been saved/cached.
const canScratch = $derived(!currentItem?.stream);
// Split "Artist — Album" for the separate, clickable meta lines.
const metaParts = $derived.by(() => {
const sub = player.track?.sub ?? "";
return sub.includes(" — ")
? sub.split(" — ").map((s) => s.trim())
: [sub.trim(), ""];
});
const volumeIcon = $derived(
player.muted || player.volume === 0
? "volume-mute"
: player.volume < 0.5
? "volume-low"
: "volume",
);
let artUrl = $state(null);
let volTrack = $state(null);
let draggingVolume = $state(false);
let draggingSeek = $state(false);
let lastAudibleVolume = 0.7;
let lastScrubSeekAt = 0;
let scrubStartX = 0;
let scrubLastX = 0;
let scrubLastAt = 0;
let scratchActive = false;
$effect(() => {
const item = currentItem;
artUrl = null;
if (!item) return;
let active = true;
if (item.art) {
getArt(item.art).then((url) => {
if (active) artUrl = url;
});
} else if (item.artUrl) {
// Streamed catalog track: cover comes from the catalog CDN, upgraded
// to the verified high-res source (Apple/CAA/Deezer-max) when ready.
getCatalogArt(playerArtSource(item)).then((url) => {
if (active) artUrl = url;
});
}
return () => (active = false);
});
// Waveform canvas + hover state.
let waveWrap = $state(null);
let waveCanvas = $state(null);
let hoverX = $state(-1);
const hoverTime = $derived(
waveWrap && player.duration ? (hoverX / waveWrap.clientWidth) * player.duration : 0,
);
// Canvas colors; played/hover follow the adaptive accent theme.
const UNPLAYED = "rgba(255,255,255,0.24)";
const PREVIEW = "rgba(255,255,255,0.66)";
const PLAYHEAD = "#ffffff";
function fmt(secs) {
if (secs == null || !isFinite(secs)) return "–:––";
const s = Math.max(0, Math.floor(secs));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function toggle() {
if (player.status === "playing") api.playerControl("pause");
else if (player.status === "paused") api.playerControl("resume");
}
function seekTarget(e, el) {
if (!player.duration || idle) return;
const rect = el.getBoundingClientRect();
const f = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
return f * player.duration;
}
function previewScrub(e, el) {
const target = seekTarget(e, el);
if (target == null) return;
player.position = target; // instant visual feedback while dragging
return target;
}
function regularScrub(target, force = false) {
const now = performance.now();
if (force || now - lastScrubSeekAt >= 75) {
lastScrubSeekAt = now;
api.playerControl("seek", target);
}
}
function startScrub(e, el) {
if (!player.duration || idle) return;
draggingSeek = true;
player.scrubbing = true;
el.setPointerCapture(e.pointerId);
scrubStartX = scrubLastX = e.clientX;
scrubLastAt = performance.now();
scratchActive = false;
previewScrub(e, el);
}
function moveScrub(e, el) {
if (!draggingSeek) return;
const target = previewScrub(e, el);
if (target == null) return;
const now = performance.now();
if (canScratch && Math.abs(e.clientX - scrubStartX) > 3) {
const activatingScratch = !scratchActive;
scratchActive = true;
const elapsed = Math.max(1, now - scrubLastAt);
// Map hand velocity (pixels/ms) to a musical 0.25×–4× rate.
const rate = Math.min(4, Math.max(0.25, Math.abs(e.clientX - scrubLastX) / elapsed * 1.6));
if (activatingScratch || now - lastScrubSeekAt >= 50) {
lastScrubSeekAt = now;
api.playerControl("scratch", target, e.clientX < scrubLastX ? -rate : rate);
}
} else if (!canScratch) {
regularScrub(target);
}
scrubLastX = e.clientX;
scrubLastAt = now;
}
function endScrub(e, el) {
if (!draggingSeek) return;
const target = previewScrub(e, el);
if (target != null) {
if (scratchActive) api.playerControl("end-scratch", target);
else regularScrub(target, true); // a simple click remains exact seek
}
draggingSeek = false;
player.scrubbing = false;
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
}
function onWaveHover(e) {
const rect = waveWrap.getBoundingClientRect();
hoverX = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
}
function setVolume(e) {
const rect = volTrack.getBoundingClientRect();
const frac = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
player.volume = frac;
player.muted = frac === 0;
if (frac > 0) lastAudibleVolume = frac;
api.playerControl("volume", frac);
}
function startVolumeDrag(e) {
draggingVolume = true;
volTrack.setPointerCapture(e.pointerId);
setVolume(e);
}
function endVolumeDrag(e) {
draggingVolume = false;
if (volTrack.hasPointerCapture(e.pointerId)) volTrack.releasePointerCapture(e.pointerId);
}
function volumeKeydown(e) {
const step = e.shiftKey ? 0.1 : 0.05;
if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
e.preventDefault();
setVolume({ clientX: volTrack.getBoundingClientRect().left + Math.max(0, player.volume - step) * volTrack.clientWidth });
} else if (e.key === "ArrowRight" || e.key === "ArrowUp") {
e.preventDefault();
setVolume({ clientX: volTrack.getBoundingClientRect().left + Math.min(1, player.volume + step) * volTrack.clientWidth });
} else if (e.key === "Home" || e.key === "End") {
e.preventDefault();
setVolume({ clientX: volTrack.getBoundingClientRect().left + (e.key === "Home" ? 0 : volTrack.clientWidth) });
}
}
function toggleMute() {
if (player.muted || player.volume === 0) {
const volume = lastAudibleVolume || 0.7;
player.volume = volume;
player.muted = false;
api.playerControl("volume", volume);
} else {
lastAudibleVolume = player.volume;
player.muted = true;
api.playerControl("volume", 0);
}
}
// Wave geometry is invariant while a track and player-bar size stay the
// same. Cache it instead of rebuilding ~1,600 line segments and resetting
// the canvas backing store on every 60 ms position event.
let wavePath = null;
let wavePathPeaks = null;
let wavePathWidth = 0;
let wavePathHeight = 0;
let lastPlayX = Number.NEGATIVE_INFINITY;
let lastHoverDrawn = Number.NEGATIVE_INFINITY;
let lastAccent = "";
let lastAccentHover = "";
let lastDuration = null;
const windowActive = $derived(ui.windowActive);
// Draw one solid mirrored waveform, colored by played / hover / unplayed,
// with a white playhead line and a green hover line.
function drawWave(force = false) {
// Position events still update playback/history while the window is
// minimized, but there is no visible canvas to repaint. Visibility
// return below forces one exact catch-up frame.
if (!windowActive) return;
const canvas = waveCanvas;
const wrap = waveWrap;
const peaks = player.waveform;
if (!canvas || !wrap || !peaks || peaks.length === 0) return;
const dpr = window.devicePixelRatio || 1;
const W = wrap.clientWidth;
const H = 26;
if (W <= 0) return;
const pixelWidth = Math.max(1, Math.round(W * dpr));
const pixelHeight = Math.max(1, Math.round(H * dpr));
const resized = canvas.width !== pixelWidth || canvas.height !== pixelHeight;
if (resized) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const N = peaks.length;
const mid = H / 2;
// Thin waveform floating in the middle: cap the amplitude well short
// of the edges so loud sections don't clip flat, and keep a small
// baseline so quiet sections still read as a hairline.
const maxAmp = 7;
const amp = (v) => Math.max(0.7, v * maxAmp);
const geometryChanged =
resized ||
wavePathPeaks !== peaks ||
wavePathWidth !== W ||
wavePathHeight !== H;
if (geometryChanged) {
const path = new Path2D();
path.moveTo(0, mid - amp(peaks[0]));
const divisor = Math.max(1, N - 1);
for (let i = 0; i < N; i++) {
path.lineTo((i / divisor) * W, mid - amp(peaks[i]));
}
for (let j = N - 1; j >= 0; j--) {
path.lineTo((j / divisor) * W, mid + amp(peaks[j]));
}
path.closePath();
wavePath = path;
wavePathPeaks = peaks;
wavePathWidth = W;
wavePathHeight = H;
}
const playX = player.duration
? Math.max(0, Math.min(W, (player.position / player.duration) * W))
: 0;
const visualsChanged =
geometryChanged ||
hoverX !== lastHoverDrawn ||
theme.accent !== lastAccent ||
theme.accentHover !== lastAccentHover ||
player.duration !== lastDuration;
// A sub-device-pixel playhead move cannot materially change the image.
// Keep exact redraws for hover, resize, theme, seeks, and short tracks.
if (!force && !visualsChanged && Math.abs(playX - lastPlayX) < 0.75 / dpr) return;
ctx.clearRect(0, 0, W, H);
const fillClip = (x0, x1, color) => {
if (x1 <= x0) return;
ctx.save();
ctx.beginPath();
ctx.rect(x0, 0, x1 - x0, H);
ctx.clip();
ctx.fillStyle = color;
ctx.fill(wavePath);
ctx.restore();
};
fillClip(0, W, UNPLAYED);
if (hoverX > playX) fillClip(playX, hoverX, PREVIEW);
// Match the primary Play button and the sidebar's completed bars.
// `accentHover` remains reserved for hover previews and controls.
fillClip(0, playX, theme.accent);
// Playhead / hover markers: fixed height, padded off the edges,
// so they read as small ticks rather than full-height rules.
const LINE_H = 18;
const lineY = (H - LINE_H) / 2;
ctx.fillStyle = PLAYHEAD;
ctx.fillRect(playX - 0.75, lineY, 1.5, LINE_H);
if (hoverX >= 0) {
ctx.fillStyle = theme.accentHover;
ctx.fillRect(hoverX - 0.5, lineY, 1, LINE_H);
}
lastPlayX = playX;
lastHoverDrawn = hoverX;
lastAccent = theme.accent;
lastAccentHover = theme.accentHover;
lastDuration = player.duration;
}
// Redraw whenever the waveform, position, hover, or accent changes
// (the accent lerps every frame during a crossfade, animating the fill).
$effect(() => {
player.waveform;
player.position;
player.duration;
hoverX;
theme.accent;
theme.accentHover;
drawWave();
});
// Redraw on player-bar resize.
$effect(() => {
if (!waveWrap) return;
const ro = new ResizeObserver(() => drawWave(true));
ro.observe(waveWrap);
return () => ro.disconnect();
});
</script>
<footer class="player">
<div class="now-playing">
{#if artUrl}
<img class="art cover" src={artUrl} alt="" draggable="false" />
{:else}
<div class="art placeholder" class:pulsing={player.status === "buffering"}>
{#if player.track}<Icon name="music" size={22} />{/if}
</div>
{/if}
<div class="meta">
{#if player.error}
<span class="title err">⚠ {player.error}</span>
{:else if player.track}
<button class="title link" onclick={openCurrentSong} title={player.track.title}>
{player.track.title}
</button>
{:else}
<span class="title muted">Nothing playing</span>
{/if}
{#if player.status === "buffering"}
<span class="sub">{metaParts[0] || "buffering…"}</span>
{:else if player.track}
{#if metaParts[0]}
<button class="sub link" onclick={openCurrentArtist} title={metaParts[0]}>
{metaParts[0]}
</button>
{/if}
{#if metaParts[1]}
<button class="sub link album" onclick={openCurrentAlbum} title={metaParts[1]}>
{metaParts[1]}
</button>
{/if}
{/if}
</div>
</div>
<div class="controls">
<div class="buttons">
<button
class="ctl small"
class:on={player.shuffle}
title="Shuffle"
onclick={toggleShuffle}
><Icon name="shuffle" size={16} /></button>
<button class="ctl" title="Previous" onclick={prevTrack} disabled={idle}>
<Icon name="skip-back" size={18} />
</button>
<button
class="play"
title="Play/Pause"
onclick={toggle}
disabled={idle || player.status === "buffering"}
>
<Icon name={player.status === "playing" ? "pause" : "play"} size={16} />
</button>
<button class="ctl" title="Next" onclick={() => nextTrack(false)} disabled={idle}>
<Icon name="skip-forward" size={18} />
</button>
<button
class="ctl small"
class:on={player.repeat !== "off"}
title={player.repeat === "one" ? "Repeat one" : player.repeat === "all" ? "Repeat all" : "Repeat"}
onclick={cycleRepeat}
><Icon name={player.repeat === "one" ? "repeat-one" : "repeat"} size={16} /></button>
</div>
<div class="scrubber">
<span class="time">{fmt(player.position)}</span>
{#if hasWave}
<div
class="wave-wrap"
bind:this={waveWrap}
role="slider"
aria-label="Seek"
aria-valuenow={Math.round(pct)}
tabindex="0"
onmousemove={onWaveHover}
onmouseleave={() => (hoverX = -1)}
class:dragging={draggingSeek}
onpointerdown={(e) => startScrub(e, waveWrap)}
onpointermove={(e) => moveScrub(e, waveWrap)}
onpointerup={(e) => endScrub(e, waveWrap)}
onpointercancel={(e) => endScrub(e, waveWrap)}
>
<canvas bind:this={waveCanvas} class="wave-canvas"></canvas>
{#if hoverX >= 0}
<div class="wave-tip" style="left: {hoverX}px">{fmt(hoverTime)}</div>
{/if}
</div>
{:else}
<div
class="track"
class:dragging={draggingSeek}
onpointerdown={(e) => startScrub(e, e.currentTarget)}
onpointermove={(e) => moveScrub(e, e.currentTarget)}
onpointerup={(e) => endScrub(e, e.currentTarget)}
onpointercancel={(e) => endScrub(e, e.currentTarget)}
role="slider"
aria-label="Seek"
aria-valuenow={Math.round(pct)}
tabindex="0"
>
{#if bufferPct > 0 && player.track?.source === "stream"}
<div class="buffer" style="width: {bufferPct}%"></div>
{/if}
<div class="fill" style="width: {pct}%"></div>
</div>
{/if}
<span class="time">{fmt(player.duration)}</span>
</div>
</div>
<div class="extras">
<SaveMenu align="up" />
<button
class="vol-icon"
class:on={ui.nowPlaying}
title="Now playing"
onclick={toggleNowPlaying}
>
<Icon name="panel-right" size={17} />
</button>
<button class="vol-icon" title={player.muted ? "Unmute" : "Mute"} onclick={toggleMute}>
<Icon name={volumeIcon} size={18} />
</button>
<div
class="track vol"
bind:this={volTrack}
class:dragging={draggingVolume}
onpointerdown={startVolumeDrag}
onpointermove={(e) => draggingVolume && setVolume(e)}
onpointerup={endVolumeDrag}
onpointercancel={endVolumeDrag}
onkeydown={volumeKeydown}
role="slider"
aria-label="Volume"
aria-valuenow={Math.round((player.muted ? 0 : player.volume) * 100)}
tabindex="0"
>
<div class="fill" style="width: {(player.muted ? 0 : player.volume) * 100}%"></div>
<div class="vol-thumb" style="left: {(player.muted ? 0 : player.volume) * 100}%"></div>
</div>
</div>
</footer>
<style>
.player {
grid-area: player;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 0 16px;
background: var(--bg-player);
border-radius: var(--radius);
}
.now-playing {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.art {
width: 56px;
height: 56px;
border-radius: 4px;
flex-shrink: 0;
display: grid;
place-items: center;
color: var(--text-muted);
font-size: 20px;
}
.art.placeholder {
background: var(--bg-highlight);
}
.art.cover {
object-fit: cover;
background: var(--bg-highlight);
}
.art.pulsing {
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
50% { opacity: 0.5; }
}
.meta {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
}
/* Separate, clickable meta lines. Buttons are width-constrained so the
hover underline only spans the text, and ellipsis-truncate. */
.title,
.sub {
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
display: block;
}
.title {
font-weight: 600;
}
.title.muted { color: var(--text-secondary); }
.title.err { color: #f15e6c; }
.title.link:hover { text-decoration: underline; }
.sub {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.35;
}
.sub.link:hover {
color: var(--text-primary);
text-decoration: underline;
}
.sub.album {
font-size: 11px;
color: var(--text-muted);
}
.sub.album.link:hover { color: var(--text-primary); }
.controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: min(45vw, 560px);
}
.buttons {
display: flex;
align-items: center;
gap: 16px;
}
.ctl {
display: grid;
place-items: center;
color: var(--text-secondary);
}
.ctl.small {
color: var(--text-muted);
}
.ctl:disabled {
opacity: 0.3;
}
.ctl:hover:not(:disabled) {
color: var(--text-primary);
}
/* shuffle / repeat active state */
.ctl.on {
color: var(--accent-hover);
position: relative;
}
.ctl.on::after {
content: "";
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent-hover);
}
.play {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--text-primary);
color: #000;
font-size: 14px;
transition: transform 0.1s;
}
.play:disabled {
opacity: 0.4;
}
.play:hover:not(:disabled) {
transform: scale(1.06);
}
/* Fixed height so swapping between the thin bar and the waveform
canvas never reflows the buttons above. */
.scrubber {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 26px;
}
.time {
font-size: 11px;
color: var(--text-secondary);
min-width: 32px;
text-align: center;
}
.track {
flex: 1;
height: 4px;
border-radius: 2px;
background: hsla(0, 0%, 100%, 0.2);
overflow: hidden;
position: relative;
cursor: pointer;
touch-action: none;
}
.track:hover {
height: 6px;
}
.track.dragging {
height: 6px;
}
.buffer {
position: absolute;
inset: 0 auto 0 0;
background: hsla(0, 0%, 100%, 0.25);
border-radius: 2px;
}
.fill {
position: relative;
height: 100%;
background: var(--text-primary);
border-radius: 2px;
}
.track:hover .fill {
background: var(--accent);
}
/* waveform scrubber */
.wave-wrap {
position: relative;
flex: 1;
height: 26px;
cursor: pointer;
touch-action: none;
}
.wave-canvas {
display: block;
width: 100%;
height: 100%;
}
.wave-tip {
position: absolute;
top: -22px;
transform: translateX(-50%);
background: #000;
color: #fff;
font-size: 11px;
padding: 2px 7px;
border-radius: 5px;
white-space: nowrap;
pointer-events: none;
}
.extras {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.vol-icon {
display: grid;
place-items: center;
font-size: 13px;
color: var(--text-secondary);
}
.vol-icon:hover {
color: var(--text-primary);
}
.vol-icon.on {
color: var(--accent-hover);
}
.track.vol {
flex: 0 0 90px;
overflow: visible;
touch-action: none;
}
.track.vol::before {
content: "";
position: absolute;
inset: -8px 0;
}
.track.vol .fill {
pointer-events: none;
}
.vol-thumb {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--text-primary);
transform: translate(-50%, -50%) scale(0);
transition: transform 0.12s ease;
pointer-events: none;
}
.track.vol:hover .vol-thumb,
.track.vol.dragging .vol-thumb,
.track.vol:focus-visible .vol-thumb {
transform: translate(-50%, -50%) scale(1);
}
.track.vol:hover,
.track.vol.dragging {
height: 5px;
}
.track.vol:hover .fill,
.track.vol.dragging .fill {
background: var(--accent);
}
</style>
+166
View File
@@ -0,0 +1,166 @@
<script>
import { player, saveStream } from "../lib/state.svelte.js";
import Icon from "./Icon.svelte";
// align hint: "down" (panel) or "up" (player bar). Auto-flips if needed.
let { align = "down" } = $props();
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
const isStream = $derived(!!currentItem?.stream);
const streamCount = $derived(player.queue.filter((it) => it.stream).length);
const isAlbum = $derived(streamCount > 1);
let triggerEl = $state(null);
let open = $state(false);
let pos = $state({ top: 0, left: 0 });
let toast = $state("");
let toastPos = $state({ top: 0, left: 0 });
let toastTimer;
const MENU_W = 190;
function place() {
const r = triggerEl.getBoundingClientRect();
const h = 92; // two items
let left = Math.max(8, Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8));
let top = align === "up" ? r.top - 6 - h : r.bottom + 6;
if (align !== "up" && top + h > window.innerHeight - 8) top = r.top - 6 - h;
if (align === "up" && top < 8) top = r.bottom + 6;
pos = { top, left };
}
async function save(scope) {
open = false;
const res = await saveStream(scope);
if (!res.msg) return;
const r = triggerEl.getBoundingClientRect();
toastPos = {
top: align === "up" ? r.top - 34 : r.bottom + 6,
left: Math.max(8, r.right - 190),
};
toast = res.msg;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast = ""), 2600);
}
function onClick(e) {
e.stopPropagation();
// Single track: one destination, save straight away. Album: choose scope.
if (!isAlbum) {
save("track");
return;
}
if (open) {
open = false;
return;
}
place();
open = true;
}
$effect(() => {
if (!open) return;
const onDoc = (e) => {
if (!e.target.closest(".save-wrap") && !e.target.closest(".save-pop")) open = false;
};
const close = () => (open = false);
document.addEventListener("click", onDoc, true);
document.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("click", onDoc, true);
document.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
});
</script>
{#if isStream}
<span class="save-wrap">
<button
bind:this={triggerEl}
class="save-icon"
class:on={open}
title="Save to library"
onclick={onClick}
>
<Icon name="download" size={18} />
</button>
</span>
{#if open}
<div class="save-pop menu" style="top:{pos.top}px; left:{pos.left}px;">
<button class="mi" onclick={() => save("track")}>
<Icon name="music" size={16} /> Save this track
</button>
<button class="mi" onclick={() => save("album")}>
<Icon name="library" size={16} /> Save whole album
</button>
</div>
{/if}
{#if toast}
<div class="save-pop toast" style="top:{toastPos.top}px; left:{toastPos.left}px;">
{toast}
</div>
{/if}
{/if}
<style>
.save-wrap { display: inline-flex; }
.save-icon {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 8px;
color: var(--text-secondary);
}
.save-icon:hover,
.save-icon.on {
color: var(--text-primary);
background: var(--bg-highlight);
}
/* Fixed positioning escapes the panel's overflow clipping and any
stacking context, so the popover always sits on top. */
.save-pop {
position: fixed;
z-index: 1000;
}
.menu {
width: 190px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
box-shadow: 0 16px 38px -12px rgba(0, 0, 0, 0.75);
}
.mi {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px 10px;
border-radius: 6px;
font-size: 13px;
color: var(--text-primary);
text-align: left;
}
.mi:hover { background: var(--bg-highlight); }
.mi :global(.icon) { color: var(--text-secondary); flex: 0 0 auto; }
.toast {
white-space: nowrap;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 8px;
padding: 7px 12px;
font-size: 12px;
color: var(--text-primary);
box-shadow: 0 12px 26px -10px rgba(0, 0, 0, 0.75);
}
</style>
+276
View File
@@ -0,0 +1,276 @@
<script>
import { app, ui, network, playlistStore, refreshPlaylists, toggleSidebar } from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
function navigate(id) {
ui.view = id;
if (id !== "library") ui.albumId = null;
}
async function newPlaylist() {
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
const id = await api.createPlaylist(name);
await refreshPlaylists();
ui.view = "playlist";
ui.playlistId = id;
}
function openPlaylist(id) {
ui.view = "playlist";
ui.playlistId = id;
}
const items = [
{ id: "home", label: "Home", icon: "home" },
{ id: "search", label: "Search", icon: "search" },
{ id: "library", label: "Your Library", icon: "library" },
{ id: "downloads", label: "Downloads", icon: "download" },
{ id: "settings", label: "Settings", icon: "settings" },
];
</script>
<nav class="sidebar" class:collapsed={ui.sidebarCollapsed}>
<div class="sb-top">
<button
class="collapse-btn"
title={ui.sidebarCollapsed ? "Expand" : "Collapse"}
onclick={toggleSidebar}
>
<Icon name="chevron-left" size={20} />
</button>
</div>
<ul>
{#each items as item (item.id)}
<li>
<button
class="nav-item"
class:active={ui.view === item.id}
title={ui.sidebarCollapsed ? item.label : null}
onclick={() => navigate(item.id)}
>
<span class="icon"><Icon name={item.icon} size={22} /></span>
<span class="label">{item.label}</span>
</button>
</li>
{/each}
</ul>
<div class="playlists">
<div class="pl-head">
<span class="label">Playlists</span>
<button class="pl-new" title="New playlist" onclick={newPlaylist}>
<Icon name="plus" size={16} />
</button>
</div>
<div class="pl-list">
{#each playlistStore.playlists as pl (pl.id)}
<button
class="pl-item"
class:active={ui.view === "playlist" && ui.playlistId === pl.id}
onclick={() => openPlaylist(pl.id)}
>
<span class="pl-name">{pl.name}</span>
<span class="pl-count">{pl.track_count}</span>
</button>
{/each}
</div>
</div>
<div class="footer">
{#if app.connection === "online" && network.shareFiles > 0}
<div class="share-info" title="Files you share with the network">
sharing {network.shareFiles} files
</div>
{/if}
</div>
</nav>
<style>
.sidebar {
grid-area: sidebar;
background: var(--bg-base);
border-radius: var(--radius);
display: flex;
flex-direction: column;
padding: 12px 8px;
overflow: hidden;
}
ul {
list-style: none;
}
.sb-top {
display: flex;
justify-content: flex-end;
padding: 0 4px 6px;
}
.collapse-btn {
width: 30px;
height: 30px;
border-radius: 6px;
display: grid;
place-items: center;
color: var(--text-secondary);
flex: 0 0 auto;
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1), color 0.15s;
}
.collapse-btn:hover {
color: var(--text-primary);
background: var(--bg-highlight);
}
/* Label collapse: fades and zeroes width so icons stay put. */
.label {
white-space: nowrap;
overflow: hidden;
max-width: 160px;
opacity: 1;
transition: opacity 0.15s ease, max-width 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ---- collapsed icon-rail state ---- */
.sidebar.collapsed .sb-top {
justify-content: center;
}
.sidebar.collapsed .collapse-btn {
transform: rotate(180deg);
}
.sidebar.collapsed .label {
opacity: 0;
max-width: 0;
}
.sidebar.collapsed .nav-item {
justify-content: center;
gap: 0;
padding: 10px 0;
}
.sidebar.collapsed .pl-head {
justify-content: center;
padding: 0 0 8px;
}
.sidebar.collapsed .pl-list {
display: none;
}
.sidebar.collapsed .footer {
opacity: 0;
}
.playlists {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.pl-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px 8px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
}
.pl-new {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--bg-highlight);
color: var(--text-secondary);
font-size: 16px;
display: grid;
place-items: center;
}
.pl-new:hover {
color: var(--text-primary);
background: var(--bg-press);
}
.pl-list {
overflow-y: auto;
flex: 1;
}
.pl-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 7px 12px;
border-radius: 5px;
color: var(--text-secondary);
font-size: 13px;
text-align: left;
}
.pl-item:hover {
color: var(--text-primary);
}
.pl-item.active {
background: var(--bg-highlight);
color: var(--text-primary);
}
.pl-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pl-count {
font-size: 11px;
color: var(--text-muted);
flex-shrink: 0;
}
.nav-item {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 10px 12px;
border-radius: 6px;
color: var(--text-secondary);
font-weight: 600;
transition: color 0.15s;
}
.nav-item:hover {
color: var(--text-primary);
}
.nav-item.active {
color: var(--text-primary);
background: var(--bg-highlight);
}
.icon {
width: 22px;
display: grid;
place-items: center;
}
.footer {
padding: 8px 4px;
}
.share-info {
padding: 2px 8px;
font-size: 11px;
color: var(--text-muted);
}
</style>
+598
View File
@@ -0,0 +1,598 @@
<script>
import {
app,
ui,
downloads,
catalogGo,
openCatalogArtist,
warmCatalogArtist,
cancelCatalogArtistWarmup,
streamSong,
streamCatalogAlbum,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
let maximized = $state(false);
let searchText = $state("");
// ---- download status ring (aggregate progress + active count) ----
const DL_ACTIVE = new Set(["connecting", "requested", "queued", "transferring"]);
const RING_C = 2 * Math.PI * 9; // circumference of the r=9 progress circle
// The ring aggregates over the whole download burst, not just the
// currently-active transfers — otherwise each finished file drops out
// of the fraction and the ring jumps backward instead of filling.
// A burst spans from the first active download (ids are monotonic, so
// members are `id >= session start`) until the queue drains.
let dlSession = $state({ min: null });
const dl = $derived.by(() => {
let active = 0;
let received = 0;
let total = 0;
for (const d of Object.values(downloads.items)) {
const isActive = DL_ACTIVE.has(d.state);
if (isActive) active += 1;
if (!d.total) continue;
if (isActive) {
received += d.received;
total += d.total;
} else if (
d.state === "complete" &&
dlSession.min != null &&
d.id >= dlSession.min
) {
received += d.total;
total += d.total;
}
}
return { active, frac: total > 0 ? Math.min(1, received / total) : 0 };
});
// When the queue drains, flash a full ring + check before hiding, then
// close out the burst.
let dlFlash = $state(false);
let dlHadActive = false;
$effect(() => {
if (dl.active > 0) {
dlHadActive = true;
dlFlash = false;
if (dlSession.min == null) {
let min = Infinity;
for (const d of Object.values(downloads.items)) {
if (DL_ACTIVE.has(d.state)) min = Math.min(min, d.id);
}
if (min < Infinity) dlSession.min = min;
}
} else if (dlHadActive) {
dlHadActive = false;
dlFlash = true;
const t = setTimeout(() => {
dlFlash = false;
dlSession.min = null;
}, 2500);
return () => clearTimeout(t);
}
});
// ---- live search suggestions (Spotify-style dropdown) ----
let sugg = $state(null); // last catalogSearch results for the dropdown
let suggOpen = $state(false);
let hi = $state(-1); // highlighted row index, -1 = none
let suggSeq = 0;
let debounceTimer = null;
const suggRows = $derived.by(() => {
if (!sugg) return [];
const rows = [];
for (const a of sugg.artists.slice(0, 2)) {
rows.push({ kind: "artist", label: a.name, sub: "Artist", img: a.picture, round: true, data: a });
}
for (const al of sugg.albums.slice(0, 3)) {
const year = al.release_date ? ` · ${al.release_date.slice(0, 4)}` : "";
rows.push({ kind: "album", label: al.title, sub: `Album · ${al.artist}${year}`, img: al.cover, round: false, data: al });
}
for (const t of sugg.tracks.slice(0, 4)) {
rows.push({ kind: "track", label: t.title, sub: `Song · ${t.artist}`, img: t.cover, round: false, data: t });
}
return rows;
});
function onSearchInput() {
clearTimeout(debounceTimer);
cancelCatalogArtistWarmup();
const q = searchText.trim();
if (q.length < 2) {
suggOpen = false;
sugg = null;
return;
}
debounceTimer = setTimeout(async () => {
const seq = ++suggSeq;
try {
const res = await api.catalogSearch(q);
// Ignore stale responses and results for superseded queries.
if (seq !== suggSeq || searchText.trim() !== q) return;
sugg = res;
hi = -1;
suggOpen = true;
// The first artist is overwhelmingly the next click. Warm its
// one-round-trip overview and art resolver while the user is
// still reading the dropdown; keyed caches share the work.
if (res.artists?.[0]) warmCatalogArtist(res.artists[0]);
} catch {
// backend unavailable — no suggestions
}
}, 250);
}
function closeSuggest() {
suggOpen = false;
hi = -1;
}
function pickRow(row) {
closeSuggest();
cancelCatalogArtistWarmup();
if (row.kind === "artist") openCatalogArtist(row.data.id, row.data);
else if (row.kind === "album") streamCatalogAlbum(row.data);
else {
const t = row.data;
streamSong(t.artist, t.title, {
cover: t.cover,
trackId: t.id,
albumId: t.album_id,
album: t.album || null,
expectSecs: t.duration_secs ?? null,
});
}
}
function searchKeydown(e) {
if (!suggOpen || suggRows.length === 0) {
if (e.key === "Escape") closeSuggest();
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
hi = (hi + 1) % suggRows.length;
} else if (e.key === "ArrowUp") {
e.preventDefault();
hi = hi <= 0 ? suggRows.length - 1 : hi - 1;
} else if (e.key === "Enter" && hi >= 0) {
e.preventDefault();
pickRow(suggRows[hi]);
} else if (e.key === "Escape") {
closeSuggest();
}
}
function searchFocusOut(e) {
// Keep the panel open while focus moves within the search form.
if (!e.currentTarget.contains(e.relatedTarget)) closeSuggest();
}
function submitSearch(e) {
e.preventDefault();
clearTimeout(debounceTimer);
suggSeq += 1; // cancel any in-flight suggestion fetch
closeSuggest();
catalogGo(searchText);
}
async function syncMaximized() {
try {
maximized = await api.win.isMaximized();
} catch {
// not running inside Tauri (browser preview)
}
}
$effect(() => {
let unlisten = null;
syncMaximized();
api.win
.onResized(syncMaximized)
.then((f) => (unlisten = f))
.catch(() => {});
return () => unlisten?.();
});
function statusClick() {
if (app.connection === "online") {
api.disconnect();
app.connection = "offline";
} else if (app.connection === "offline") {
app.showLogin = true;
}
}
</script>
<header class="titlebar" data-tauri-drag-region>
<div class="brand">
<img class="logo" src="/logo.png" alt="" draggable="false" />
<span class="name">SoulStream</span>
</div>
<button
class="status {app.connection}"
onclick={statusClick}
title={app.connection === "online" ? "Click to disconnect" : "Click to connect"}
>
● {app.connection === "online" ? app.username : app.connection}
</button>
<form class="bar-search" onsubmit={submitSearch} onfocusout={searchFocusOut}>
<Icon name="search" size={13} />
<input
type="text"
placeholder="Search artists, albums, songs…"
bind:value={searchText}
oninput={onSearchInput}
onkeydown={searchKeydown}
onfocus={() => { if (sugg && searchText.trim().length >= 2) suggOpen = true; }}
spellcheck="false"
/>
{#if suggOpen && suggRows.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="suggest" onmousedown={(e) => e.preventDefault()}>
{#each suggRows as row, i (row.kind + row.data.id)}
<button
type="button"
class="sugg-row"
class:active={i === hi}
onmouseenter={() => (hi = i)}
onclick={() => pickRow(row)}
>
{#if row.img}
<img class="sugg-img" class:round={row.round} src={row.img} alt="" draggable="false" />
{:else}
<div class="sugg-img placeholder" class:round={row.round}>
<Icon name="music" size={13} />
</div>
{/if}
<span class="sugg-text">
<span class="sugg-label">{row.label}</span>
<span class="sugg-sub">{row.sub}</span>
</span>
{#if row.kind !== "artist"}
<span class="sugg-play"><Icon name="play" size={12} /></span>
{/if}
</button>
{/each}
<button type="button" class="sugg-row all" class:active={hi === -1} onclick={submitSearch}>
<span class="sugg-all-icon"><Icon name="search" size={13} /></span>
<span class="sugg-text">
<span class="sugg-label">See all results for “{searchText.trim()}”</span>
<span class="sugg-sub">Enter</span>
</span>
</button>
</div>
{/if}
</form>
<div class="controls">
{#if dl.active > 0 || dlFlash}
<button
class="dl-ring"
title={dlFlash
? "Downloads finished"
: `${dl.active} downloading — open Downloads`}
onclick={() => (ui.view = "downloads")}
>
<svg width="26" height="26" viewBox="0 0 26 26">
<circle class="track" cx="13" cy="13" r="9" />
<circle
class="prog"
cx="13"
cy="13"
r="9"
stroke-dasharray={RING_C}
stroke-dashoffset={dlFlash ? 0 : RING_C * (1 - dl.frac)}
transform="rotate(-90 13 13)"
/>
{#if dlFlash}
<path class="tick" d="M9 13.4l2.7 2.7 5.2-5.6" />
{:else}
<text x="13" y="16.5" text-anchor="middle">
{dl.active > 9 ? "9+" : dl.active}
</text>
{/if}
</svg>
</button>
{/if}
<button class="win-btn" title="Minimize" onclick={() => api.win.minimize()}>
<Icon name="minus" size={16} />
</button>
<button
class="win-btn"
title={maximized ? "Restore" : "Maximize"}
onclick={() => api.win.toggleMaximize().then(syncMaximized)}
>
<Icon name={maximized ? "restore" : "square"} size={13} />
</button>
<button class="win-btn close" title="Close" onclick={() => api.win.close()}>
<Icon name="x" size={16} />
</button>
</div>
</header>
<style>
/* Lines the brand up with the sidebar's content column below: the bar
already sits inside the 8px window frame, so 20px more reaches the
8px sidebar padding + 12px item padding. */
.titlebar {
grid-area: titlebar;
position: relative;
/* Above the sibling content panels, so the search suggestions
dropdown paints over album grids instead of interleaving. */
z-index: 100;
display: flex;
align-items: center;
gap: 10px;
padding-left: 20px;
user-select: none;
-webkit-user-select: none;
}
/* The visible strip above the panels is the 30px bar row plus the 8px
grid gap; nudge the left cluster down so it centers optically in that
full strip. The window controls stay flush with the top edge. */
.brand,
.status {
position: relative;
top: 4px;
}
/* Decorative: let mouse events fall through to the drag region. */
.brand {
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
}
.logo {
width: 20px;
height: 20px;
border-radius: 5px;
}
.name {
font-weight: 700;
font-size: 13px;
}
.status {
font-size: 12px;
color: var(--text-muted);
padding: 3px 9px;
border-radius: 10px;
}
.status:hover {
background: var(--bg-highlight);
color: var(--text-secondary);
}
.status.online {
color: var(--accent-hover);
}
.status.connecting {
color: #e8a33d;
}
/* Centered on the window regardless of the left cluster's width; the
same +4px optical nudge as the brand. Dragging still works on the
uncovered bar either side. */
.bar-search {
position: absolute;
left: 50%;
top: 7px;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
width: min(360px, 30vw);
height: 24px;
padding: 0 12px;
border-radius: 12px;
background: var(--bg-elevated);
color: var(--text-muted);
}
.bar-search:focus-within {
outline: 1px solid var(--text-muted);
color: var(--text-secondary);
}
.bar-search input {
flex: 1;
min-width: 0;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: 12px;
}
.bar-search input::placeholder {
color: var(--text-muted);
}
/* ---- live suggestions dropdown ---- */
.suggest {
position: absolute;
top: 30px;
left: -8px;
right: -8px;
display: flex;
flex-direction: column;
padding: 6px;
border-radius: 10px;
background: var(--bg-elevated);
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.55);
z-index: 50;
}
.sugg-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 5px 8px;
border-radius: 7px;
text-align: left;
color: var(--text-primary);
}
.sugg-row.active {
background: var(--bg-highlight);
}
.sugg-img {
width: 30px;
height: 30px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
background: var(--bg-highlight);
}
.sugg-img.round {
border-radius: 50%;
}
.sugg-img.placeholder {
display: grid;
place-items: center;
color: var(--text-muted);
}
.sugg-text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.sugg-label {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sugg-sub {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sugg-play {
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
color: var(--text-muted);
opacity: 0;
flex-shrink: 0;
}
.sugg-row:hover .sugg-play,
.sugg-row.active .sugg-play {
opacity: 1;
color: var(--text-primary);
}
.sugg-row.all {
margin-top: 2px;
border-top: 1px solid var(--bg-highlight);
border-radius: 0 0 7px 7px;
color: var(--text-secondary);
}
.sugg-row.all.active,
.sugg-row.all:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
.sugg-all-icon {
display: grid;
place-items: center;
width: 30px;
height: 30px;
flex-shrink: 0;
color: var(--text-muted);
}
.controls {
margin-left: auto;
display: flex;
align-self: stretch;
}
/* download status ring */
.dl-ring {
align-self: center;
display: grid;
place-items: center;
width: 34px;
height: 30px;
margin-right: 4px;
border-radius: 7px;
}
.dl-ring:hover {
background: var(--bg-highlight);
}
.dl-ring .track {
fill: none;
stroke: rgba(255, 255, 255, 0.16);
stroke-width: 2.5;
}
.dl-ring .prog {
fill: none;
stroke: var(--accent-hover);
stroke-width: 2.5;
stroke-linecap: round;
transition: stroke-dashoffset 0.4s ease;
}
.dl-ring text {
fill: var(--text-primary);
font-size: 10px;
font-weight: 700;
}
.dl-ring .tick {
fill: none;
stroke: var(--accent-hover);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.win-btn {
width: 46px;
display: grid;
place-items: center;
color: var(--text-secondary);
border-radius: 0;
}
.win-btn:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
.win-btn.close:hover {
background: #c42b1c;
color: #fff;
}
</style>