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,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user