Baseline: full tree snapshot after git repository loss

The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
KeelrLeek committed 2026-07-22 17:57:00 -05:00
commit a667b4b28e
161 files changed
+53170

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
<script>
import Sidebar from "./components/Sidebar.svelte";
import TitleBar from "./components/TitleBar.svelte";
import PlayerBar from "./components/PlayerBar.svelte";
import NowPlaying from "./components/NowPlaying.svelte";
import ArtworkViewer from "./components/ArtworkViewer.svelte";
import LoginModal from "./components/LoginModal.svelte";
import Home from "./views/Home.svelte";
import Search from "./views/Search.svelte";
import Library from "./views/Library.svelte";
import Downloads from "./views/Downloads.svelte";
import Settings from "./views/Settings.svelte";
import Playlist from "./views/Playlist.svelte";
import CatalogSearch from "./views/CatalogSearch.svelte";
import CatalogArtist from "./views/CatalogArtist.svelte";
import CatalogAlbum from "./views/CatalogAlbum.svelte";
import {
ui,
initEvents,
autoConnect,
refreshLibrary,
refreshPlaylists,
} from "./lib/state.svelte.js";
import { floatScroll } from "./lib/scrollbar.js";
const views = {
home: Home,
search: Search,
library: Library,
downloads: Downloads,
settings: Settings,
playlist: Playlist,
catalog: CatalogSearch,
catalogArtist: CatalogArtist,
catalogAlbum: CatalogAlbum,
};
const CurrentView = $derived(views[ui.view] ?? Home);
initEvents();
autoConnect();
refreshLibrary();
refreshPlaylists();
</script>
<div class="app" class:np-open={ui.nowPlaying} class:sidebar-collapsed={ui.sidebarCollapsed}>
<TitleBar />
<Sidebar />
<main class="content" use:floatScroll>
<CurrentView />
</main>
<NowPlaying />
<PlayerBar />
<ArtworkViewer />
<LoginModal />
</div>
<style>
/* Frameless window: the titlebar row is part of the app background —
no separate chrome color, the panels float beneath it. */
.app {
display: grid;
grid-template-columns: var(--sidebar-width) 1fr var(--nowplaying-width);
grid-template-rows: 30px 1fr var(--player-height);
grid-template-areas:
"titlebar titlebar titlebar"
"sidebar content nowplaying"
"player player player";
height: 100%;
gap: 8px;
padding: 0 8px 8px;
background: var(--bg-sidebar);
overflow: hidden;
transition: grid-template-columns 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Collapsed left nav: narrow the sidebar column to an icon rail. The
grid-template-columns transition animates the reflow. */
.app.sidebar-collapsed {
--sidebar-width: 64px;
}
/* Now Playing panel slides away: the column collapses to zero (the panel
itself keeps its width and slides off-screen — see NowPlaying.svelte). */
.app:not(.np-open) {
--nowplaying-width: 0px;
}
.content {
grid-area: content;
background: linear-gradient(var(--bg-highlight) 0, var(--bg-base) 240px);
/* The highlight wash belongs to the top of the PAGE, not the viewport:
anchored to the pane (default `scroll`), the gradient stays put
while content scrolls, so the hero's scrim — which dissolves into
pure bg-base — ends on a lighter backdrop mid-gradient and paints a
hard seam at the hero's bottom edge. `local` scrolls the gradient
away with the content, keeping the seam colors identical at every
scroll position. */
background-attachment: local;
border-radius: var(--radius);
overflow-y: auto;
overflow-anchor: none;
/* Main scroll pane: no elastic rubber-band at its edges. Inner regions
deliberately do NOT set this, so their wheel chains here. */
overscroll-behavior: none;
padding: 24px 28px;
}
</style>
+88
View File
@@ -0,0 +1,88 @@
<script>
import { getArt, ui } from "../lib/state.svelte.js";
import Icon from "./Icon.svelte";
let { album, onselect = null } = $props();
let art = $state(null);
$effect(() => {
art = null;
getArt(album.art_path).then((url) => (art = url));
});
function open() {
if (onselect) {
onselect(album);
return;
}
ui.view = "library";
ui.albumId = album.id;
}
</script>
<button class="card" type="button" onclick={open}>
{#if art}
<img class="art" src={art} alt="" draggable="false" />
{:else}
<div class="art placeholder"><Icon name="disc" size={40} /></div>
{/if}
<div class="card-title" title={album.title}>{album.title}</div>
<div class="card-sub">{album.year ? `${album.year} · ` : ""}{album.artist}</div>
</button>
<style>
.card {
display: flex;
flex-direction: column;
text-align: left;
background: var(--bg-elevated);
border-radius: var(--radius);
padding: 14px;
transition: background 0.2s;
cursor: pointer;
width: 100%;
/* Compact baseline for short titles; a five-line title is allowed to
grow beyond this instead of reserving its full height everywhere. */
min-height: 245px;
}
.card:hover {
background: var(--bg-highlight);
}
.art {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 6px;
margin-bottom: 12px;
}
.art.placeholder {
display: grid;
place-items: center;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
color: var(--text-muted);
font-size: 32px;
}
.card-title {
font-weight: 600;
line-height: 1.25;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 6;
}
.card-sub {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
+212
View File
@@ -0,0 +1,212 @@
<script>
// Full-window artwork viewer — a lightbox for the playing album's cover.
// Opened from the corner expand button on the Now Playing panel's art.
// No player chrome: the artwork itself, as large as the window allows,
// at the full resolution of the canonical cover (3000px-class for
// library albums). Click anywhere, Escape, or the corner button closes.
import { fade } from "svelte/transition";
import { player, ui, getArt, getCatalogArt, playerArtSource } from "../lib/state.svelte.js";
import Icon from "./Icon.svelte";
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
// Album art (local canonical file or catalog CDN) → data URL. The original
// stays untouched for the foreground; the ambient backdrop is baked once
// at 256px so opening the viewer does not create a viewport-scale blur
// surface in the compositor.
const AMBIENT_SIZE = 256;
const AMBIENT_BLUR = 18;
let artUrl = $state(null);
let ambientUrl = $state(null);
async function makeAmbient(src, isActive) {
const img = new Image();
img.decoding = "async";
img.src = src;
await img.decode();
if (!isActive()) return null;
const canvas = document.createElement("canvas");
canvas.width = AMBIENT_SIZE;
canvas.height = AMBIENT_SIZE;
const ctx = canvas.getContext("2d");
if (!ctx || !img.naturalWidth || !img.naturalHeight) return null;
// Match object-fit: cover for non-square artwork. The viewer's existing
// 112% overscan/scale crops away the small baked-blur edge just as it did
// for the old live CSS filter.
const scale = Math.max(
AMBIENT_SIZE / img.naturalWidth,
AMBIENT_SIZE / img.naturalHeight,
);
const width = img.naturalWidth * scale;
const height = img.naturalHeight * scale;
ctx.filter = `blur(${AMBIENT_BLUR}px) saturate(1.4) brightness(0.5)`;
ctx.drawImage(
img,
(AMBIENT_SIZE - width) / 2,
(AMBIENT_SIZE - height) / 2,
width,
height,
);
if (!isActive()) return null;
return canvas.toDataURL("image/jpeg", 0.82);
}
$effect(() => {
const open = ui.fullscreenArt;
const item = currentItem;
artUrl = null;
ambientUrl = null;
if (!open || !item) return;
let active = true;
const pending = item.art
? getArt(item.art)
: item.artUrl
? getCatalogArt(playerArtSource(item))
: Promise.resolve(null);
pending
.then(async (url) => {
if (!active || !url) return;
artUrl = url;
try {
const ambient = await makeAmbient(url, () => active);
if (active && ambient) ambientUrl = ambient;
} catch {
// The foreground remains fully usable if ambient baking
// fails for an unusual image payload.
}
})
.catch(() => {});
return () => (active = false);
});
// Nothing playing → nothing to view.
$effect(() => {
if (ui.fullscreenArt && !currentItem) ui.fullscreenArt = false;
});
function onKey(e) {
if (!ui.fullscreenArt) return;
if (e.key === "Escape") {
e.preventDefault();
ui.fullscreenArt = false;
}
}
</script>
<svelte:window onkeydown={onKey} />
{#if ui.fullscreenArt && currentItem}
<!-- The whole surface is one dismiss target, lightbox-style. -->
<div
class="viewer"
role="button"
tabindex="-1"
aria-label="Close artwork viewer"
transition:fade={{ duration: 160 }}
onclick={() => (ui.fullscreenArt = false)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") ui.fullscreenArt = false;
}}
>
{#if ambientUrl}
<img class="bg" src={ambientUrl} alt="" aria-hidden="true" draggable="false" />
{/if}
<div class="shade" aria-hidden="true"></div>
<button
class="close"
title="Close (Esc)"
onclick={(e) => {
e.stopPropagation();
ui.fullscreenArt = false;
}}
>
<Icon name="shrink" size={17} />
</button>
{#if artUrl}
<img class="art" src={artUrl} alt="Album artwork" draggable="false" />
{:else}
<div class="art placeholder"><Icon name="disc" size={72} /></div>
{/if}
</div>
{/if}
<style>
.viewer {
position: fixed;
inset: 0;
z-index: 100;
overflow: hidden;
background: #0b0b0e;
display: grid;
place-items: center;
cursor: zoom-out;
}
.bg {
position: absolute;
inset: -6%;
width: 112%;
height: 112%;
object-fit: cover;
transform: scale(1.06);
}
.shade {
position: absolute;
inset: 0;
background: radial-gradient(
120% 90% at 50% 45%,
transparent 40%,
rgba(6, 6, 9, 0.5) 100%
);
}
.close {
position: absolute;
top: 18px;
right: 18px;
z-index: 3;
width: 38px;
height: 38px;
border-radius: 50%;
display: grid;
place-items: center;
color: #fff;
background: rgba(10, 10, 14, 0.55);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
cursor: pointer;
transition: background 0.16s ease, transform 0.16s ease;
}
.close:hover { background: rgba(10, 10, 14, 0.85); transform: scale(1.06); }
/* The artwork owns the window: as large as the viewport allows with a
slim breathing margin, full native resolution behind it. */
.art {
position: relative;
z-index: 2;
width: min(94vh, 94vw);
aspect-ratio: 1;
object-fit: contain;
border-radius: 10px;
box-shadow: 0 48px 110px -34px rgba(0, 0, 0, 0.9);
background: transparent;
animation: settle 0.24s cubic-bezier(0.2, 0.7, 0.3, 1);
cursor: zoom-out;
}
.art.placeholder {
display: grid;
place-items: center;
color: var(--text-muted);
background: var(--bg-highlight);
}
@keyframes settle {
from { opacity: 0; transform: scale(0.965); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.art { animation: none; }
}
</style>
+255
View File
@@ -0,0 +1,255 @@
<script>
import { onDestroy, onMount } from "svelte";
import { getCatalogHeroGpuSource } from "../lib/state.svelte.js";
import { createHeroGpuRenderer } from "../lib/heroGpu.js";
let {
source = "",
scope = "",
plan = null,
active = true,
onloading = () => {},
onready = () => {},
onerror = () => {},
} = $props();
let canvas;
let bloomCanvas;
let previousCanvas;
let previousBloomCanvas;
let renderer = null;
let rendererError = null;
let rendererReady = $state(false);
let painted = $state(false);
let previousVisible = $state(false);
let previousFading = $state(false);
let generation = 0;
let transitionTimer;
let resizeObserver;
let renderedScope = null;
function reportFailure(error, failedSource = source) {
if (!failedSource) return;
onerror(failedSource, error instanceof Error ? error : new Error(String(error)));
}
onMount(() => {
try {
renderer = createHeroGpuRenderer(
canvas,
bloomCanvas,
previousCanvas,
previousBloomCanvas,
(error) => reportFailure(error),
);
resizeObserver = new ResizeObserver(() => renderer?.resize());
resizeObserver.observe(canvas);
rendererReady = true;
} catch (error) {
// Initialization often overlaps the art search, before `source`
// exists. Mark it settled so the first real source can trigger the
// normal fallback path instead of remaining on an empty canvas.
rendererError = error instanceof Error ? error : new Error(String(error));
rendererReady = true;
}
});
$effect(() => {
const selected = source;
const selectedScope = scope;
const cropPlan = plan;
const visible = active;
const ready = rendererReady;
// Start payload preparation even while WebGL programs are compiling.
// The keyed state cache makes the renderer-ready rerun share this work.
const payloadReady = selected && visible && (!ready || renderer)
? getCatalogHeroGpuSource(selected, cropPlan)
: null;
if (!selected || !visible) {
generation += 1;
clearTimeout(transitionTimer);
painted = false;
previousVisible = false;
previousFading = false;
if (ready && renderer) renderer.clear();
return;
}
if (!ready) return;
if (!renderer) {
reportFailure(rendererError ?? new Error("live GPU renderer unavailable"), selected);
return;
}
// Preserve carousel crossfades within one artist, but never hold the
// previous artist's finished frame while the next artist is processing.
if (renderedScope !== selectedScope) {
renderedScope = selectedScope;
generation += 1;
clearTimeout(transitionTimer);
painted = false;
previousVisible = false;
previousFading = false;
renderer.clear();
}
const request = ++generation;
let cancelled = false;
onloading(selected);
(async () => {
try {
const payload = await payloadReady;
if (!payload) throw new Error("live hero payload unavailable");
if (cancelled || request !== generation || source !== selected) return;
const result = renderer.render(payload);
if (cancelled || request !== generation || source !== selected) return;
canvas.dataset.heroGpu = "ready";
canvas.dataset.heroSource = selected;
canvas.dataset.heroRenderMs = result.renderMs.toFixed(2);
canvas.dataset.heroRenderer = result.renderer;
clearTimeout(transitionTimer);
if (result.hadPrevious) {
previousVisible = true;
previousFading = false;
requestAnimationFrame(() => {
if (request !== generation) return;
// Commit a style pass at opacity 1 before starting the
// fade: rAF callbacks run before style recalc, so
// without this forced reflow the visible+fading class
// changes collapse into one recalc (opacity 0 -> 0)
// and the 1.4s transition never fires.
void previousCanvas.offsetWidth;
previousFading = true;
});
transitionTimer = setTimeout(() => {
previousVisible = false;
previousFading = false;
}, 1_400);
}
painted = true;
onready(selected, result);
} catch (error) {
if (!cancelled && request === generation && source === selected) {
canvas.dataset.heroGpu = "failed";
reportFailure(error, selected);
}
}
})();
return () => {
cancelled = true;
};
});
onDestroy(() => {
generation += 1;
clearTimeout(transitionTimer);
resizeObserver?.disconnect();
renderer?.destroy();
});
</script>
<!-- Each `previous` canvas must come AFTER its `current` sibling: they share
a z-index, so DOM order decides stacking, and the outgoing frame has to
overlay the already-updated current canvas while it fades out. -->
<div class="gpu-hero" class:active={Boolean(source)} class:painted aria-hidden="true">
<canvas bind:this={bloomCanvas} class="bloom current"></canvas>
<canvas bind:this={previousBloomCanvas} class="bloom previous" class:fading={previousFading} class:visible={previousVisible}></canvas>
<canvas bind:this={canvas} class="banner current"></canvas>
<canvas bind:this={previousCanvas} class="banner previous" class:fading={previousFading} class:visible={previousVisible}></canvas>
</div>
<style>
.gpu-hero {
position: relative;
display: block;
z-index: 1;
/* The 70px header strip must be PADDING, not a margin on the canvas:
this box has no border, so a child margin-top would collapse through
it and drag the whole wrapper — including the top-0 bloom strip —
70px down. That left only the static seed gradient behind the
frosted results-head, and offset the previous canvas 70px below the
current one during crossfades. */
padding-top: 70px;
opacity: 0;
transition: opacity 180ms ease;
}
/* Keep the WebGL context/shaders warm while metadata is resolving without
contributing a second 16:9 box beside a provisional <img>. */
.gpu-hero:not(.active) {
position: absolute;
inset: 0;
opacity: 0;
}
.gpu-hero.painted {
opacity: 1;
}
.gpu-hero.painted:not(.active) {
opacity: 0;
}
.banner {
position: relative;
display: block;
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
max-height: 78vh;
z-index: 1;
}
.banner.previous {
position: absolute;
/* Matches the wrapper's 70px padding-top, so the outgoing frame sits
exactly over the in-flow current canvas. */
top: 70px;
left: 0;
opacity: 0;
pointer-events: none;
}
.banner.previous.visible {
opacity: 1;
}
.banner.previous.fading {
opacity: 0;
transition: opacity 1.4s ease;
}
.bloom {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 106px;
filter: blur(34px) saturate(1.6);
transform: scale(1.15);
opacity: 0.95;
z-index: 0;
}
.bloom.previous {
opacity: 0;
}
.bloom.previous.visible {
opacity: 0.95;
}
.bloom.previous.fading {
opacity: 0;
transition: opacity 1.4s ease;
}
@media (prefers-reduced-motion: reduce) {
.gpu-hero,
.banner.previous.fading,
.bloom.previous.fading {
transition-duration: 1ms;
}
}
</style>
+91
View File
@@ -0,0 +1,91 @@
<script>
// Lucide/Feather-style line icons, matching the TriSign app's look.
let { name, size = 20, strokeWidth = 2, fill = false } = $props();
// Icons drawn as solid shapes (playback transport).
const SOLID = new Set(["play", "pause", "skip-back", "skip-forward"]);
const solid = $derived(fill || SOLID.has(name));
const PATHS = {
home: '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
library: '<path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
minus: '<path d="M5 12h14"/>',
square: '<rect width="16" height="16" x="4" y="4" rx="2"/>',
restore: '<rect width="12" height="12" x="4" y="8" rx="2"/><path d="M8 4h10a2 2 0 0 1 2 2v10"/>',
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
"skip-back": '<polygon points="19 20 9 12 19 4 19 20"/><rect x="3" y="5" width="2.5" height="14" rx="1"/>',
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
pause: '<rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/>',
"skip-forward": '<polygon points="5 4 15 12 5 20 5 4"/><rect x="18.5" y="5" width="2.5" height="14" rx="1"/>',
volume: '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>',
"volume-low": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>',
"volume-mute": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="22" x2="16" y1="9" y2="15"/><line x1="16" x2="22" y1="9" y2="15"/>',
music: '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
disc: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="2"/>',
folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>',
"folder-open": '<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/>',
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
expand: '<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" x2="14" y1="3" y2="10"/><line x1="3" x2="10" y1="21" y2="14"/>',
shrink: '<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" x2="21" y1="10" y2="3"/><line x1="3" x2="10" y1="21" y2="14"/>',
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
// Lucide layers-2: its ink fills the viewBox symmetrically, so the
// glyph centers correctly inside small pills (the 3-layer variant
// rides high when trimmed).
layers: '<path d="m16.02 12 5.48 3.13a1 1 0 0 1 0 1.74L13 21.74a2 2 0 0 1-2 0l-8.5-4.87a1 1 0 0 1 0-1.74L7.98 12"/><path d="M13 13.74a2 2 0 0 1-2 0L2.5 8.87a1 1 0 0 1 0-1.74L11 2.26a2 2 0 0 1 2 0l8.5 4.87a1 1 0 0 1 0 1.74Z"/>',
refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
"list-plus": '<path d="M11 12H3"/><path d="M16 6H3"/><path d="M16 18H3"/><path d="M18 9v6"/><path d="M21 12h-6"/>',
shuffle: '<path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l6.1-8.6c.7-1.1 2-1.7 3.3-1.7H22"/><path d="m18 2 4 4-4 4"/><path d="M2 6h1.9c1.5 0 2.9.9 3.6 2.2"/><path d="M22 18h-5.9c-1.3 0-2.6-.7-3.3-1.8l-.5-.8"/><path d="m18 14 4 4-4 4"/>',
repeat: '<path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/>',
"repeat-one": '<path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/><path d="M11 10h1v4"/>',
"panel-right": '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/>',
// Preview-tracks launcher: a portrait card with a play mark. The reel
// variant (side rails either edge) muddies below ~16px, so the card
// outline and triangle are all the glyph keeps — it still has to be
// unmistakably NOT the transport triangle beside it.
"preview-reel": '<rect width="12" height="19" x="6" y="2.5" rx="2.5"/><path d="m10.6 8.8 4 3.2-4 3.2z"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
clock: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
trophy: '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><rect x="6" y="4" width="12" height="6" rx="1"/>',
tag: '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5"/>',
globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
"brand-instagram": '<rect width="20" height="20" x="2" y="2" rx="5" ry="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" x2="17.51" y1="6.5" y2="6.5"/>',
"brand-x": '<path d="M17.4 3H20l-6.9 7.9L21 21h-5.4l-4.2-5.5L6.5 21H4l7.4-8.4L3.5 3H9l3.8 5L17.4 3z"/>',
"brand-facebook": '<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/>',
"brand-tiktok": '<path d="M9 12a4 4 0 1 0 4 4V4a5 5 0 0 0 5 5"/>',
"brand-youtube": '<path d="M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.55 49.55 0 0 1-16.2 0A2 2 0 0 1 2.5 17"/><path d="m10 15 5-3-5-3z"/>',
"brand-bandcamp": '<path d="M22 7h-9.5L2 17h9.5L22 7z"/>',
"brand-soundcloud": '<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>',
"hard-drive": '<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>',
};
</script>
<svg
class="icon"
viewBox="0 0 24 24"
width={size}
height={size}
fill={solid ? "currentColor" : "none"}
stroke={solid ? "none" : "currentColor"}
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>{@html PATHS[name] ?? ""}</svg>
<style>
.icon {
display: block;
flex-shrink: 0;
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<script>
import { app, connect } from "../lib/state.svelte.js";
let username = $state("");
let password = $state("");
let remember = $state(true);
function submit(e) {
e.preventDefault();
if (!username || !password) return;
connect(username, password, remember);
}
</script>
{#if app.showLogin}
<div
class="backdrop"
onclick={(e) => { if (e.target === e.currentTarget) app.showLogin = false; }}
onkeydown={(e) => { if (e.key === "Escape") app.showLogin = false; }}
role="presentation"
>
<form class="modal" onsubmit={submit}>
<h2>Connect to Soulseek</h2>
<p class="hint">
Any username and password works — new accounts are created on
first login. Use something unique.
</p>
<label>
Username
<input bind:value={username} spellcheck="false" autocomplete="off" />
</label>
<label>
Password
<input type="password" bind:value={password} autocomplete="off" />
</label>
<label class="check">
<input type="checkbox" bind:checked={remember} />
Remember me on this computer
</label>
{#if app.loginError}
<p class="error">{app.loginError}</p>
{/if}
<button class="go" disabled={app.connection === "connecting"}>
{app.connection === "connecting" ? "Connecting…" : "Connect"}
</button>
</form>
</div>
{/if}
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: grid;
place-items: center;
z-index: 100;
}
.modal {
width: 360px;
background: var(--bg-elevated);
border-radius: 12px;
padding: 28px;
display: flex;
flex-direction: column;
gap: 14px;
}
h2 {
font-size: 20px;
}
.hint {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
label input:not([type="checkbox"]) {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-base);
color: var(--text-primary);
outline: none;
}
label input:focus {
border-color: var(--text-secondary);
}
.check {
flex-direction: row;
align-items: center;
gap: 8px;
font-weight: 400;
}
.error {
color: #f15e6c;
font-size: 12px;
}
.go {
margin-top: 6px;
padding: 12px;
border-radius: 24px;
background: var(--accent);
color: #000;
font-weight: 700;
transition: background 0.15s;
}
.go:hover:not(:disabled) {
background: var(--accent-hover);
}
.go:disabled {
opacity: 0.6;
cursor: default;
}
</style>
+902
View File
@@ -0,0 +1,902 @@
<script>
import { untrack } from "svelte";
import {
player,
ui,
toggleNowPlaying,
getArt,
getCatalogArt,
jumpToQueueIndex,
discovery,
loadDiscovery,
openCatalogArtist,
streamCatalogAlbum,
openCurrentArtist,
openCurrentAlbum,
openCurrentSong,
libraryStore,
artBroken,
markArtBroken,
playerArtSource,
} from "../lib/state.svelte.js";
import Icon from "./Icon.svelte";
import SaveMenu from "./SaveMenu.svelte";
import { floatScroll } from "../lib/scrollbar.js";
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
// Gate stream-only sections (quality, source, Save) on the displayed
// item's own stream field — the source of truth for what's on screen.
// Library tracks never carry one, so they never show "Save to Library"
// (and a track already in the library always plays locally, never streams).
// player.track.source is derived and can lag across gapless boundaries.
const isStream = $derived(!!currentItem?.stream);
const quality = $derived(currentItem?.quality ?? null);
// Album context: the queue is a catalog album/continuation with >1 track.
const albumMode = $derived(
!!player.catalogRef.albumId && player.queue.length > 1,
);
// Artist name for the Save action: album items carry "artist — album".
const artistName = $derived.by(() => {
const sub = currentItem?.sub ?? "";
return sub.includes(" — ") ? sub.split(" — ")[0].trim() : sub;
});
// Artist / album split for the separate, clickable identity lines.
const metaParts = $derived.by(() => {
const sub = player.track?.sub ?? "";
return sub.includes(" — ")
? sub.split(" — ").map((s) => s.trim())
: [sub.trim(), ""];
});
const LOSSLESS = new Set(["FLAC", "WAV", "ALAC", "AIFF", "APE"]);
// Discovery: load more-from-artist / fans-also-like / about when the
// playing artist changes, but only while the panel is visible. The old
// always-mounted fetch could launch a full artist refinement in the
// background while the user was opening an unrelated artist page.
$effect(() => {
const name = artistName;
const visible = ui.nowPlaying;
if (name && visible) {
// loadDiscovery reads/writes its own reactive progress fields.
// Without this boundary a failed/empty lookup makes `loading=false`
// retrigger the effect indefinitely until Svelte aborts the flush.
untrack(() => loadDiscovery(name));
}
});
function openDiscoveryArtist() {
if (discovery.artistId == null) return;
openCatalogArtist(discovery.artistId, {
id: discovery.artistId,
name: artistName,
picture: discovery.picture,
fans: discovery.fans,
album_count: discovery.albumCount,
});
}
// Collapse the bio back to the clamped state whenever the artist changes.
let bioExpanded = $state(false);
$effect(() => {
discovery.artist;
bioExpanded = false;
});
// Keep the panel body mounted just long enough to finish the close slide,
// then release its reactive waveform work and filtered layers entirely.
// Reopening cancels the pending teardown and restores content immediately.
let panelContentVisible = $state(ui.nowPlaying);
$effect(() => {
if (ui.nowPlaying) {
panelContentVisible = true;
return;
}
const timer = setTimeout(() => (panelContentVisible = false), 300);
return () => clearTimeout(timer);
});
const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
// "More from": full albums ahead of EPs/singles, newest first within each
// tier, drop the album that's playing now, cap at 6.
const typeRank = (t) => (t === "album" ? 0 : t === "ep" ? 1 : 2);
const moreAlbums = $derived.by(() => {
const here = norm(currentItem?.sub?.split(" — ")[1] ?? "");
return [...discovery.albums]
.filter((a) => norm(a.title) !== here)
.sort(
(a, b) =>
typeRank(a.record_type) - typeRank(b.record_type) ||
(b.release_date ?? "").localeCompare(a.release_date ?? ""),
)
.slice(0, 6);
});
const relatedArtists = $derived(discovery.related.slice(0, 4));
// How many of this artist's albums are already in the local library.
const inLibraryCount = $derived.by(() => {
const a = norm(artistName);
if (!a) return 0;
return libraryStore.albums.filter((x) => norm(x.artist) === a).length;
});
function albumYear(a) {
return a.release_date ? a.release_date.slice(0, 4) : "";
}
function fmtFans(n) {
if (!n) return "—";
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
if (n >= 1e3) return `${Math.round(n / 1e3)}k`;
return String(n);
}
// Album art (local path or catalog CDN) → data URL, reused for the glow.
let artUrl = $state(null);
$effect(() => {
// A closed panel should not duplicate PlayerBar's cold cover-art IPC or
// retain a large data URL that has no visible consumer.
const item = panelContentVisible ? currentItem : null;
artUrl = null;
if (!item) return;
let active = true;
if (item.art) {
getArt(item.art).then((u) => active && (artUrl = u));
} else if (item.artUrl) {
getCatalogArt(playerArtSource(item)).then((u) => active && (artUrl = u));
}
return () => (active = false);
});
const bufferPct = $derived(
player.streamTotal > 0
? Math.min(100, (player.streamReceived / player.streamTotal) * 100)
: 0,
);
const playedPct = $derived(
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
);
// Average dense source peaks into fixed, equally sized display bars. Using
// an average (rather than picking one peak) stops isolated transients from
// making random bars hit the ceiling.
// 78 × 2px bars plus 77 × 2px gaps = 310px. The sidebar's 312px
// waveform lane can center that exact integer footprint, avoiding the
// fractional gap rounding that made some neighboring bars look closer.
const BARS = 78;
const bars = $derived.by(() => {
const w = player.waveform;
if (!w || w.length === 0) return [];
const out = [];
for (let i = 0; i < BARS; i++) {
const from = Math.floor((i / BARS) * w.length);
const to = Math.max(from + 1, Math.floor(((i + 1) / BARS) * w.length));
let sum = 0;
for (let j = from; j < to; j++) sum += w[j] ?? 0;
out.push(sum / (to - from));
}
return out;
});
const playedBars = $derived((playedPct / 100) * bars.length);
function barHeight(value, index) {
const capped = Math.pow(Math.min(0.88, value * 1.18), 0.8);
// Stable per-bar grain keeps a dense loud passage from flattening
// into a ruler of identical maximum-height bars. It is deterministic
// (no visual flicker as the playhead moves) and only matters near the
// cap, where source peaks otherwise converge on the same value.
const grain = ((Math.sin((index + 1) * 12.9898) * 43758.5453) % 1 + 1) % 1;
const peakFactor = Math.max(0, (capped - 0.58) / 0.3);
const level = Math.max(0, Math.min(0.88, capped + (grain - 0.5) * 0.12 * peakFactor));
return 14 + level * 72; // preserve headroom; never peak against the top
}
function barFill(index) {
return Math.max(0, Math.min(1, playedBars - index));
}
function fmtTime(s) {
if (s == null || !isFinite(s)) return "–:––";
s = Math.max(0, Math.floor(s));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function fmtSize(bytes) {
if (!bytes) return null;
return `${(bytes / 1048576).toFixed(1)} MB`;
}
function fmtRate(q) {
if (!q?.sampleRate) return null;
const khz = (q.sampleRate / 1000).toFixed(1).replace(/\.0$/, "");
return q.bitDepth ? `${khz} kHz · ${q.bitDepth}-bit` : `${khz} kHz`;
}
</script>
<aside class="nowplaying" class:closed={!ui.nowPlaying}>
<!-- Frosted-glass header: it overlays the scrolling content and blurs
whatever sits beneath it, so the album art's colors seep through and
shift as you scroll the art up under the glass. -->
<header class="np-head" data-scrollhead>
<span class="np-title">Now playing</span>
<button class="np-icon" title="Hide panel" onclick={toggleNowPlaying}>
<Icon name="chevron-left" size={16} />
</button>
</header>
<div class="np-scroll" use:floatScroll>
{#if panelContentVisible}
{#if currentItem}
<!-- glowing cover art -->
<div class="hero">
{#if artUrl}
<img class="glow" src={artUrl} alt="" aria-hidden="true" draggable="false" />
<div class="cover-wrap">
<img class="cover" src={artUrl} alt="" draggable="false" />
<button
class="expand"
title="View artwork"
aria-label="View album artwork full screen"
onclick={() => (ui.fullscreenArt = true)}
>
<Icon name="expand" size={15} />
</button>
</div>
{:else}
<div class="cover placeholder"><Icon name="music" size={40} /></div>
{/if}
</div>
<!-- identity: song / artist / album, each its own link -->
<div class="idn">
<div class="idn-text">
<button class="tt link" onclick={openCurrentSong} title={player.track?.title}>
{player.track?.title ?? "—"}
</button>
{#if metaParts[0]}
<button class="ar link" onclick={openCurrentArtist} title={metaParts[0]}>
{metaParts[0]}
</button>
{/if}
{#if metaParts[1]}
<button class="ar link album" onclick={openCurrentAlbum} title={metaParts[1]}>
{metaParts[1]}
</button>
{/if}
</div>
</div>
<!-- mini waveform + times -->
{#if bars.length}
<div class="wave" aria-hidden="true">
{#each bars as v, i}
<i
style="height:{barHeight(v, i)}%; --fill:{barFill(i)}"
></i>
{/each}
</div>
{/if}
<div class="times">
<span>{fmtTime(player.position)}</span>
<span>{fmtTime(player.duration)}</span>
</div>
<!-- quality chips -->
{#if quality}
<div class="chips">
{#if quality.format}
<span class="chip" class:loss={LOSSLESS.has(quality.format)}>
{#if LOSSLESS.has(quality.format)}<span class="dot"></span>{/if}
{quality.format}
</span>
{/if}
{#if quality.bitrate}
<span class="chip" class:loss={LOSSLESS.has(quality.format)}>
{quality.bitrate} kbps{quality.vbr ? " VBR" : ""}
</span>
{/if}
{#if fmtRate(quality)}<span class="chip">{fmtRate(quality)}</span>{/if}
{#if fmtSize(quality.size)}<span class="chip">{fmtSize(quality.size)}</span>{/if}
</div>
{/if}
<!-- source card (streaming only) -->
{#if isStream && currentItem.stream}
<div class="src">
<div class="src-lab">Streaming from</div>
<div class="peer">
<div class="av">{currentItem.stream.username.slice(0, 1).toUpperCase()}</div>
<div class="who" title={currentItem.stream.username}>{currentItem.stream.username}</div>
</div>
{#if player.streamTotal > 0}
<div class="buf">
<div class="bar"><i style="width:{bufferPct}%"></i></div>
<div class="meta">
<span>Buffered {(player.streamReceived / 1048576).toFixed(1)} / {(player.streamTotal / 1048576).toFixed(1)} MB</span>
<span>{Math.round(bufferPct)}%</span>
</div>
</div>
{/if}
</div>
<!-- save the file(s) you're already streaming (no re-search) -->
<div class="actions-row">
<SaveMenu align="down" />
</div>
{/if}
<!-- queue / album tracklist -->
{#if player.queue.length > 1}
<div class="section">
<div class="lab">{albumMode ? "From the album" : "Queue"}</div>
<div class="list">
{#each player.queue as item, i}
<button class="row" class:now={i === player.queueIndex} onclick={() => jumpToQueueIndex(i)}>
<span class="n">
{#if i === player.queueIndex}
<span
class="eq"
class:active={ui.nowPlaying && player.status === "playing" && ui.windowActive}
><b></b><b></b><b></b></span>
{:else}{i + 1}{/if}
</span>
<span class="nm" title={item.title}>{item.title}</span>
<span class="du">{fmtTime(item.duration)}</span>
</button>
{/each}
</div>
</div>
{/if}
<!-- Discovery: more from the artist -->
{#if moreAlbums.length}
<div class="section">
<div class="lab divlab">More from {artistName}</div>
<div class="grid2">
{#each moreAlbums as al}
<button class="mini" title="Play {al.title}" onclick={() => streamCatalogAlbum(al)}>
<div class="art">
{#if al.cover && !artBroken[al.cover]}<img src={al.cover} alt="" loading="lazy" draggable="false" onerror={() => markArtBroken(al.cover)} />{/if}
</div>
<div class="mt">{al.title}</div>
<div class="my">{albumYear(al)}{al.record_type && al.record_type !== "album" ? ` · ${al.record_type}` : ""}</div>
</button>
{/each}
</div>
</div>
{/if}
<!-- Discovery: fans also like -->
{#if relatedArtists.length}
<div class="section">
<div class="lab divlab">Fans also like</div>
<div class="fans">
{#each relatedArtists as ar}
<button class="fan" title={ar.name} onclick={() => openCatalogArtist(ar.id, ar)}>
<div class="av">
{#if ar.picture}<img src={ar.picture} alt="" loading="lazy" draggable="false" />{/if}
</div>
<div class="fn">{ar.name}</div>
</button>
{/each}
</div>
</div>
{/if}
<!-- Discovery: about the artist -->
{#if discovery.artistId && (discovery.picture || discovery.albumCount)}
<div class="section">
<div class="lab divlab">About the artist</div>
<div class="aboutcard">
<button class="bandphoto" title="Open {artistName}" onclick={openDiscoveryArtist}>
{#if discovery.picture}
<img src={discovery.picture} alt="" loading="lazy" draggable="false" />
{:else}
<span class="ph"><Icon name="music" size={28} /></span>
{/if}
</button>
<div class="about-text">
<button class="nm" onclick={openDiscoveryArtist}>{artistName}</button>
{#if discovery.bio}
<div class="bio">
<p class:clamped={!bioExpanded}>{discovery.bio}</p>
<button class="more" onclick={() => (bioExpanded = !bioExpanded)}>
{bioExpanded ? "Show less" : "Read more"}
</button>
</div>
{/if}
</div>
<div class="about-body">
<div class="st"><b>{discovery.albumCount ?? "—"}</b>releases</div>
<div class="st"><b>{fmtFans(discovery.fans)}</b>fans</div>
<div class="st"><b>{inLibraryCount}</b>in library</div>
</div>
</div>
</div>
{/if}
{:else}
<div class="empty">
<Icon name="music" size={30} />
<span>Nothing playing</span>
</div>
{/if}
{/if}
</div>
</aside>
<style>
.nowplaying {
grid-area: nowplaying;
background: var(--bg-player);
border-radius: var(--radius);
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
position: relative;
/* Fixed width so the panel keeps its shape and slides off-screen when
the grid column collapses to 0, rather than squishing its content.
(The grid column uses --nowplaying-width, which animates 340→0.) */
width: 340px;
justify-self: start;
visibility: visible;
transition:
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.24s ease,
visibility 0s linear;
}
.nowplaying.closed {
transform: translateX(calc(100% + 8px));
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.24s ease,
visibility 0s linear 0.28s;
}
/* Frosted-glass header floating over the scrolling content: backdrop-filter
blurs whatever is beneath it, so the album colors seep through and shift
as the art scrolls up under the glass. */
.np-head {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 46px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
z-index: 5;
background: rgba(20, 20, 24, 0.28);
backdrop-filter: blur(20px) saturate(1.7);
-webkit-backdrop-filter: blur(20px) saturate(1.7);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04);
}
.np-title {
font-size: 13px;
font-weight: 600;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.np-icon {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--text-secondary);
}
.np-icon:hover { background: var(--bg-highlight); color: var(--text-primary); }
.np-scroll {
overflow-y: auto;
overflow-anchor: none;
overscroll-behavior: none;
/* Clear the 46px glass header so the art isn't overlapped at rest; the
art still slides under the glass (tinting it) as you scroll. */
padding: 54px 14px 18px;
display: flex;
flex-direction: column;
gap: 16px;
position: relative;
z-index: 1;
}
/* Keep every section at its natural height and let the panel scroll —
a flex column shrinks children by default, which collapsed the
percentage-height waveform bars on shorter windows. */
.np-scroll > * {
flex-shrink: 0;
}
/* glowing cover art (ambilight: a blurred copy of the same image) */
.hero { position: relative; padding: 6px 4px 10px; }
.glow {
position: absolute;
inset: -2% -2% 6%;
z-index: 0;
width: 104%;
height: 100%;
object-fit: cover;
border-radius: 30px;
filter: blur(38px) saturate(1.4);
opacity: 0.62;
transform: scale(1.02);
pointer-events: none;
}
.cover-wrap { position: relative; z-index: 1; }
.cover {
position: relative;
z-index: 1;
width: 100%;
aspect-ratio: 1;
border-radius: 12px;
object-fit: cover;
background: var(--bg-highlight);
box-shadow: 0 18px 38px -18px rgba(0, 0, 0, 0.7);
display: block;
}
/* Corner expand control, Spotify-style: a dark glass disc pinned inside
the art's top-right corner, revealed on hover (or keyboard focus). */
.expand {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
width: 30px;
height: 30px;
border-radius: 50%;
display: grid;
place-items: center;
color: #fff;
background: rgba(10, 10, 14, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
opacity: 0;
transform: translateY(-3px) scale(0.9);
transition: opacity 0.16s ease, transform 0.16s ease, background 0.16s ease;
}
.cover-wrap:hover .expand,
.expand:focus-visible {
opacity: 1;
transform: none;
}
.expand:hover { background: rgba(10, 10, 14, 0.85); }
@media (prefers-reduced-motion: reduce) {
.expand { transition: opacity 0.16s ease; transform: none; }
}
.cover.placeholder {
display: grid;
place-items: center;
color: var(--text-muted);
}
.idn { display: flex; align-items: flex-start; gap: 10px; }
.idn-text { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; }
/* Each identity line is its own link — width-constrained so the hover
underline hugs the text, and ellipsis-truncated. */
.tt,
.ar {
max-width: 100%;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.tt {
font-size: 21px;
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.2;
}
.tt.link:hover { text-decoration: underline; }
.ar {
color: var(--text-secondary);
font-size: 13.5px;
margin-top: 3px;
}
.ar.link:hover { color: var(--text-primary); text-decoration: underline; }
.ar.album {
font-size: 12.5px;
color: var(--text-muted);
margin-top: 1px;
}
.ar.album.link:hover { color: var(--text-primary); }
/* mini waveform */
.wave {
display: flex;
justify-content: center;
align-items: end;
gap: 2px;
height: 38px;
}
.wave i {
flex: 0 0 2px;
width: 2px;
border-radius: 1px;
background: color-mix(in srgb, var(--accent) calc(var(--fill) * 100%), var(--text-muted));
min-height: 2px;
}
.times {
display: flex;
justify-content: space-between;
font-size: 11.5px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
margin-top: -8px;
}
/* quality chips */
.chips { display: flex; flex-wrap: wrap; gap: 7px; }
.chip {
font-size: 12px;
padding: 4px 11px;
border-radius: 999px;
background: var(--bg-highlight);
color: var(--text-primary);
font-weight: 550;
display: inline-flex;
align-items: center;
gap: 6px;
}
.chip.loss { color: #4bd08a; }
.chip .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
/* source card */
.src {
background: var(--bg-elevated);
border-radius: 10px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.src-lab {
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
font-weight: 600;
}
.peer { display: flex; align-items: center; gap: 10px; min-width: 0; }
.av {
width: 32px;
height: 32px;
border-radius: 9px;
flex: 0 0 auto;
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
display: grid;
place-items: center;
font-weight: 700;
font-size: 13px;
color: #04222f;
}
.who {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.buf { display: flex; flex-direction: column; gap: 5px; }
.buf .bar { height: 6px; border-radius: 3px; background: var(--bg-highlight); overflow: hidden; }
.buf .bar i {
display: block;
height: 100%;
border-radius: 3px;
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
}
.buf .meta {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
/* save action row */
.actions-row {
display: flex;
align-items: center;
gap: 8px;
}
/* tracklist */
.section { display: flex; flex-direction: column; gap: 10px; }
.lab {
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
font-weight: 600;
}
.list { display: flex; flex-direction: column; }
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 7px 8px;
border-radius: 6px;
font-size: 13px;
color: var(--text-secondary);
text-align: left;
width: 100%;
}
.row:hover { background: var(--bg-highlight); }
.row .n {
width: 16px;
text-align: center;
font-size: 12px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
flex: 0 0 auto;
}
.row .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row .du { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; flex: 0 0 auto; }
.row.now { color: var(--accent); font-weight: 600; }
.row.now .n { color: var(--accent); }
.eq { display: inline-flex; align-items: flex-end; gap: 1.5px; width: 14px; height: 11px; }
.eq b { width: 2.5px; background: var(--accent); border-radius: 1px; }
/* A stepped equalizer keeps the playing cue alive without asking Chromium
to repaint the glass panel and artist page on every display frame. */
.eq.active b { animation: eq 1.2s steps(1, end) infinite; }
.eq b:nth-child(1) { height: 40%; animation-delay: -0.2s; }
.eq b:nth-child(2) { height: 90%; }
.eq b:nth-child(3) { height: 60%; animation-delay: -0.4s; }
@keyframes eq { 0%, 100% { transform: scaleY(0.4); } 50% { transform: scaleY(1); } }
@media (prefers-reduced-motion: reduce) { .eq.active b { animation: none; } }
/* discovery: more-from-artist grid */
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
/* min-width:0 lets both columns stay an equal 1fr; without it a long
(nowrap) title forces its column wider and shrinks the other. */
.mini { text-align: left; min-width: 0; }
.mini .art {
width: 100%;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
background: var(--bg-highlight);
}
.mini .art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.mini:hover .art { outline: 2px solid var(--accent); outline-offset: -2px; }
.mini .mt {
font-size: 12.5px;
font-weight: 600;
margin-top: 6px;
line-height: 1.25;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 6;
}
.mini .my { font-size: 11.5px; color: var(--text-muted); text-transform: capitalize; }
/* discovery: fans also like — a fixed row of 5 (no horizontal scroll,
which always left one avatar half-cut at the edge). Explicit square
avatars (aspect-ratio inside a flex row gets squished to an oval);
space-between fits all five within the panel with no cutoff. */
.fans { display: flex; justify-content: space-evenly; padding: 4px 0; }
.fan { width: 52px; text-align: center; }
.fan .av {
width: 52px;
height: 52px;
border-radius: 50%;
overflow: hidden;
background: var(--bg-highlight);
}
.fan .av img { width: 100%; height: 100%; object-fit: cover; display: block; }
.fan:hover .av { outline: 2px solid var(--accent); outline-offset: 2px; }
.fan .fn {
font-size: 11px;
margin-top: 7px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* discovery: about the artist */
.aboutcard {
display: block;
width: 100%;
border-radius: 12px;
overflow: hidden;
background: var(--bg-elevated);
}
/* Full square Deezer image, uncropped (square img in a square box), with a
tall bottom fade to the card colour so the photo melts into the text. */
.bandphoto {
display: block;
position: relative;
width: 100%;
aspect-ratio: 1;
text-align: left;
}
.bandphoto img { width: 100%; height: 100%; object-fit: cover; display: block; }
.bandphoto .ph {
width: 100%;
aspect-ratio: 1;
display: grid;
place-items: center;
color: var(--text-muted);
}
.bandphoto::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
to top,
var(--bg-elevated) 6%,
color-mix(in srgb, var(--bg-elevated) 55%, transparent) 22%,
transparent 48%
);
}
/* Name + bio ride up into the faded bottom of the photo, so the text
reads as emerging from the image. */
.about-text {
position: relative;
z-index: 2;
margin-top: -52px;
padding: 0 13px;
}
.about-text .nm {
display: block;
text-align: left;
font-size: 19px;
font-weight: 700;
color: #fff;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.75);
margin-bottom: 8px;
}
.about-text .nm:hover { text-decoration: underline; }
.bio { padding: 0 0 2px; }
.bio p {
margin: 0;
font-size: 12.5px;
line-height: 1.5;
color: var(--text-secondary);
}
.bio p.clamped {
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.bio .more {
margin-top: 4px;
font-size: 12px;
font-weight: 600;
color: var(--accent-hover);
}
.bio .more:hover { text-decoration: underline; }
.about-body { display: flex; gap: 18px; padding: 12px 13px 13px; }
.about-body .st { font-size: 11px; color: var(--text-muted); }
.about-body .st b {
display: block;
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 0;
color: var(--text-muted);
font-size: 13px;
}
</style>
+813
View File
@@ -0,0 +1,813 @@
<script>
import {
player,
theme,
ui,
nextTrack,
prevTrack,
toggleShuffle,
cycleRepeat,
toggleNowPlaying,
openCurrentArtist,
openCurrentAlbum,
openCurrentSong,
getArt,
getCatalogArt,
playerArtSource,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
import SaveMenu from "./SaveMenu.svelte";
const pct = $derived(
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
);
const bufferPct = $derived(
player.streamTotal > 0
? Math.min(100, (player.streamReceived / player.streamTotal) * 100)
: 0,
);
const hasWave = $derived(player.waveform.length > 0);
const idle = $derived(player.status === "idle");
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
// The native scratch engine needs a complete local file. A network stream
// keeps the reliable seek-on-drag behavior until it has been saved/cached.
const canScratch = $derived(!currentItem?.stream);
// Split "Artist — Album" for the separate, clickable meta lines.
const metaParts = $derived.by(() => {
const sub = player.track?.sub ?? "";
return sub.includes(" — ")
? sub.split(" — ").map((s) => s.trim())
: [sub.trim(), ""];
});
const volumeIcon = $derived(
player.muted || player.volume === 0
? "volume-mute"
: player.volume < 0.5
? "volume-low"
: "volume",
);
let artUrl = $state(null);
let volTrack = $state(null);
let draggingVolume = $state(false);
let draggingSeek = $state(false);
let lastAudibleVolume = 0.7;
let lastScrubSeekAt = 0;
let scrubStartX = 0;
let scrubLastX = 0;
let scrubLastAt = 0;
let scratchActive = false;
$effect(() => {
const item = currentItem;
artUrl = null;
if (!item) return;
let active = true;
if (item.art) {
getArt(item.art).then((url) => {
if (active) artUrl = url;
});
} else if (item.artUrl) {
// Streamed catalog track: cover comes from the catalog CDN, upgraded
// to the verified high-res source (Apple/CAA/Deezer-max) when ready.
getCatalogArt(playerArtSource(item)).then((url) => {
if (active) artUrl = url;
});
}
return () => (active = false);
});
// Waveform canvas + hover state.
let waveWrap = $state(null);
let waveCanvas = $state(null);
let hoverX = $state(-1);
const hoverTime = $derived(
waveWrap && player.duration ? (hoverX / waveWrap.clientWidth) * player.duration : 0,
);
// Canvas colors; played/hover follow the adaptive accent theme.
const UNPLAYED = "rgba(255,255,255,0.24)";
const PREVIEW = "rgba(255,255,255,0.66)";
const PLAYHEAD = "#ffffff";
function fmt(secs) {
if (secs == null || !isFinite(secs)) return "–:––";
const s = Math.max(0, Math.floor(secs));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function toggle() {
if (player.status === "playing") api.playerControl("pause");
else if (player.status === "paused") api.playerControl("resume");
}
function seekTarget(e, el) {
if (!player.duration || idle) return;
const rect = el.getBoundingClientRect();
const f = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
return f * player.duration;
}
function previewScrub(e, el) {
const target = seekTarget(e, el);
if (target == null) return;
player.position = target; // instant visual feedback while dragging
return target;
}
function regularScrub(target, force = false) {
const now = performance.now();
if (force || now - lastScrubSeekAt >= 75) {
lastScrubSeekAt = now;
api.playerControl("seek", target);
}
}
function startScrub(e, el) {
if (!player.duration || idle) return;
draggingSeek = true;
player.scrubbing = true;
el.setPointerCapture(e.pointerId);
scrubStartX = scrubLastX = e.clientX;
scrubLastAt = performance.now();
scratchActive = false;
previewScrub(e, el);
}
function moveScrub(e, el) {
if (!draggingSeek) return;
const target = previewScrub(e, el);
if (target == null) return;
const now = performance.now();
if (canScratch && Math.abs(e.clientX - scrubStartX) > 3) {
const activatingScratch = !scratchActive;
scratchActive = true;
const elapsed = Math.max(1, now - scrubLastAt);
// Map hand velocity (pixels/ms) to a musical 0.25×–4× rate.
const rate = Math.min(4, Math.max(0.25, Math.abs(e.clientX - scrubLastX) / elapsed * 1.6));
if (activatingScratch || now - lastScrubSeekAt >= 50) {
lastScrubSeekAt = now;
api.playerControl("scratch", target, e.clientX < scrubLastX ? -rate : rate);
}
} else if (!canScratch) {
regularScrub(target);
}
scrubLastX = e.clientX;
scrubLastAt = now;
}
function endScrub(e, el) {
if (!draggingSeek) return;
const target = previewScrub(e, el);
if (target != null) {
if (scratchActive) api.playerControl("end-scratch", target);
else regularScrub(target, true); // a simple click remains exact seek
}
draggingSeek = false;
player.scrubbing = false;
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
}
function onWaveHover(e) {
const rect = waveWrap.getBoundingClientRect();
hoverX = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
}
function setVolume(e) {
const rect = volTrack.getBoundingClientRect();
const frac = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
player.volume = frac;
player.muted = frac === 0;
if (frac > 0) lastAudibleVolume = frac;
api.playerControl("volume", frac);
}
function startVolumeDrag(e) {
draggingVolume = true;
volTrack.setPointerCapture(e.pointerId);
setVolume(e);
}
function endVolumeDrag(e) {
draggingVolume = false;
if (volTrack.hasPointerCapture(e.pointerId)) volTrack.releasePointerCapture(e.pointerId);
}
function volumeKeydown(e) {
const step = e.shiftKey ? 0.1 : 0.05;
if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
e.preventDefault();
setVolume({ clientX: volTrack.getBoundingClientRect().left + Math.max(0, player.volume - step) * volTrack.clientWidth });
} else if (e.key === "ArrowRight" || e.key === "ArrowUp") {
e.preventDefault();
setVolume({ clientX: volTrack.getBoundingClientRect().left + Math.min(1, player.volume + step) * volTrack.clientWidth });
} else if (e.key === "Home" || e.key === "End") {
e.preventDefault();
setVolume({ clientX: volTrack.getBoundingClientRect().left + (e.key === "Home" ? 0 : volTrack.clientWidth) });
}
}
function toggleMute() {
if (player.muted || player.volume === 0) {
const volume = lastAudibleVolume || 0.7;
player.volume = volume;
player.muted = false;
api.playerControl("volume", volume);
} else {
lastAudibleVolume = player.volume;
player.muted = true;
api.playerControl("volume", 0);
}
}
// Wave geometry is invariant while a track and player-bar size stay the
// same. Cache it instead of rebuilding ~1,600 line segments and resetting
// the canvas backing store on every 60 ms position event.
let wavePath = null;
let wavePathPeaks = null;
let wavePathWidth = 0;
let wavePathHeight = 0;
let lastPlayX = Number.NEGATIVE_INFINITY;
let lastHoverDrawn = Number.NEGATIVE_INFINITY;
let lastAccent = "";
let lastAccentHover = "";
let lastDuration = null;
const windowActive = $derived(ui.windowActive);
// Draw one solid mirrored waveform, colored by played / hover / unplayed,
// with a white playhead line and a green hover line.
function drawWave(force = false) {
// Position events still update playback/history while the window is
// minimized, but there is no visible canvas to repaint. Visibility
// return below forces one exact catch-up frame.
if (!windowActive) return;
const canvas = waveCanvas;
const wrap = waveWrap;
const peaks = player.waveform;
if (!canvas || !wrap || !peaks || peaks.length === 0) return;
const dpr = window.devicePixelRatio || 1;
const W = wrap.clientWidth;
const H = 26;
if (W <= 0) return;
const pixelWidth = Math.max(1, Math.round(W * dpr));
const pixelHeight = Math.max(1, Math.round(H * dpr));
const resized = canvas.width !== pixelWidth || canvas.height !== pixelHeight;
if (resized) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const N = peaks.length;
const mid = H / 2;
// Thin waveform floating in the middle: cap the amplitude well short
// of the edges so loud sections don't clip flat, and keep a small
// baseline so quiet sections still read as a hairline.
const maxAmp = 7;
const amp = (v) => Math.max(0.7, v * maxAmp);
const geometryChanged =
resized ||
wavePathPeaks !== peaks ||
wavePathWidth !== W ||
wavePathHeight !== H;
if (geometryChanged) {
const path = new Path2D();
path.moveTo(0, mid - amp(peaks[0]));
const divisor = Math.max(1, N - 1);
for (let i = 0; i < N; i++) {
path.lineTo((i / divisor) * W, mid - amp(peaks[i]));
}
for (let j = N - 1; j >= 0; j--) {
path.lineTo((j / divisor) * W, mid + amp(peaks[j]));
}
path.closePath();
wavePath = path;
wavePathPeaks = peaks;
wavePathWidth = W;
wavePathHeight = H;
}
const playX = player.duration
? Math.max(0, Math.min(W, (player.position / player.duration) * W))
: 0;
const visualsChanged =
geometryChanged ||
hoverX !== lastHoverDrawn ||
theme.accent !== lastAccent ||
theme.accentHover !== lastAccentHover ||
player.duration !== lastDuration;
// A sub-device-pixel playhead move cannot materially change the image.
// Keep exact redraws for hover, resize, theme, seeks, and short tracks.
if (!force && !visualsChanged && Math.abs(playX - lastPlayX) < 0.75 / dpr) return;
ctx.clearRect(0, 0, W, H);
const fillClip = (x0, x1, color) => {
if (x1 <= x0) return;
ctx.save();
ctx.beginPath();
ctx.rect(x0, 0, x1 - x0, H);
ctx.clip();
ctx.fillStyle = color;
ctx.fill(wavePath);
ctx.restore();
};
fillClip(0, W, UNPLAYED);
if (hoverX > playX) fillClip(playX, hoverX, PREVIEW);
// Match the primary Play button and the sidebar's completed bars.
// `accentHover` remains reserved for hover previews and controls.
fillClip(0, playX, theme.accent);
// Playhead / hover markers: fixed height, padded off the edges,
// so they read as small ticks rather than full-height rules.
const LINE_H = 18;
const lineY = (H - LINE_H) / 2;
ctx.fillStyle = PLAYHEAD;
ctx.fillRect(playX - 0.75, lineY, 1.5, LINE_H);
if (hoverX >= 0) {
ctx.fillStyle = theme.accentHover;
ctx.fillRect(hoverX - 0.5, lineY, 1, LINE_H);
}
lastPlayX = playX;
lastHoverDrawn = hoverX;
lastAccent = theme.accent;
lastAccentHover = theme.accentHover;
lastDuration = player.duration;
}
// Redraw whenever the waveform, position, hover, or accent changes
// (the accent lerps every frame during a crossfade, animating the fill).
$effect(() => {
player.waveform;
player.position;
player.duration;
hoverX;
theme.accent;
theme.accentHover;
drawWave();
});
// Redraw on player-bar resize.
$effect(() => {
if (!waveWrap) return;
const ro = new ResizeObserver(() => drawWave(true));
ro.observe(waveWrap);
return () => ro.disconnect();
});
</script>
<footer class="player">
<div class="now-playing">
{#if artUrl}
<img class="art cover" src={artUrl} alt="" draggable="false" />
{:else}
<div class="art placeholder" class:pulsing={player.status === "buffering"}>
{#if player.track}<Icon name="music" size={22} />{/if}
</div>
{/if}
<div class="meta">
{#if player.error}
<span class="title err">⚠ {player.error}</span>
{:else if player.track}
<button class="title link" onclick={openCurrentSong} title={player.track.title}>
{player.track.title}
</button>
{:else}
<span class="title muted">Nothing playing</span>
{/if}
{#if player.status === "buffering"}
<span class="sub">{metaParts[0] || "buffering…"}</span>
{:else if player.track}
{#if metaParts[0]}
<button class="sub link" onclick={openCurrentArtist} title={metaParts[0]}>
{metaParts[0]}
</button>
{/if}
{#if metaParts[1]}
<button class="sub link album" onclick={openCurrentAlbum} title={metaParts[1]}>
{metaParts[1]}
</button>
{/if}
{/if}
</div>
</div>
<div class="controls">
<div class="buttons">
<button
class="ctl small"
class:on={player.shuffle}
title="Shuffle"
onclick={toggleShuffle}
><Icon name="shuffle" size={16} /></button>
<button class="ctl" title="Previous" onclick={prevTrack} disabled={idle}>
<Icon name="skip-back" size={18} />
</button>
<button
class="play"
title="Play/Pause"
onclick={toggle}
disabled={idle || player.status === "buffering"}
>
<Icon name={player.status === "playing" ? "pause" : "play"} size={16} />
</button>
<button class="ctl" title="Next" onclick={() => nextTrack(false)} disabled={idle}>
<Icon name="skip-forward" size={18} />
</button>
<button
class="ctl small"
class:on={player.repeat !== "off"}
title={player.repeat === "one" ? "Repeat one" : player.repeat === "all" ? "Repeat all" : "Repeat"}
onclick={cycleRepeat}
><Icon name={player.repeat === "one" ? "repeat-one" : "repeat"} size={16} /></button>
</div>
<div class="scrubber">
<span class="time">{fmt(player.position)}</span>
{#if hasWave}
<div
class="wave-wrap"
bind:this={waveWrap}
role="slider"
aria-label="Seek"
aria-valuenow={Math.round(pct)}
tabindex="0"
onmousemove={onWaveHover}
onmouseleave={() => (hoverX = -1)}
class:dragging={draggingSeek}
onpointerdown={(e) => startScrub(e, waveWrap)}
onpointermove={(e) => moveScrub(e, waveWrap)}
onpointerup={(e) => endScrub(e, waveWrap)}
onpointercancel={(e) => endScrub(e, waveWrap)}
>
<canvas bind:this={waveCanvas} class="wave-canvas"></canvas>
{#if hoverX >= 0}
<div class="wave-tip" style="left: {hoverX}px">{fmt(hoverTime)}</div>
{/if}
</div>
{:else}
<div
class="track"
class:dragging={draggingSeek}
onpointerdown={(e) => startScrub(e, e.currentTarget)}
onpointermove={(e) => moveScrub(e, e.currentTarget)}
onpointerup={(e) => endScrub(e, e.currentTarget)}
onpointercancel={(e) => endScrub(e, e.currentTarget)}
role="slider"
aria-label="Seek"
aria-valuenow={Math.round(pct)}
tabindex="0"
>
{#if bufferPct > 0 && player.track?.source === "stream"}
<div class="buffer" style="width: {bufferPct}%"></div>
{/if}
<div class="fill" style="width: {pct}%"></div>
</div>
{/if}
<span class="time">{fmt(player.duration)}</span>
</div>
</div>
<div class="extras">
<SaveMenu align="up" />
<button
class="vol-icon"
class:on={ui.nowPlaying}
title="Now playing"
onclick={toggleNowPlaying}
>
<Icon name="panel-right" size={17} />
</button>
<button class="vol-icon" title={player.muted ? "Unmute" : "Mute"} onclick={toggleMute}>
<Icon name={volumeIcon} size={18} />
</button>
<div
class="track vol"
bind:this={volTrack}
class:dragging={draggingVolume}
onpointerdown={startVolumeDrag}
onpointermove={(e) => draggingVolume && setVolume(e)}
onpointerup={endVolumeDrag}
onpointercancel={endVolumeDrag}
onkeydown={volumeKeydown}
role="slider"
aria-label="Volume"
aria-valuenow={Math.round((player.muted ? 0 : player.volume) * 100)}
tabindex="0"
>
<div class="fill" style="width: {(player.muted ? 0 : player.volume) * 100}%"></div>
<div class="vol-thumb" style="left: {(player.muted ? 0 : player.volume) * 100}%"></div>
</div>
</div>
</footer>
<style>
.player {
grid-area: player;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 0 16px;
background: var(--bg-player);
border-radius: var(--radius);
}
.now-playing {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.art {
width: 56px;
height: 56px;
border-radius: 4px;
flex-shrink: 0;
display: grid;
place-items: center;
color: var(--text-muted);
font-size: 20px;
}
.art.placeholder {
background: var(--bg-highlight);
}
.art.cover {
object-fit: cover;
background: var(--bg-highlight);
}
.art.pulsing {
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
50% { opacity: 0.5; }
}
.meta {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
}
/* Separate, clickable meta lines. Buttons are width-constrained so the
hover underline only spans the text, and ellipsis-truncate. */
.title,
.sub {
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
display: block;
}
.title {
font-weight: 600;
}
.title.muted { color: var(--text-secondary); }
.title.err { color: #f15e6c; }
.title.link:hover { text-decoration: underline; }
.sub {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.35;
}
.sub.link:hover {
color: var(--text-primary);
text-decoration: underline;
}
.sub.album {
font-size: 11px;
color: var(--text-muted);
}
.sub.album.link:hover { color: var(--text-primary); }
.controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: min(45vw, 560px);
}
.buttons {
display: flex;
align-items: center;
gap: 16px;
}
.ctl {
display: grid;
place-items: center;
color: var(--text-secondary);
}
.ctl.small {
color: var(--text-muted);
}
.ctl:disabled {
opacity: 0.3;
}
.ctl:hover:not(:disabled) {
color: var(--text-primary);
}
/* shuffle / repeat active state */
.ctl.on {
color: var(--accent-hover);
position: relative;
}
.ctl.on::after {
content: "";
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent-hover);
}
.play {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--text-primary);
color: #000;
font-size: 14px;
transition: transform 0.1s;
}
.play:disabled {
opacity: 0.4;
}
.play:hover:not(:disabled) {
transform: scale(1.06);
}
/* Fixed height so swapping between the thin bar and the waveform
canvas never reflows the buttons above. */
.scrubber {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 26px;
}
.time {
font-size: 11px;
color: var(--text-secondary);
min-width: 32px;
text-align: center;
}
.track {
flex: 1;
height: 4px;
border-radius: 2px;
background: hsla(0, 0%, 100%, 0.2);
overflow: hidden;
position: relative;
cursor: pointer;
touch-action: none;
}
.track:hover {
height: 6px;
}
.track.dragging {
height: 6px;
}
.buffer {
position: absolute;
inset: 0 auto 0 0;
background: hsla(0, 0%, 100%, 0.25);
border-radius: 2px;
}
.fill {
position: relative;
height: 100%;
background: var(--text-primary);
border-radius: 2px;
}
.track:hover .fill {
background: var(--accent);
}
/* waveform scrubber */
.wave-wrap {
position: relative;
flex: 1;
height: 26px;
cursor: pointer;
touch-action: none;
}
.wave-canvas {
display: block;
width: 100%;
height: 100%;
}
.wave-tip {
position: absolute;
top: -22px;
transform: translateX(-50%);
background: #000;
color: #fff;
font-size: 11px;
padding: 2px 7px;
border-radius: 5px;
white-space: nowrap;
pointer-events: none;
}
.extras {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.vol-icon {
display: grid;
place-items: center;
font-size: 13px;
color: var(--text-secondary);
}
.vol-icon:hover {
color: var(--text-primary);
}
.vol-icon.on {
color: var(--accent-hover);
}
.track.vol {
flex: 0 0 90px;
overflow: visible;
touch-action: none;
}
.track.vol::before {
content: "";
position: absolute;
inset: -8px 0;
}
.track.vol .fill {
pointer-events: none;
}
.vol-thumb {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--text-primary);
transform: translate(-50%, -50%) scale(0);
transition: transform 0.12s ease;
pointer-events: none;
}
.track.vol:hover .vol-thumb,
.track.vol.dragging .vol-thumb,
.track.vol:focus-visible .vol-thumb {
transform: translate(-50%, -50%) scale(1);
}
.track.vol:hover,
.track.vol.dragging {
height: 5px;
}
.track.vol:hover .fill,
.track.vol.dragging .fill {
background: var(--accent);
}
</style>
+166
View File
@@ -0,0 +1,166 @@
<script>
import { player, saveStream } from "../lib/state.svelte.js";
import Icon from "./Icon.svelte";
// align hint: "down" (panel) or "up" (player bar). Auto-flips if needed.
let { align = "down" } = $props();
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
const isStream = $derived(!!currentItem?.stream);
const streamCount = $derived(player.queue.filter((it) => it.stream).length);
const isAlbum = $derived(streamCount > 1);
let triggerEl = $state(null);
let open = $state(false);
let pos = $state({ top: 0, left: 0 });
let toast = $state("");
let toastPos = $state({ top: 0, left: 0 });
let toastTimer;
const MENU_W = 190;
function place() {
const r = triggerEl.getBoundingClientRect();
const h = 92; // two items
let left = Math.max(8, Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8));
let top = align === "up" ? r.top - 6 - h : r.bottom + 6;
if (align !== "up" && top + h > window.innerHeight - 8) top = r.top - 6 - h;
if (align === "up" && top < 8) top = r.bottom + 6;
pos = { top, left };
}
async function save(scope) {
open = false;
const res = await saveStream(scope);
if (!res.msg) return;
const r = triggerEl.getBoundingClientRect();
toastPos = {
top: align === "up" ? r.top - 34 : r.bottom + 6,
left: Math.max(8, r.right - 190),
};
toast = res.msg;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast = ""), 2600);
}
function onClick(e) {
e.stopPropagation();
// Single track: one destination, save straight away. Album: choose scope.
if (!isAlbum) {
save("track");
return;
}
if (open) {
open = false;
return;
}
place();
open = true;
}
$effect(() => {
if (!open) return;
const onDoc = (e) => {
if (!e.target.closest(".save-wrap") && !e.target.closest(".save-pop")) open = false;
};
const close = () => (open = false);
document.addEventListener("click", onDoc, true);
document.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("click", onDoc, true);
document.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
});
</script>
{#if isStream}
<span class="save-wrap">
<button
bind:this={triggerEl}
class="save-icon"
class:on={open}
title="Save to library"
onclick={onClick}
>
<Icon name="download" size={18} />
</button>
</span>
{#if open}
<div class="save-pop menu" style="top:{pos.top}px; left:{pos.left}px;">
<button class="mi" onclick={() => save("track")}>
<Icon name="music" size={16} /> Save this track
</button>
<button class="mi" onclick={() => save("album")}>
<Icon name="library" size={16} /> Save whole album
</button>
</div>
{/if}
{#if toast}
<div class="save-pop toast" style="top:{toastPos.top}px; left:{toastPos.left}px;">
{toast}
</div>
{/if}
{/if}
<style>
.save-wrap { display: inline-flex; }
.save-icon {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 8px;
color: var(--text-secondary);
}
.save-icon:hover,
.save-icon.on {
color: var(--text-primary);
background: var(--bg-highlight);
}
/* Fixed positioning escapes the panel's overflow clipping and any
stacking context, so the popover always sits on top. */
.save-pop {
position: fixed;
z-index: 1000;
}
.menu {
width: 190px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
box-shadow: 0 16px 38px -12px rgba(0, 0, 0, 0.75);
}
.mi {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px 10px;
border-radius: 6px;
font-size: 13px;
color: var(--text-primary);
text-align: left;
}
.mi:hover { background: var(--bg-highlight); }
.mi :global(.icon) { color: var(--text-secondary); flex: 0 0 auto; }
.toast {
white-space: nowrap;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 8px;
padding: 7px 12px;
font-size: 12px;
color: var(--text-primary);
box-shadow: 0 12px 26px -10px rgba(0, 0, 0, 0.75);
}
</style>
+276
View File
@@ -0,0 +1,276 @@
<script>
import { app, ui, network, playlistStore, refreshPlaylists, toggleSidebar } from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
function navigate(id) {
ui.view = id;
if (id !== "library") ui.albumId = null;
}
async function newPlaylist() {
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
const id = await api.createPlaylist(name);
await refreshPlaylists();
ui.view = "playlist";
ui.playlistId = id;
}
function openPlaylist(id) {
ui.view = "playlist";
ui.playlistId = id;
}
const items = [
{ id: "home", label: "Home", icon: "home" },
{ id: "search", label: "Search", icon: "search" },
{ id: "library", label: "Your Library", icon: "library" },
{ id: "downloads", label: "Downloads", icon: "download" },
{ id: "settings", label: "Settings", icon: "settings" },
];
</script>
<nav class="sidebar" class:collapsed={ui.sidebarCollapsed}>
<div class="sb-top">
<button
class="collapse-btn"
title={ui.sidebarCollapsed ? "Expand" : "Collapse"}
onclick={toggleSidebar}
>
<Icon name="chevron-left" size={20} />
</button>
</div>
<ul>
{#each items as item (item.id)}
<li>
<button
class="nav-item"
class:active={ui.view === item.id}
title={ui.sidebarCollapsed ? item.label : null}
onclick={() => navigate(item.id)}
>
<span class="icon"><Icon name={item.icon} size={22} /></span>
<span class="label">{item.label}</span>
</button>
</li>
{/each}
</ul>
<div class="playlists">
<div class="pl-head">
<span class="label">Playlists</span>
<button class="pl-new" title="New playlist" onclick={newPlaylist}>
<Icon name="plus" size={16} />
</button>
</div>
<div class="pl-list">
{#each playlistStore.playlists as pl (pl.id)}
<button
class="pl-item"
class:active={ui.view === "playlist" && ui.playlistId === pl.id}
onclick={() => openPlaylist(pl.id)}
>
<span class="pl-name">{pl.name}</span>
<span class="pl-count">{pl.track_count}</span>
</button>
{/each}
</div>
</div>
<div class="footer">
{#if app.connection === "online" && network.shareFiles > 0}
<div class="share-info" title="Files you share with the network">
sharing {network.shareFiles} files
</div>
{/if}
</div>
</nav>
<style>
.sidebar {
grid-area: sidebar;
background: var(--bg-base);
border-radius: var(--radius);
display: flex;
flex-direction: column;
padding: 12px 8px;
overflow: hidden;
}
ul {
list-style: none;
}
.sb-top {
display: flex;
justify-content: flex-end;
padding: 0 4px 6px;
}
.collapse-btn {
width: 30px;
height: 30px;
border-radius: 6px;
display: grid;
place-items: center;
color: var(--text-secondary);
flex: 0 0 auto;
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1), color 0.15s;
}
.collapse-btn:hover {
color: var(--text-primary);
background: var(--bg-highlight);
}
/* Label collapse: fades and zeroes width so icons stay put. */
.label {
white-space: nowrap;
overflow: hidden;
max-width: 160px;
opacity: 1;
transition: opacity 0.15s ease, max-width 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ---- collapsed icon-rail state ---- */
.sidebar.collapsed .sb-top {
justify-content: center;
}
.sidebar.collapsed .collapse-btn {
transform: rotate(180deg);
}
.sidebar.collapsed .label {
opacity: 0;
max-width: 0;
}
.sidebar.collapsed .nav-item {
justify-content: center;
gap: 0;
padding: 10px 0;
}
.sidebar.collapsed .pl-head {
justify-content: center;
padding: 0 0 8px;
}
.sidebar.collapsed .pl-list {
display: none;
}
.sidebar.collapsed .footer {
opacity: 0;
}
.playlists {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.pl-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px 8px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
}
.pl-new {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--bg-highlight);
color: var(--text-secondary);
font-size: 16px;
display: grid;
place-items: center;
}
.pl-new:hover {
color: var(--text-primary);
background: var(--bg-press);
}
.pl-list {
overflow-y: auto;
flex: 1;
}
.pl-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 7px 12px;
border-radius: 5px;
color: var(--text-secondary);
font-size: 13px;
text-align: left;
}
.pl-item:hover {
color: var(--text-primary);
}
.pl-item.active {
background: var(--bg-highlight);
color: var(--text-primary);
}
.pl-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pl-count {
font-size: 11px;
color: var(--text-muted);
flex-shrink: 0;
}
.nav-item {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 10px 12px;
border-radius: 6px;
color: var(--text-secondary);
font-weight: 600;
transition: color 0.15s;
}
.nav-item:hover {
color: var(--text-primary);
}
.nav-item.active {
color: var(--text-primary);
background: var(--bg-highlight);
}
.icon {
width: 22px;
display: grid;
place-items: center;
}
.footer {
padding: 8px 4px;
}
.share-info {
padding: 2px 8px;
font-size: 11px;
color: var(--text-muted);
}
</style>
+598
View File
@@ -0,0 +1,598 @@
<script>
import {
app,
ui,
downloads,
catalogGo,
openCatalogArtist,
warmCatalogArtist,
cancelCatalogArtistWarmup,
streamSong,
streamCatalogAlbum,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
let maximized = $state(false);
let searchText = $state("");
// ---- download status ring (aggregate progress + active count) ----
const DL_ACTIVE = new Set(["connecting", "requested", "queued", "transferring"]);
const RING_C = 2 * Math.PI * 9; // circumference of the r=9 progress circle
// The ring aggregates over the whole download burst, not just the
// currently-active transfers — otherwise each finished file drops out
// of the fraction and the ring jumps backward instead of filling.
// A burst spans from the first active download (ids are monotonic, so
// members are `id >= session start`) until the queue drains.
let dlSession = $state({ min: null });
const dl = $derived.by(() => {
let active = 0;
let received = 0;
let total = 0;
for (const d of Object.values(downloads.items)) {
const isActive = DL_ACTIVE.has(d.state);
if (isActive) active += 1;
if (!d.total) continue;
if (isActive) {
received += d.received;
total += d.total;
} else if (
d.state === "complete" &&
dlSession.min != null &&
d.id >= dlSession.min
) {
received += d.total;
total += d.total;
}
}
return { active, frac: total > 0 ? Math.min(1, received / total) : 0 };
});
// When the queue drains, flash a full ring + check before hiding, then
// close out the burst.
let dlFlash = $state(false);
let dlHadActive = false;
$effect(() => {
if (dl.active > 0) {
dlHadActive = true;
dlFlash = false;
if (dlSession.min == null) {
let min = Infinity;
for (const d of Object.values(downloads.items)) {
if (DL_ACTIVE.has(d.state)) min = Math.min(min, d.id);
}
if (min < Infinity) dlSession.min = min;
}
} else if (dlHadActive) {
dlHadActive = false;
dlFlash = true;
const t = setTimeout(() => {
dlFlash = false;
dlSession.min = null;
}, 2500);
return () => clearTimeout(t);
}
});
// ---- live search suggestions (Spotify-style dropdown) ----
let sugg = $state(null); // last catalogSearch results for the dropdown
let suggOpen = $state(false);
let hi = $state(-1); // highlighted row index, -1 = none
let suggSeq = 0;
let debounceTimer = null;
const suggRows = $derived.by(() => {
if (!sugg) return [];
const rows = [];
for (const a of sugg.artists.slice(0, 2)) {
rows.push({ kind: "artist", label: a.name, sub: "Artist", img: a.picture, round: true, data: a });
}
for (const al of sugg.albums.slice(0, 3)) {
const year = al.release_date ? ` · ${al.release_date.slice(0, 4)}` : "";
rows.push({ kind: "album", label: al.title, sub: `Album · ${al.artist}${year}`, img: al.cover, round: false, data: al });
}
for (const t of sugg.tracks.slice(0, 4)) {
rows.push({ kind: "track", label: t.title, sub: `Song · ${t.artist}`, img: t.cover, round: false, data: t });
}
return rows;
});
function onSearchInput() {
clearTimeout(debounceTimer);
cancelCatalogArtistWarmup();
const q = searchText.trim();
if (q.length < 2) {
suggOpen = false;
sugg = null;
return;
}
debounceTimer = setTimeout(async () => {
const seq = ++suggSeq;
try {
const res = await api.catalogSearch(q);
// Ignore stale responses and results for superseded queries.
if (seq !== suggSeq || searchText.trim() !== q) return;
sugg = res;
hi = -1;
suggOpen = true;
// The first artist is overwhelmingly the next click. Warm its
// one-round-trip overview and art resolver while the user is
// still reading the dropdown; keyed caches share the work.
if (res.artists?.[0]) warmCatalogArtist(res.artists[0]);
} catch {
// backend unavailable — no suggestions
}
}, 250);
}
function closeSuggest() {
suggOpen = false;
hi = -1;
}
function pickRow(row) {
closeSuggest();
cancelCatalogArtistWarmup();
if (row.kind === "artist") openCatalogArtist(row.data.id, row.data);
else if (row.kind === "album") streamCatalogAlbum(row.data);
else {
const t = row.data;
streamSong(t.artist, t.title, {
cover: t.cover,
trackId: t.id,
albumId: t.album_id,
album: t.album || null,
expectSecs: t.duration_secs ?? null,
});
}
}
function searchKeydown(e) {
if (!suggOpen || suggRows.length === 0) {
if (e.key === "Escape") closeSuggest();
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
hi = (hi + 1) % suggRows.length;
} else if (e.key === "ArrowUp") {
e.preventDefault();
hi = hi <= 0 ? suggRows.length - 1 : hi - 1;
} else if (e.key === "Enter" && hi >= 0) {
e.preventDefault();
pickRow(suggRows[hi]);
} else if (e.key === "Escape") {
closeSuggest();
}
}
function searchFocusOut(e) {
// Keep the panel open while focus moves within the search form.
if (!e.currentTarget.contains(e.relatedTarget)) closeSuggest();
}
function submitSearch(e) {
e.preventDefault();
clearTimeout(debounceTimer);
suggSeq += 1; // cancel any in-flight suggestion fetch
closeSuggest();
catalogGo(searchText);
}
async function syncMaximized() {
try {
maximized = await api.win.isMaximized();
} catch {
// not running inside Tauri (browser preview)
}
}
$effect(() => {
let unlisten = null;
syncMaximized();
api.win
.onResized(syncMaximized)
.then((f) => (unlisten = f))
.catch(() => {});
return () => unlisten?.();
});
function statusClick() {
if (app.connection === "online") {
api.disconnect();
app.connection = "offline";
} else if (app.connection === "offline") {
app.showLogin = true;
}
}
</script>
<header class="titlebar" data-tauri-drag-region>
<div class="brand">
<img class="logo" src="/logo.png" alt="" draggable="false" />
<span class="name">SoulStream</span>
</div>
<button
class="status {app.connection}"
onclick={statusClick}
title={app.connection === "online" ? "Click to disconnect" : "Click to connect"}
>
● {app.connection === "online" ? app.username : app.connection}
</button>
<form class="bar-search" onsubmit={submitSearch} onfocusout={searchFocusOut}>
<Icon name="search" size={13} />
<input
type="text"
placeholder="Search artists, albums, songs…"
bind:value={searchText}
oninput={onSearchInput}
onkeydown={searchKeydown}
onfocus={() => { if (sugg && searchText.trim().length >= 2) suggOpen = true; }}
spellcheck="false"
/>
{#if suggOpen && suggRows.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="suggest" onmousedown={(e) => e.preventDefault()}>
{#each suggRows as row, i (row.kind + row.data.id)}
<button
type="button"
class="sugg-row"
class:active={i === hi}
onmouseenter={() => (hi = i)}
onclick={() => pickRow(row)}
>
{#if row.img}
<img class="sugg-img" class:round={row.round} src={row.img} alt="" draggable="false" />
{:else}
<div class="sugg-img placeholder" class:round={row.round}>
<Icon name="music" size={13} />
</div>
{/if}
<span class="sugg-text">
<span class="sugg-label">{row.label}</span>
<span class="sugg-sub">{row.sub}</span>
</span>
{#if row.kind !== "artist"}
<span class="sugg-play"><Icon name="play" size={12} /></span>
{/if}
</button>
{/each}
<button type="button" class="sugg-row all" class:active={hi === -1} onclick={submitSearch}>
<span class="sugg-all-icon"><Icon name="search" size={13} /></span>
<span class="sugg-text">
<span class="sugg-label">See all results for “{searchText.trim()}”</span>
<span class="sugg-sub">Enter</span>
</span>
</button>
</div>
{/if}
</form>
<div class="controls">
{#if dl.active > 0 || dlFlash}
<button
class="dl-ring"
title={dlFlash
? "Downloads finished"
: `${dl.active} downloading — open Downloads`}
onclick={() => (ui.view = "downloads")}
>
<svg width="26" height="26" viewBox="0 0 26 26">
<circle class="track" cx="13" cy="13" r="9" />
<circle
class="prog"
cx="13"
cy="13"
r="9"
stroke-dasharray={RING_C}
stroke-dashoffset={dlFlash ? 0 : RING_C * (1 - dl.frac)}
transform="rotate(-90 13 13)"
/>
{#if dlFlash}
<path class="tick" d="M9 13.4l2.7 2.7 5.2-5.6" />
{:else}
<text x="13" y="16.5" text-anchor="middle">
{dl.active > 9 ? "9+" : dl.active}
</text>
{/if}
</svg>
</button>
{/if}
<button class="win-btn" title="Minimize" onclick={() => api.win.minimize()}>
<Icon name="minus" size={16} />
</button>
<button
class="win-btn"
title={maximized ? "Restore" : "Maximize"}
onclick={() => api.win.toggleMaximize().then(syncMaximized)}
>
<Icon name={maximized ? "restore" : "square"} size={13} />
</button>
<button class="win-btn close" title="Close" onclick={() => api.win.close()}>
<Icon name="x" size={16} />
</button>
</div>
</header>
<style>
/* Lines the brand up with the sidebar's content column below: the bar
already sits inside the 8px window frame, so 20px more reaches the
8px sidebar padding + 12px item padding. */
.titlebar {
grid-area: titlebar;
position: relative;
/* Above the sibling content panels, so the search suggestions
dropdown paints over album grids instead of interleaving. */
z-index: 100;
display: flex;
align-items: center;
gap: 10px;
padding-left: 20px;
user-select: none;
-webkit-user-select: none;
}
/* The visible strip above the panels is the 30px bar row plus the 8px
grid gap; nudge the left cluster down so it centers optically in that
full strip. The window controls stay flush with the top edge. */
.brand,
.status {
position: relative;
top: 4px;
}
/* Decorative: let mouse events fall through to the drag region. */
.brand {
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
}
.logo {
width: 20px;
height: 20px;
border-radius: 5px;
}
.name {
font-weight: 700;
font-size: 13px;
}
.status {
font-size: 12px;
color: var(--text-muted);
padding: 3px 9px;
border-radius: 10px;
}
.status:hover {
background: var(--bg-highlight);
color: var(--text-secondary);
}
.status.online {
color: var(--accent-hover);
}
.status.connecting {
color: #e8a33d;
}
/* Centered on the window regardless of the left cluster's width; the
same +4px optical nudge as the brand. Dragging still works on the
uncovered bar either side. */
.bar-search {
position: absolute;
left: 50%;
top: 7px;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
width: min(360px, 30vw);
height: 24px;
padding: 0 12px;
border-radius: 12px;
background: var(--bg-elevated);
color: var(--text-muted);
}
.bar-search:focus-within {
outline: 1px solid var(--text-muted);
color: var(--text-secondary);
}
.bar-search input {
flex: 1;
min-width: 0;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: 12px;
}
.bar-search input::placeholder {
color: var(--text-muted);
}
/* ---- live suggestions dropdown ---- */
.suggest {
position: absolute;
top: 30px;
left: -8px;
right: -8px;
display: flex;
flex-direction: column;
padding: 6px;
border-radius: 10px;
background: var(--bg-elevated);
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.55);
z-index: 50;
}
.sugg-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 5px 8px;
border-radius: 7px;
text-align: left;
color: var(--text-primary);
}
.sugg-row.active {
background: var(--bg-highlight);
}
.sugg-img {
width: 30px;
height: 30px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
background: var(--bg-highlight);
}
.sugg-img.round {
border-radius: 50%;
}
.sugg-img.placeholder {
display: grid;
place-items: center;
color: var(--text-muted);
}
.sugg-text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.sugg-label {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sugg-sub {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sugg-play {
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
color: var(--text-muted);
opacity: 0;
flex-shrink: 0;
}
.sugg-row:hover .sugg-play,
.sugg-row.active .sugg-play {
opacity: 1;
color: var(--text-primary);
}
.sugg-row.all {
margin-top: 2px;
border-top: 1px solid var(--bg-highlight);
border-radius: 0 0 7px 7px;
color: var(--text-secondary);
}
.sugg-row.all.active,
.sugg-row.all:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
.sugg-all-icon {
display: grid;
place-items: center;
width: 30px;
height: 30px;
flex-shrink: 0;
color: var(--text-muted);
}
.controls {
margin-left: auto;
display: flex;
align-self: stretch;
}
/* download status ring */
.dl-ring {
align-self: center;
display: grid;
place-items: center;
width: 34px;
height: 30px;
margin-right: 4px;
border-radius: 7px;
}
.dl-ring:hover {
background: var(--bg-highlight);
}
.dl-ring .track {
fill: none;
stroke: rgba(255, 255, 255, 0.16);
stroke-width: 2.5;
}
.dl-ring .prog {
fill: none;
stroke: var(--accent-hover);
stroke-width: 2.5;
stroke-linecap: round;
transition: stroke-dashoffset 0.4s ease;
}
.dl-ring text {
fill: var(--text-primary);
font-size: 10px;
font-weight: 700;
}
.dl-ring .tick {
fill: none;
stroke: var(--accent-hover);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.win-btn {
width: 46px;
display: grid;
place-items: center;
color: var(--text-secondary);
border-radius: 0;
}
.win-btn:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
.win-btn.close:hover {
background: #c42b1c;
color: #fff;
}
</style>
+154
View File
@@ -0,0 +1,154 @@
// Thin wrapper over Tauri IPC so components never import tauri directly.
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { mockInvoke } from "./mockLibrary.js";
// mockInvoke is inert (returns undefined) unless localStorage.mockLibrary === "1".
const invoke = (cmd, args) => mockInvoke(cmd, args) ?? tauriInvoke(cmd, args);
// Artist-page payloads are keyed by a Deezer id all the way through the
// backend. Reject a mismatched response at the IPC boundary so a provider,
// cache, or future command regression cannot populate another artist's page.
// String comparison deliberately accepts route-string ids and numeric Tauri
// ids as the same identity without coercing the u64 argument sent to Rust.
const artistIdKey = (value) => (value == null ? "" : String(value).trim());
const sameArtistId = (left, right) => {
const leftKey = artistIdKey(left);
return !!leftKey && leftKey === artistIdKey(right);
};
const checkedArtistPayload = (command, artistId) =>
invoke(command, { artistId }).then((payload) => {
if (!sameArtistId(payload?.artist?.id, artistId)) {
throw new Error(`${command} returned a mismatched artist identity`);
}
return payload;
});
const checkedArtistIdentity = (artistId, artist) =>
invoke("catalog_artist_identity", { artistId, artist }).then((payload) => {
if (!sameArtistId(payload?.artist_id ?? payload?.artistId, artistId)) {
throw new Error("catalog_artist_identity returned a mismatched artist identity");
}
return payload;
});
const checkedAlbumPayload = (albumId) =>
invoke("catalog_album", { albumId }).then((payload) => {
if (!sameArtistId(payload?.album?.id, albumId)) {
throw new Error("catalog_album returned a mismatched album identity");
}
return payload;
});
export const appStatus = () => invoke("app_status");
export const getCredentials = () => invoke("get_credentials");
export const connectAndLogin = (username, password, remember) =>
invoke("connect_and_login", { username, password, remember });
export const connectStored = () => invoke("connect_stored");
export const disconnect = () => invoke("disconnect");
export const startSearch = (query) => invoke("start_search", { query });
export const excludedSearchPhrases = () => invoke("excluded_search_phrases");
export const stopSearch = (token) => invoke("stop_search", { token });
export const downloadFile = (username, path, size) =>
invoke("download_file", { username, path, size });
export const copyStreamToFolder = (files, folder) =>
invoke("copy_stream_to_folder", { files, folder });
export const saveToLibrary = (files) => invoke("save_to_library", { files });
export const cancelDownload = (id) => invoke("cancel_download", { id });
export const showInFolder = (path) => invoke("show_in_folder", { path });
export const refreshArtistArt = () => invoke("refresh_artist_art");
export const playStream = (username, path, size, duration) =>
invoke("play_stream", { username, path, size, duration });
export const playLocal = (path, duration) => invoke("play_local", { path, duration });
export const preloadLocal = (path, duration) => invoke("preload_local", { path, duration });
export const playerControl = (action, value, rate) =>
invoke("player_control", { action, value: value ?? null, rate: rate ?? null });
export const waveform = (path, buckets) => invoke("waveform", { path, buckets });
export const libraryAlbums = () => invoke("library_albums");
export const albumTracks = (albumId) => invoke("album_tracks", { albumId });
export const recordPlay = (artist, title) => invoke("record_play", { artist, title });
export const addPlaySeconds = (playId, secs) => invoke("add_play_seconds", { playId, secs });
export const artistPlayStats = (artist) => invoke("artist_play_stats", { artist });
// The WebView blocks target="_blank"; external links open via the backend.
export const openExternal = (url) => invoke("open_external", { url });
export const albumArt = (path) => invoke("album_art", { path });
export const refreshAlbumArt = (albumId) => invoke("refresh_album_art", { albumId });
export const refreshAllAlbumArt = () => invoke("refresh_all_album_art");
export const getSettings = () => invoke("get_settings");
export const setSettings = (patch) => invoke("set_settings", patch);
export const forgetCredentials = () => invoke("forget_credentials");
export const pickFolder = () => invoke("pick_folder");
export const listPlaylists = () => invoke("list_playlists");
export const createPlaylist = (name) => invoke("create_playlist", { name });
export const deletePlaylist = (id) => invoke("delete_playlist", { id });
export const playlistTracks = (id) => invoke("playlist_tracks", { id });
export const addToPlaylist = (playlistId, tracks) =>
invoke("add_to_playlist", { playlistId, tracks });
export const removeFromPlaylist = (playlistId, position) =>
invoke("remove_from_playlist", { playlistId, position });
export const catalogSearch = (query) => invoke("catalog_search", { query });
export const catalogArtist = (artistId) => checkedArtistPayload("catalog_artist", artistId);
export const catalogArtistArt = (artistId) =>
checkedArtistPayload("catalog_artist_art", artistId);
export const catalogArtistLogos = (artistId) => invoke("catalog_artist_logos", { artistId });
// Deferred MusicBrainz pass (socials + MB-only releases). Rate-limited at
// 1 req/s upstream, so it is fetched after the page renders, never before.
export const catalogArtistSocials = (artistId) =>
invoke("catalog_artist_socials", { artistId });
export const catalogArtistBio = (artistId, artist) =>
invoke("catalog_artist_bio", { artistId: artistId ?? null, artist });
export const catalogArtistIdentity = (artistId, artist) =>
checkedArtistIdentity(artistId, artist);
export const catalogCachedCoverLogo = (artistId) =>
invoke("catalog_cached_cover_logo", { artistId });
export const catalogCoverLogo = (artistId, artist, covers) =>
invoke("catalog_cover_logo", { artistId, artist, covers });
export const catalogArtistHeroSeed = (artistId) =>
invoke("catalog_artist_hero_seed", { artistId });
export const catalogArtistOverview = (artistId) =>
checkedArtistPayload("catalog_artist_overview", artistId);
export const pickArtistHero = (artistId) => invoke("pick_artist_hero", { artistId });
export const clearArtistHero = (artistId) => invoke("clear_artist_hero", { artistId });
export const catalogRelated = (artistId) => invoke("catalog_related", { artistId });
export const catalogAlbum = (albumId) => checkedAlbumPayload(albumId);
export const catalogAlbumInfo = (artistId, artist, title) =>
invoke("catalog_album_info", { artistId: artistId ?? null, artist, title });
export const catalogArt = (url) => invoke("catalog_art", { url });
export const catalogCover = (artistId, artist, title, cover) =>
invoke("catalog_cover", { artistId: artistId ?? null, artist, title, cover });
export const invalidateCatalogCover = (artistId, artist, title, cover) =>
invoke("invalidate_catalog_cover", { artistId: artistId ?? null, artist, title, cover });
export const catalogHeroPreview = (url) => invoke("catalog_hero_preview", { url });
export const catalogHeroGpuSource = (url, plan = null) =>
invoke("catalog_hero_gpu_source", { url, plan });
export const catalogCachedHeroArt = (url) => invoke("catalog_cached_hero_art", { url });
export const catalogHeroArt = (url, plan = null) =>
invoke("catalog_hero_art", { url, plan });
export const preloadStream = (username, path, size) =>
invoke("preload_stream", { username, path, size });
export const preloadCachedStream = (username, path, size, duration) =>
invoke("preload_cached_stream", { username, path, size, duration });
export const streamWaveform = (username, path, size, buckets) =>
invoke("stream_waveform", { username, path, size, buckets });
// Window chrome (the app draws its own titlebar; decorations are off).
import { getCurrentWindow } from "@tauri-apps/api/window";
export const win = {
minimize: () => getCurrentWindow().minimize(),
toggleMaximize: () => getCurrentWindow().toggleMaximize(),
close: () => getCurrentWindow().close(),
isMinimized: () => getCurrentWindow().isMinimized(),
isFocused: () => getCurrentWindow().isFocused(),
isMaximized: () => getCurrentWindow().isMaximized(),
// returns Promise<unlisten>
onResized: (handler) => getCurrentWindow().onResized(handler),
onFocusChanged: (handler) => getCurrentWindow().onFocusChanged(handler),
};
// Subscribe to a backend event; returns an unlisten function.
// No-ops outside Tauri (e.g. plain-browser preview).
export function onEvent(name, handler) {
const un = listen(name, (e) => handler(e.payload)).catch(() => () => {});
return () => un.then((f) => f());
}
+130
View File
@@ -0,0 +1,130 @@
// Dominant-color extraction from album art, tuned to produce a usable
// accent on a dark UI: prefers saturated mid-lightness pixels, then clamps
// the result into a safe saturation/lightness band.
export function hexToRgb(hex) {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export function rgbToHex([r, g, b]) {
const c = (v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0");
return `#${c(r)}${c(g)}${c(b)}`;
}
export function lerpRgb(a, b, t) {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return [0, 0, l];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return [h, s, l];
}
function hslToRgb(h, s, l) {
if (s === 0) return [l * 255, l * 255, l * 255];
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const f = (t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255];
}
/**
* Extract an accent color from an image data URL.
* Returns { accent, hover } hex strings, or null if nothing usable.
*/
export async function accentFromImage(dataUrl) {
const img = new Image();
img.src = dataUrl;
try {
await img.decode();
} catch {
return null;
}
const S = 48;
const canvas = document.createElement("canvas");
canvas.width = S;
canvas.height = S;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, S, S);
let data;
try {
data = ctx.getImageData(0, 0, S, S).data;
} catch {
return null;
}
// "True tone": the color FAMILY that covers the most of the artwork wins
// (chroma-weighted hue histogram), and the result keeps that family's
// real saturation/lightness character — a mossy cover yields moss, not
// neon. Legibility clamps are gentle floors, not vibrancy boosts.
const BINS = 24;
const binWeight = new Array(BINS).fill(0);
const samples = [];
const pixelCount = data.length / 4;
for (let i = 0; i < data.length; i += 4) {
const [h, s, l] = rgbToHsl(data[i], data[i + 1], data[i + 2]);
if (l < 0.08 || l > 0.95 || s < 0.1) continue;
const w = s * (1 - Math.abs(2 * l - 1)); // ~chroma
if (w <= 0) continue;
const bin = Math.floor(h * BINS) % BINS;
binWeight[bin] += w;
samples.push({ h, s, l, w, bin });
}
// Neutral guard: effectively grayscale art (or art with only a faint
// color cast) stays a deliberate silver, not an invented hue.
const totalWeight = samples.reduce((sum, p) => sum + p.w, 0);
if (!samples.length || totalWeight / pixelCount < 0.03) {
return {
accent: rgbToHex(hslToRgb(0, 0.05, 0.72)),
hover: rgbToHex(hslToRgb(0, 0.05, 0.8)),
};
}
// Smooth adjacent bins so a family split across a boundary still wins.
let bestBin = 0;
let bestScore = -1;
for (let i = 0; i < BINS; i++) {
const score = binWeight[i]
+ 0.5 * (binWeight[(i + 1) % BINS] + binWeight[(i + BINS - 1) % BINS]);
if (score > bestScore) { bestScore = score; bestBin = i; }
}
// Weighted circular mean over the winning family (bin ± 1).
let x = 0, y = 0, ws = 0, sSum = 0, lSum = 0;
for (const p of samples) {
const d = Math.min((p.bin - bestBin + BINS) % BINS, (bestBin - p.bin + BINS) % BINS);
if (d > 1) continue;
x += Math.cos(p.h * 2 * Math.PI) * p.w;
y += Math.sin(p.h * 2 * Math.PI) * p.w;
sSum += p.s * p.w;
lSum += p.l * p.w;
ws += p.w;
}
if (!ws) return null;
let h = Math.atan2(y, x) / (2 * Math.PI);
if (h < 0) h += 1;
const s = Math.min(0.88, Math.max(0.3, (sSum / ws) * 1.05));
const l = Math.min(0.66, Math.max(0.46, lSum / ws));
const accent = rgbToHex(hslToRgb(h, s, l));
const hover = rgbToHex(hslToRgb(h, s, Math.min(0.74, l + 0.08)));
return { accent, hover };
}
+543
View File
@@ -0,0 +1,543 @@
export const HERO_MASTER_WIDTH = 3840;
export const HERO_MASTER_HEIGHT = 2160;
const HEADER_BYTES = 16;
const MAGIC = [0x53, 0x48, 0x47, 0x31]; // SHG1
const VS = `#version 300 es
precision highp float;
const vec2 pos[3] = vec2[3](vec2(-1.,-1.), vec2(3.,-1.), vec2(-1.,3.));
void main(){ gl_Position = vec4(pos[gl_VertexID], 0., 1.); }`;
// FidelityFX FSR 1 EASU, transliterated from AMD's reference implementation.
// `flipInput` is used only for the first pass because Rust's RGBA rows arrive
// top-first while render-target textures use conventional GL bottom-first rows.
const FS_EASU = `#version 300 es
precision highp float;
uniform sampler2D src;
uniform vec2 inSize;
uniform vec2 outSize;
uniform float flipInput;
out vec4 frag;
float lumaOf(vec3 c){ return c.b*0.5 + (c.r*0.5 + c.g); }
vec3 tex(ivec2 p){ return texelFetch(src, clamp(p, ivec2(0), ivec2(inSize)-1), 0).rgb; }
void easuSet(inout vec2 dir, inout float len, vec2 pp, bool biS, bool biT, bool biU, bool biV,
float lA, float lB, float lC, float lD, float lE){
float w = 0.0;
if (biS) w = (1.0-pp.x)*(1.0-pp.y);
if (biT) w = pp.x*(1.0-pp.y);
if (biU) w = (1.0-pp.x)*pp.y;
if (biV) w = pp.x*pp.y;
float dc = lD-lC, cb = lC-lB;
float lenX = max(abs(dc), abs(cb));
lenX = 1.0/max(lenX, 1e-5);
float dirX = lD-lB;
dir.x += dirX*w;
lenX = clamp(abs(dirX)*lenX, 0.0, 1.0);
lenX *= lenX;
len += lenX*w;
float ec = lE-lC, ca = lC-lA;
float lenY = max(abs(ec), abs(ca));
lenY = 1.0/max(lenY, 1e-5);
float dirY = lE-lA;
dir.y += dirY*w;
lenY = clamp(abs(dirY)*lenY, 0.0, 1.0);
lenY *= lenY;
len += lenY*w;
}
void easuTap(inout vec3 aC, inout float aW, vec2 off, vec2 dir, vec2 len, float lob, float clp, vec3 c){
vec2 v = vec2(off.x*dir.x + off.y*dir.y, off.x*(-dir.y) + off.y*dir.x);
v *= len;
float d2 = min(v.x*v.x + v.y*v.y, clp);
float wB = 0.4*d2 - 1.0;
float wA = lob*d2 - 1.0;
wB *= wB; wA *= wA;
wB = (25.0/16.0)*wB - (25.0/16.0 - 1.0);
float w = wB*wA;
aC += c*w; aW += w;
}
void main(){
vec2 ip = floor(gl_FragCoord.xy);
if (flipInput > 0.5) ip.y = outSize.y - 1.0 - ip.y;
vec2 pp = (ip + 0.5)*(inSize/outSize) - 0.5;
vec2 fp = floor(pp);
pp -= fp;
ivec2 F = ivec2(fp);
vec3 bC = tex(F+ivec2(0,-1)), cC = tex(F+ivec2(1,-1));
vec3 eC = tex(F+ivec2(-1,0)), fC = tex(F), gC = tex(F+ivec2(1,0)), hC = tex(F+ivec2(2,0));
vec3 iC = tex(F+ivec2(-1,1)), jC = tex(F+ivec2(0,1)), kC = tex(F+ivec2(1,1)), lC2 = tex(F+ivec2(2,1));
vec3 nC = tex(F+ivec2(0,2)), oC = tex(F+ivec2(1,2));
float bL = lumaOf(bC), cL = lumaOf(cC), eL = lumaOf(eC), fL = lumaOf(fC);
float gL = lumaOf(gC), hL = lumaOf(hC), iL = lumaOf(iC), jL = lumaOf(jC);
float kL = lumaOf(kC), lL = lumaOf(lC2), nL = lumaOf(nC), oL = lumaOf(oC);
vec2 dir = vec2(0.0); float len = 0.0;
easuSet(dir, len, pp, true,false,false,false, bL, eL, fL, gL, jL);
easuSet(dir, len, pp, false,true,false,false, cL, fL, gL, hL, kL);
easuSet(dir, len, pp, false,false,true,false, fL, iL, jL, kL, nL);
easuSet(dir, len, pp, false,false,false,true, gL, jL, kL, lL, oL);
vec2 dir2 = dir*dir;
float dirR = dir2.x + dir2.y;
bool zro = dirR < (1.0/32768.0);
dirR = inversesqrt(max(dirR, 1e-12));
dirR = zro ? 1.0 : dirR;
dir.x = zro ? 1.0 : dir.x;
dir *= dirR;
len = len*0.5;
len *= len;
float stretch = (dir.x*dir.x + dir.y*dir.y)/max(max(abs(dir.x), abs(dir.y)), 1e-5);
vec2 len2 = vec2(1.0 + (stretch - 1.0)*len, 1.0 - 0.5*len);
float lob = 0.5 + ((1.0/4.0 - 0.04) - 0.5)*len;
float clp = 1.0/max(lob, 1e-5);
vec3 min4 = min(min(fC, gC), min(jC, kC));
vec3 max4 = max(max(fC, gC), max(jC, kC));
vec3 aC = vec3(0.0); float aW = 0.0;
easuTap(aC, aW, vec2( 0.,-1.)-pp, dir, len2, lob, clp, bC);
easuTap(aC, aW, vec2( 1.,-1.)-pp, dir, len2, lob, clp, cC);
easuTap(aC, aW, vec2(-1., 1.)-pp, dir, len2, lob, clp, iC);
easuTap(aC, aW, vec2( 0., 1.)-pp, dir, len2, lob, clp, jC);
easuTap(aC, aW, vec2( 0., 0.)-pp, dir, len2, lob, clp, fC);
easuTap(aC, aW, vec2(-1., 0.)-pp, dir, len2, lob, clp, eC);
easuTap(aC, aW, vec2( 1., 1.)-pp, dir, len2, lob, clp, kC);
easuTap(aC, aW, vec2( 1., 0.)-pp, dir, len2, lob, clp, gC);
easuTap(aC, aW, vec2( 0., 2.)-pp, dir, len2, lob, clp, nC);
easuTap(aC, aW, vec2( 1., 2.)-pp, dir, len2, lob, clp, oC);
easuTap(aC, aW, vec2( 2., 1.)-pp, dir, len2, lob, clp, lC2);
easuTap(aC, aW, vec2( 2., 0.)-pp, dir, len2, lob, clp, hC);
frag = vec4(min(max4, max(min4, aC/aW)), 1.0);
}`;
const FS_RCAS = `#version 300 es
precision highp float;
uniform sampler2D src;
uniform vec2 size;
uniform float con;
out vec4 frag;
vec3 tex(ivec2 p){ return texelFetch(src, clamp(p, ivec2(0), ivec2(size)-1), 0).rgb; }
void main(){
ivec2 sp = ivec2(gl_FragCoord.xy);
vec3 b = tex(sp+ivec2(0,-1)), d = tex(sp+ivec2(-1,0)), e = tex(sp), f = tex(sp+ivec2(1,0)), h = tex(sp+ivec2(0,1));
vec3 mn4 = min(min(b,d),min(f,h)), mx4 = max(max(b,d),max(f,h));
vec3 hitMin = min(mn4,e)/(4.0*mx4+1e-5);
vec3 hitMax = (1.0-max(mx4,e))/(4.0*mn4-4.0-1e-5);
vec3 lobeRGB = max(-hitMin,hitMax);
float lobe = max(-0.1875, min(max(lobeRGB.r,max(lobeRGB.g,lobeRGB.b)),0.0))*con;
frag = vec4(((b+d+f+h)*lobe+e)/(4.0*lobe+1.0), 1.0);
}`;
// Exact shipped Ruby Chrome 30% + vintage fade 100% + native-4K grain 70%.
// `trunc`, not `floor`, matches Rust's signed integer division toward zero.
const FS_GRADE = `#version 300 es
precision highp float;
uniform sampler2D src;
uniform float grainAmp;
uniform float seed;
out vec4 frag;
float ss(float e0,float e1,float v){ float t=clamp((v-e0)/(e1-e0),0.,1.); return t*t*(3.-2.*t); }
uint grainHash(uint x){
x ^= x >> 16; x *= 0x7feb352du;
x ^= x >> 15; x *= 0x846ca68bu;
return x ^ (x >> 16);
}
float grainByte(ivec2 p,uint salt){
uint key = uint(p.x)*0x9e3779b1u ^ uint(p.y)*0x85ebca6bu ^ salt;
return float(grainHash(key) & 255u);
}
void main(){
ivec2 pixel = ivec2(gl_FragCoord.xy);
vec3 c = texelFetch(src, pixel, 0).rgb;
float luma = c.r*0.2126+c.g*0.7152+c.b*0.0722;
float sh = 1.0-ss(0.16,0.60,luma), hi = ss(0.38,0.90,luma);
vec3 g = mix(vec3(luma), c, 1.24);
g = (g-0.5)*1.12+0.5;
g = mix(g, g*g*(3.-2.*g), 0.18);
g.r += hi*0.070+sh*0.018; g.g -= hi*0.028; g.b += sh*0.045+hi*0.018;
g = clamp(g,0.,1.);
vec3 color = mix(c, g, 0.30);
float l2 = color.r*0.2126+color.g*0.7152+color.b*0.0722;
vec3 faded = clamp(mix(vec3(l2*0.78+0.078), color*0.78+vec3(0.085,0.078,0.068), 0.88),0.,1.);
uint grainSeed = uint(seed);
float r1 = grainByte(pixel,grainSeed)-128.;
float r2 = grainByte(pixel,grainSeed^0xa511e9b3u)-128.;
float delta = clamp(trunc((r1+r2)*grainAmp/256.), -grainAmp, grainAmp)/255.;
frag = vec4(clamp(faded+delta,0.,1.), 1.0);
}`;
const FS_BLIT = `#version 300 es
precision highp float;
uniform sampler2D src;
uniform vec2 outSize;
uniform vec2 uvScale;
uniform vec2 uvOffset;
uniform float flipInput;
out vec4 frag;
void main(){
vec2 uv = gl_FragCoord.xy/outSize;
uv = uvOffset + uv*uvScale;
if (flipInput > 0.5) uv.y = 1.0-uv.y;
frag = vec4(texture(src, uv).rgb, 1.0);
}`;
// Preserve the 16:9 master like CSS object-fit cover, aligned center/top.
export function heroCoverUv(outputWidth, outputHeight) {
const sourceAspect = HERO_MASTER_WIDTH / HERO_MASTER_HEIGHT;
const outputAspect = outputWidth / outputHeight;
if (outputAspect > sourceAspect) {
const visibleHeight = sourceAspect / outputAspect;
return {
scale: [1, visibleHeight],
offset: [0, 1 - visibleHeight],
};
}
const visibleWidth = outputAspect / sourceAspect;
return {
scale: [visibleWidth, 1],
offset: [(1 - visibleWidth) / 2, 0],
};
}
function parsePayload(payload) {
const bytes = payload instanceof Uint8Array
? payload
: new Uint8Array(payload);
if (bytes.byteLength < HEADER_BYTES || !MAGIC.every((v, i) => bytes[i] === v)) {
throw new Error("invalid live hero payload");
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const width = view.getUint32(4, true);
const height = view.getUint32(8, true);
const seed = view.getUint32(12, true);
const expected = HEADER_BYTES + width * height * 4;
if (!width || !height || !Number.isSafeInteger(expected) || expected !== bytes.byteLength) {
throw new Error("invalid live hero dimensions");
}
return { width, height, seed, rgba: bytes.subarray(HEADER_BYTES) };
}
function compile(gl, source, type) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const error = gl.getShaderInfoLog(shader) || "shader compilation failed";
gl.deleteShader(shader);
throw new Error(error);
}
return shader;
}
function program(gl, fragment) {
const result = gl.createProgram();
const vertex = compile(gl, VS, gl.VERTEX_SHADER);
const pixel = compile(gl, fragment, gl.FRAGMENT_SHADER);
gl.attachShader(result, vertex);
gl.attachShader(result, pixel);
gl.linkProgram(result);
gl.deleteShader(vertex);
gl.deleteShader(pixel);
if (!gl.getProgramParameter(result, gl.LINK_STATUS)) {
const error = gl.getProgramInfoLog(result) || "shader link failed";
gl.deleteProgram(result);
throw new Error(error);
}
return result;
}
function mipLevels(width, height) {
return 1 + Math.floor(Math.log2(Math.max(width, height)));
}
export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previousBloomCanvas, onLost) {
const gl = canvas.getContext("webgl2", {
alpha: false,
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: false,
preserveDrawingBuffer: true,
powerPreference: "high-performance",
});
if (!gl) throw new Error("WebGL2 is unavailable");
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
if ("drawingBufferColorSpace" in gl) gl.drawingBufferColorSpace = "srgb";
const maxTexture = gl.getParameter(gl.MAX_TEXTURE_SIZE);
if (maxTexture < HERO_MASTER_WIDTH) {
throw new Error(`GPU texture limit ${maxTexture}px is below 4K`);
}
const programs = {
easu: program(gl, FS_EASU),
rcas: program(gl, FS_RCAS),
grade: program(gl, FS_GRADE),
blit: program(gl, FS_BLIT),
};
let master = null;
let destroyed = false;
const contextLost = (event) => {
event.preventDefault();
onLost?.(new Error("WebGL context lost"));
};
canvas.addEventListener("webglcontextlost", contextLost);
function texture(width, height, levels = 1) {
const value = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, value);
gl.texStorage2D(gl.TEXTURE_2D, levels, gl.RGBA8, width, height);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(
gl.TEXTURE_2D,
gl.TEXTURE_MIN_FILTER,
levels > 1 ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR,
);
return value;
}
function target(width, height, levels = 1) {
const tx = texture(width, height, levels);
const fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tx, 0);
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
gl.deleteFramebuffer(fb);
gl.deleteTexture(tx);
throw new Error("GPU hero framebuffer is incomplete");
}
return { tx, fb, width, height, levels };
}
function disposeTarget(value, keepTexture = false) {
if (!value) return;
if (value.fb) gl.deleteFramebuffer(value.fb);
if (!keepTexture && value.tx) gl.deleteTexture(value.tx);
}
function draw(prog, src, output, uniforms = {}) {
gl.useProgram(prog);
gl.bindFramebuffer(gl.FRAMEBUFFER, output?.fb ?? null);
const width = output?.width ?? canvas.width;
const height = output?.height ?? canvas.height;
gl.viewport(0, 0, width, height);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, src);
gl.uniform1i(gl.getUniformLocation(prog, "src"), 0);
for (const [name, value] of Object.entries(uniforms)) {
const location = gl.getUniformLocation(prog, name);
if (location === null) continue;
if (Array.isArray(value)) gl.uniform2f(location, value[0], value[1]);
else gl.uniform1f(location, value);
}
gl.drawArrays(gl.TRIANGLES, 0, 3);
const error = gl.getError();
if (error !== gl.NO_ERROR) throw new Error(`WebGL hero draw failed (${error})`);
}
function copyCanvas(from, to) {
if (!from.width || !from.height) return;
to.width = from.width;
to.height = from.height;
const context = to.getContext("2d", { alpha: false });
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.drawImage(from, 0, 0, to.width, to.height);
}
function snapshot() {
if (!master || !canvas.width) return false;
copyCanvas(canvas, previousCanvas);
copyCanvas(bloomCanvas, previousBloomCanvas);
return true;
}
function buildMaster(payload) {
const { width, height, seed, rgba } = parsePayload(payload);
if (width > maxTexture || height > maxTexture) {
throw new Error(`hero source ${width}x${height} exceeds the GPU texture limit`);
}
const downsample = width > HERO_MASTER_WIDTH || height > HERO_MASTER_HEIGHT;
const sourceLevels = downsample ? mipLevels(width, height) : 1;
const source = texture(width, height, sourceLevels);
const temporaries = [];
let scaled = null;
let sharpened = null;
let finished = null;
try {
gl.bindTexture(gl.TEXTURE_2D, source);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
width,
height,
gl.RGBA,
gl.UNSIGNED_BYTE,
rgba,
);
if (downsample) gl.generateMipmap(gl.TEXTURE_2D);
if (downsample || (width === HERO_MASTER_WIDTH && height === HERO_MASTER_HEIGHT)) {
scaled = target(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT);
temporaries.push(scaled);
draw(programs.blit, source, scaled, {
outSize: [HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT],
uvScale: [1, 1],
uvOffset: [0, 0],
flipInput: 1,
});
} else {
let currentTexture = source;
let currentWidth = width;
let currentHeight = height;
let firstPass = true;
const firstWidth = Math.min(width * 2, HERO_MASTER_WIDTH);
if (firstWidth < HERO_MASTER_WIDTH) {
const intermediate = target(
firstWidth,
Math.max(1, Math.round((height * firstWidth) / width)),
);
temporaries.push(intermediate);
draw(programs.easu, currentTexture, intermediate, {
inSize: [currentWidth, currentHeight],
outSize: [intermediate.width, intermediate.height],
flipInput: 1,
});
currentTexture = intermediate.tx;
currentWidth = intermediate.width;
currentHeight = intermediate.height;
firstPass = false;
}
scaled = target(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT);
temporaries.push(scaled);
draw(programs.easu, currentTexture, scaled, {
inSize: [currentWidth, currentHeight],
outSize: [HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT],
flipInput: firstPass ? 1 : 0,
});
}
sharpened = target(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT);
temporaries.push(sharpened);
draw(programs.rcas, scaled.tx, sharpened, {
size: [HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT],
con: Math.pow(2, -0.25),
});
finished = target(
HERO_MASTER_WIDTH,
HERO_MASTER_HEIGHT,
mipLevels(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT),
);
draw(programs.grade, sharpened.tx, finished, {
grainAmp: 20,
seed,
});
gl.bindTexture(gl.TEXTURE_2D, finished.tx);
gl.generateMipmap(gl.TEXTURE_2D);
gl.finish();
disposeTarget(finished, true);
return { tx: finished.tx, width: HERO_MASTER_WIDTH, height: HERO_MASTER_HEIGHT };
} catch (error) {
disposeTarget(finished);
throw error;
} finally {
for (const value of temporaries) disposeTarget(value);
gl.deleteTexture(source);
}
}
function copyBloom() {
if (!canvas.width || !canvas.height) return;
const cssHeight = 106;
const rect = canvas.getBoundingClientRect();
const scale = rect.height > 0 ? canvas.height / rect.height : 1;
const sourceHeight = Math.max(1, Math.min(canvas.height, Math.round(cssHeight * scale)));
bloomCanvas.width = canvas.width;
bloomCanvas.height = sourceHeight;
const context = bloomCanvas.getContext("2d", { alpha: false });
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.drawImage(
canvas,
0,
0,
canvas.width,
sourceHeight,
0,
0,
bloomCanvas.width,
bloomCanvas.height,
);
}
function resize() {
if (destroyed || !master) return;
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const dpr = Math.max(1, window.devicePixelRatio || 1);
const scale = Math.min(
dpr,
HERO_MASTER_WIDTH / rect.width,
HERO_MASTER_HEIGHT / rect.height,
);
const width = Math.max(1, Math.round(rect.width * scale));
const height = Math.max(1, Math.round(rect.height * scale));
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
const cover = heroCoverUv(width, height);
draw(programs.blit, master.tx, null, {
outSize: [width, height],
uvScale: cover.scale,
uvOffset: cover.offset,
flipInput: 0,
});
gl.finish();
copyBloom();
}
function render(payload) {
if (destroyed) throw new Error("GPU hero renderer is closed");
const hadPrevious = snapshot();
const started = performance.now();
const next = buildMaster(payload);
const previous = master;
master = next;
resize();
if (previous?.tx) gl.deleteTexture(previous.tx);
return {
hadPrevious,
renderMs: performance.now() - started,
renderer: gl.getParameter(gl.RENDERER),
};
}
// Keep the compiled programs/context warm between artist navigations, but
// release the 4K master texture and canvas backing stores while there is no
// selected GPU hero. A stale master is both visually wrong and roughly
// 44 MiB once its mip chain is included.
function clear() {
if (destroyed) return;
if (master?.tx && !gl.isContextLost()) gl.deleteTexture(master.tx);
master = null;
for (const target of [canvas, bloomCanvas, previousCanvas, previousBloomCanvas]) {
target.width = 1;
target.height = 1;
}
}
function destroy() {
if (destroyed) return;
destroyed = true;
canvas.removeEventListener("webglcontextlost", contextLost);
if (master?.tx && !gl.isContextLost()) gl.deleteTexture(master.tx);
if (!gl.isContextLost()) {
for (const value of Object.values(programs)) gl.deleteProgram(value);
}
master = null;
}
return { render, resize, clear, destroy };
}
+69
View File
@@ -0,0 +1,69 @@
// Dev-only mock library data, for exercising the Library UI on a machine
// without a populated music library. Inert unless explicitly enabled by
// setting localStorage.mockLibrary = "1" in the webview (then reload);
// remove the key to go back to real backend data.
const ARTISTS = ["Aphex Twin","Boards of Canada","Caribou","Daft Punk","Eno Brian","Four Tet","Grimes","Holly Herndon","Jon Hopkins","Kraftwerk","LCD Soundsystem","Moderat","Nils Frahm","Oneohtrix","Portishead","Radiohead","Squarepusher","Tycho","Underworld","Vangelis","Wilco","Yello","Zero 7","Air","Bonobo","Burial","Clark","Deadmau5","Emancipator","Floating Points","Gold Panda","Helios","Kiasmos","Lorn","Moby","Nujabes","Ott","Plaid","Rival Consoles","Shpongle"];
const albums = [];
let aid = 1;
for (let i = 0; i < ARTISTS.length; i++) {
const n = 2 + (i % 3); // 2-4 albums per artist
for (let j = 0; j < n; j++) {
albums.push({
id: aid,
artist: ARTISTS[i],
title: `Album ${j + 1} of ${ARTISTS[i]}`,
year: 1995 + ((i + j) % 30),
track_count: 10,
art_path: `mock://art/${aid}`,
artist_art_path: `mock://artist/${i}`,
path: `C:\\mock\\${i}\\${j}`,
});
aid++;
}
}
function svgArt(hue, label) {
const s =
`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">` +
`<rect width="256" height="256" fill="hsl(${hue},45%,35%)"/>` +
`<text x="128" y="140" font-size="90" text-anchor="middle" fill="rgba(255,255,255,.75)" font-family="sans-serif">${label}</text></svg>`;
return "data:image/svg+xml;base64," + btoa(s);
}
function tracksFor(albumId) {
const artist = albums.find((a) => a.id === albumId)?.artist ?? "?";
return Array.from({ length: 10 }, (_, k) => ({
id: albumId * 100 + k,
title: `Track ${k + 1}`,
artist,
track_no: k + 1,
duration_secs: 120 + k * 17,
bitrate_kbps: 320,
path: `C:\\mock\\t${albumId}-${k}.mp3`,
}));
}
const enabled = (() => {
try {
return localStorage.getItem("mockLibrary") === "1";
} catch {
return false;
}
})();
/** Returns a Promise for mocked commands, or undefined to pass through. */
export function mockInvoke(cmd, args) {
if (!enabled) return undefined;
if (cmd === "library_albums") return Promise.resolve(albums);
if (cmd === "album_tracks")
return new Promise((r) => setTimeout(() => r(tracksFor(args.albumId)), 60));
if (cmd === "album_art") {
const m = /(\d+)$/.exec(args.path || "");
const num = m ? +m[1] : 0;
return Promise.resolve(svgArt((num * 47) % 360, String.fromCharCode(65 + (num % 26))));
}
if (cmd === "list_playlists") return Promise.resolve([]);
return undefined;
}
+211
View File
@@ -0,0 +1,211 @@
// Floating overlay scrollbar, applied with `use:floatScroll` on any element
// that scrolls. It hides the native scrollbar entirely — so the bar never
// reserves layout width and can never reflow the content when it appears —
// and draws a thin thumb that floats over the container's right padding,
// fading in while scrolling and out again when idle. No track, no arrow
// buttons; just the one bar. Reusable across the whole app.
//
// When the scroller has a floating glass header pinned to its top (marked
// `[data-scrollhead]`, either a descendant or a direct sibling), the thumb's
// travel starts one PAD *below* that header, so it never rides up over the
// header and shows the same PAD gap top and bottom.
//
// ARCHITECTURE — read before changing how the thumb is positioned.
// The thumb lives inside a zero-height `position: sticky` rail, so the
// COMPOSITOR keeps it pinned in the scrollport; JS only slides it along the
// track. The obvious simpler design — an absolute thumb counter-scrolled per
// frame (top = scrollTop + trackPos) — jitters as soon as the scroller gets
// composited (threaded) scrolling, which the artist page's sticky
// backdrop-filter header forces: the compositor scrolls the content ahead of
// the main thread, the counter-scroll commits one frame late, and the thumb
// visibly oscillates by the full per-frame scroll delta. Pages that scroll on
// the main thread never show this, which is why the bug looked artist-page-
// only. With the sticky rail, a late frame only mis-places the thumb along the
// track by scrollDelta × (trackH / scrollH) — a few percent — imperceptible.
const PAD = 8; // gap between the thumb's travel and the top header / bottom edge
const HOVER_ZONE = 22; // px in from the right edge that reveals the thumb on hover
export function floatScroll(node) {
node.classList.add("float-scroll");
node.style.scrollbarWidth = "none"; // inline: beats scoped component rules
if (getComputedStyle(node).position === "static") node.style.position = "relative";
// Zero-height sticky rail as the scroller's FIRST child (sticky can only
// hold an element back from scrolling up past `top: 0`, so it must sit at
// the top of the flow). The thumb hangs off it, absolutely positioned.
const rail = document.createElement("div");
rail.className = "float-rail";
const thumb = document.createElement("div");
thumb.className = "float-thumb";
rail.appendChild(thumb);
node.prepend(rail);
let hideTimer = 0;
let dragging = false;
let measureRaf = 0;
// Cached layout, refreshed only by measure() (never during a scroll).
let active = false;
let topInset = PAD;
let trackH = 0;
let thumbH = 24;
let maxScroll = 0;
let padTop = 0;
let boxRight = 0; // viewport x of the scroller's right edge (for hover)
let boxTop = 0; // viewport y of the scroller's top edge
// The `show` state (scrolling) fades on its own after a beat; the separate
// `hover` state below is independent and controls its own visibility, so a
// deliberate grab only needs `dragging` to hold the thumb up.
function scheduleHide() {
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
if (!dragging) thumb.classList.remove("show");
}, 500);
}
function show() {
thumb.classList.add("show");
scheduleHide();
}
// Recompute the cached geometry. Runs on init and whenever the layout might
// have changed (resize, content grow/shrink, a view swapping a header in or
// out) — but deliberately NOT on scroll: the per-frame path below reads
// only scrollTop and forces no layout.
function measure() {
measureRaf = 0;
const viewH = node.clientHeight;
const scrollH = node.scrollHeight;
const box = node.getBoundingClientRect();
boxRight = box.right;
boxTop = box.top;
const cs = getComputedStyle(node);
padTop = parseFloat(cs.paddingTop) || 0;
// The rail is an in-flow (zero-height) child. In a flex/grid scroller
// it would still contribute one row-gap before the real first child —
// cancel it so adding the rail never shifts the content.
const rowGap = parseFloat(cs.rowGap) || 0;
rail.style.marginBottom = rowGap ? `-${rowGap}px` : "0";
// The rail spans the content box; the thumb should sit 3px inside the
// PADDING box's right edge (where it always lived), so pull it back out
// through the right padding.
const padRight = parseFloat(cs.paddingRight) || 0;
thumb.style.right = `${3 - padRight}px`;
// A `[data-scrollhead]` glass header pinned to the top of this scroller:
// a descendant (the artist page's Results bar inside the content pane)
// or a direct sibling (the Now Playing header sits beside its scroller).
const head =
node.querySelector("[data-scrollhead]") ||
node.parentElement?.querySelector(":scope > [data-scrollhead]");
const headH = head ? head.getBoundingClientRect().height : 0;
active = scrollH > viewH + 1;
if (!active) {
thumb.style.display = "none";
return;
}
topInset = headH + PAD;
trackH = Math.max(0, viewH - topInset - PAD);
thumbH = Math.max(24, Math.min(trackH, (viewH / scrollH) * trackH));
maxScroll = scrollH - viewH;
thumb.style.display = "block";
thumb.style.height = `${thumbH}px`;
place();
}
function scheduleMeasure() {
if (!measureRaf) measureRaf = requestAnimationFrame(measure);
}
// Per-frame, cheap: read only scrollTop, write one composited transform.
// The engine pins the sticky rail at the scroller's CONTENT-box top (i.e.
// `padTop` below the visual top edge, verified empirically in WebView2) for
// the entire scroll, so the rail-relative offset is a constant correction.
function place() {
if (!active) return;
const ratio = maxScroll > 0 ? node.scrollTop / maxScroll : 0;
const y = topInset - padTop + ratio * (trackH - thumbH);
thumb.style.transform = `translateY(${y}px)`;
}
function onScroll() {
place();
show();
}
// The thumb appears while scrolling; separately, moving the pointer into the
// narrow strip along the scroller's right edge reveals it faintly (the
// `hover` state) so it can be found and grabbed without scrolling first.
node.addEventListener("scroll", onScroll, { passive: true });
let nearEdge = false;
function setHover(on) {
if (on === nearEdge) return;
nearEdge = on;
thumb.classList.toggle("hover", on);
}
function onPointerMove(e) {
setHover(
active &&
e.clientX >= boxRight - HOVER_ZONE &&
e.clientX <= boxRight + 2 &&
e.clientY >= boxTop + topInset - 4,
);
}
function onPointerLeave() {
setHover(false);
}
node.addEventListener("pointermove", onPointerMove, { passive: true });
node.addEventListener("pointerleave", onPointerLeave);
const ro = new ResizeObserver(scheduleMeasure);
ro.observe(node);
// Content growing/shrinking (queue changes, sections toggling) changes
// scrollHeight; childList catches that — and a view swapping a header in or
// out — without firing on every text tick.
const mo = new MutationObserver(scheduleMeasure);
mo.observe(node, { childList: true, subtree: true });
// Drag the thumb.
let dragY0 = 0;
let scroll0 = 0;
thumb.addEventListener("pointerdown", (e) => {
dragging = true;
dragY0 = e.clientY;
scroll0 = node.scrollTop;
thumb.classList.add("dragging");
thumb.setPointerCapture(e.pointerId);
e.preventDefault();
});
thumb.addEventListener("pointermove", (e) => {
if (!dragging) return;
const track = trackH - thumbH;
node.scrollTop = scroll0 + (track > 0 ? ((e.clientY - dragY0) / track) * maxScroll : 0);
});
function endDrag(e) {
if (!dragging) return;
dragging = false;
thumb.classList.remove("dragging");
if (thumb.hasPointerCapture(e.pointerId)) thumb.releasePointerCapture(e.pointerId);
scheduleHide();
}
thumb.addEventListener("pointerup", endDrag);
thumb.addEventListener("pointercancel", endDrag);
measure();
return {
destroy() {
clearTimeout(hideTimer);
if (measureRaf) cancelAnimationFrame(measureRaf);
node.removeEventListener("scroll", onScroll);
node.removeEventListener("pointermove", onPointerMove);
node.removeEventListener("pointerleave", onPointerLeave);
// The thumb's own pointer listeners go away with rail.remove().
ro.disconnect();
mo.disconnect();
rail.remove();
},
};
}
File diff suppressed because it is too large. Load diff
+338
View File
@@ -0,0 +1,338 @@
// Silhouette tracer: anti-aliased alpha raster -> SVG path. No dependencies.
//
// Fanart's ClearLOGOs are a fixed 800x310 canvas, so a logo whose ink is short
// (Weezer's is only 131px tall) cannot be enlarged without visibly blurring. We
// only ever paint these as a flat accent-coloured mask, so the colour data is
// irrelevant -- all we need is the ALPHA SILHOUETTE, and that traces cleanly to
// vector: crisp at any size, and free of the source's resolution ceiling.
//
// Marching squares with INTERPOLATION is what makes this work. Thresholding
// alpha to a hard in/out mask and walking pixel edges would yield a 1px
// staircase that no amount of smoothing fully hides (and whose 90-degree steps
// are indistinguishable from real corners in a letterform). Instead we treat
// alpha as a scalar field and place each vertex where it actually crosses 50%,
// which recovers sub-pixel edge positions from the source's anti-aliasing --
// the curve of an "o" comes out smooth because the anti-aliased pixels told us
// where the true edge lay.
const ISO = 127.5; // the 50%-coverage isoline we trace
// --- separating the ink from a baked-in drop shadow -------------------------
// Many ClearLOGOs paint a drop shadow into the artwork itself, and it is often
// FULLY OPAQUE (Weezer's is ~5k pixels at alpha 255), so tracing the alpha
// channel picks the shadow up as if it were part of the letterform and the
// glyphs come out bloated and tailed. Alpha cannot separate them at any
// threshold -- but colour can: we only ever mask logos that scored as
// monochrome, so the ink is one colour and the shadow is a different (darker)
// one. Model each pixel as a linear blend of ink C and shadow S and keep the
// ink's share; that also un-blends the anti-aliased pixels along the boundary,
// which is what keeps the traced edge sub-pixel accurate.
const LUM = (r, g, b) => r * 0.299 + g * 0.587 + b * 0.114;
/** Mean colour of the dominant 3-bit bucket among solid pixels: the ink. */
function inkColor(px, w, h) {
const buckets = new Map();
for (let p = 0; p < w * h; p++) {
const i = p * 4;
if (px[i + 3] < 200) continue;
const key = ((px[i] >> 5) << 6) | ((px[i + 1] >> 5) << 3) | (px[i + 2] >> 5);
const b = buckets.get(key);
if (b) {
b[0] += px[i];
b[1] += px[i + 1];
b[2] += px[i + 2];
b[3]++;
} else buckets.set(key, [px[i], px[i + 1], px[i + 2], 1]);
}
let top = null;
for (const b of buckets.values()) if (!top || b[3] > top[3]) top = b;
if (!top) return null;
return [top[0] / top[3], top[1] / top[3], top[2] / top[3]];
}
/**
* Per-pixel ink coverage in 0..255 -- the field we actually trace.
*
* With no shadow present this is just the alpha channel, so clean logos trace
* exactly as before. When a dark opaque population IS found, each pixel is
* un-blended against the ink/shadow axis so only the letterform survives.
*/
function inkField(px, w, h) {
const n = w * h;
const field = new Float32Array(n);
const C = inkColor(px, w, h);
if (!C) return field;
// The un-blend below models the artwork as exactly two colours, but a drop
// shadow is a soft GRADIENT. Where a letter edge sits over its own shadow
// the model is slightly off, and the 50% isoline jitters by a fraction of a
// pixel -- which reads as a serrated edge once the logo is scaled up. A mild
// blur of the coverage field removes that high-frequency jitter while
// leaving a straight edge exactly where it was.
const soften = () => {
const tmp = new Float32Array(n);
const at = (x, y) => field[Math.min(h - 1, Math.max(0, y)) * w + Math.min(w - 1, Math.max(0, x))];
for (let y = 0; y < h; y++)
for (let x = 0; x < w; x++)
tmp[y * w + x] = (at(x - 1, y) + 2 * at(x, y) + at(x + 1, y)) / 4;
const at2 = (x, y) => tmp[Math.min(h - 1, Math.max(0, y)) * w + x];
for (let y = 0; y < h; y++)
for (let x = 0; x < w; x++)
field[y * w + x] = (at2(x, y - 1) + 2 * at2(x, y) + at2(x, y + 1)) / 4;
};
// Is there a shadow? Look for solid pixels markedly darker than the ink.
const darkCut = LUM(C[0], C[1], C[2]) - 60;
let sr = 0, sg = 0, sb = 0, dark = 0, solid = 0;
for (let p = 0; p < n; p++) {
const i = p * 4;
if (px[i + 3] < 200) continue;
solid++;
if (LUM(px[i], px[i + 1], px[i + 2]) < darkCut) {
sr += px[i];
sg += px[i + 1];
sb += px[i + 2];
dark++;
}
}
// Ink darker than its own shadow would be, or barely any dark pixels: no
// shadow to strip, so fall back to plain alpha (previous behaviour).
const S = dark ? [sr / dark, sg / dark, sb / dark] : null;
const dx = S ? C[0] - S[0] : 0;
const dy = S ? C[1] - S[1] : 0;
const dz = S ? C[2] - S[2] : 0;
const len2 = dx * dx + dy * dy + dz * dz;
if (!S || dark / solid < 0.02 || len2 < 900) {
for (let p = 0; p < n; p++) field[p] = px[p * 4 + 3];
return field;
}
// coverage = how far along the shadow->ink axis this pixel sits, times its
// own opacity. Pure ink -> 1, pure shadow -> 0, anti-aliased edge -> the
// true fractional coverage.
for (let p = 0; p < n; p++) {
const i = p * 4;
const a = px[i + 3];
if (!a) continue;
const t =
((px[i] - S[0]) * dx + (px[i + 1] - S[1]) * dy + (px[i + 2] - S[2]) * dz) /
len2;
field[p] = a * Math.max(0, Math.min(1, t));
}
soften(); // only on the shadow path -- clean logos need no smoothing
return field;
}
// Marching-squares cases -> contour segments, as [fromEdge, toEdge] pairs.
// Corner bits: 1 top-left, 2 top-right, 4 bottom-right, 8 bottom-left.
// Edges: 0 top, 1 right, 2 bottom, 3 left. Every segment is directed so the ink
// lies to its LEFT, which makes all loops close with consistent winding.
const CASES = [
[], // 0 ....
[[3, 0]], // 1 tl
[[0, 1]], // 2 tr
[[3, 1]], // 3 tl tr
[[1, 2]], // 4 br
null, // 5 tl br -- saddle, resolved by the cell centre
[[0, 2]], // 6 tr br
[[3, 2]], // 7 tl tr br
[[2, 3]], // 8 bl
[[2, 0]], // 9 tl bl
null, // 10 tr bl -- saddle
[[2, 1]], // 11 tl tr bl
[[1, 3]], // 12 br bl
[[1, 0]], // 13 tl br bl
[[0, 3]], // 14 tr br bl
[], // 15 ....
];
/**
* Trace an ink-coverage field into closed sub-pixel contours.
* Returns loops of [x, y] points in source-pixel coordinates.
*/
function contours(cov, w, h) {
// Padded by one empty pixel, so ink touching the image border still closes
// into a loop instead of running off the edge.
const W = w + 2;
const H = h + 2;
const a = new Float32Array(W * H);
for (let y = 0; y < h; y++)
for (let x = 0; x < w; x++) a[(y + 1) * W + (x + 1)] = cov[y * w + x];
const at = (x, y) => a[y * W + x];
// Where along a cell edge does alpha cross ISO? This is the whole trick:
// the fractional position recovers the true edge from anti-aliased pixels.
const lerp = (v0, v1) => {
const d = v1 - v0;
return Math.abs(d) < 1e-6 ? 0.5 : (ISO - v0) / d;
};
// Endpoint of `edge` for the cell whose top-left corner is (x, y).
const point = (x, y, edge) => {
const tl = at(x, y);
const tr = at(x + 1, y);
const br = at(x + 1, y + 1);
const bl = at(x, y + 1);
switch (edge) {
case 0: return [x + lerp(tl, tr), y];
case 1: return [x + 1, y + lerp(tr, br)];
case 2: return [x + lerp(bl, br), y + 1];
default: return [x, y + lerp(tl, bl)];
}
};
// Chain segments by their endpoints. Two cells sharing an edge compute that
// crossing from the same pair of corner values, so the coordinates are
// bit-identical and hash to the same key -- no tolerance needed.
const key = ([x, y]) => `${x.toFixed(4)},${y.toFixed(4)}`;
const next = new Map(); // start key -> {to, toKey}
for (let y = 0; y < H - 1; y++) {
for (let x = 0; x < W - 1; x++) {
const tl = at(x, y) >= ISO ? 1 : 0;
const tr = at(x + 1, y) >= ISO ? 2 : 0;
const br = at(x + 1, y + 1) >= ISO ? 4 : 0;
const bl = at(x, y + 1) >= ISO ? 8 : 0;
const c = tl | tr | br | bl;
let segs = CASES[c];
if (segs && !segs.length) continue;
if (!segs) {
// Saddle: the cell centre decides whether the two ink corners
// join through the middle or stay separate blobs.
const mid =
(at(x, y) + at(x + 1, y) + at(x + 1, y + 1) + at(x, y + 1)) / 4;
const joined = mid >= ISO;
if (c === 5)
segs = joined ? [[1, 0], [3, 2]] : [[3, 0], [1, 2]];
else segs = joined ? [[0, 3], [2, 1]] : [[0, 1], [2, 3]];
}
for (const [from, to] of segs) {
const p0 = point(x, y, from);
const p1 = point(x, y, to);
next.set(key(p0), { to: p1, toKey: key(p1) });
}
}
}
const loops = [];
const seen = new Set();
for (const start of next.keys()) {
if (seen.has(start)) continue;
const loop = [];
let k = start;
while (!seen.has(k)) {
const step = next.get(k);
if (!step) break; // open contour (shouldn't happen once padded)
seen.add(k);
loop.push(step.to);
k = step.toKey;
}
if (loop.length >= 3) loops.push(loop);
}
// Undo the 1px pad so coordinates are back in source-image space.
return loops.map((l) => l.map(([x, y]) => [x - 1, y - 1]));
}
/** Ramer-Douglas-Peucker: drop vertices that stay within `eps` of the chord. */
function simplify(pts, eps) {
if (pts.length < 3) return pts;
const keep = new Uint8Array(pts.length);
keep[0] = keep[pts.length - 1] = 1;
const stack = [[0, pts.length - 1]];
while (stack.length) {
const [i, j] = stack.pop();
const [x1, y1] = pts[i];
const [x2, y2] = pts[j];
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.hypot(dx, dy) || 1;
let far = -1;
let best = eps;
for (let k = i + 1; k < j; k++) {
const [px, py] = pts[k];
const d = Math.abs(dy * px - dx * py + x2 * y1 - y2 * x1) / len;
if (d > best) {
best = d;
far = k;
}
}
if (far > 0) {
keep[far] = 1;
stack.push([i, far], [far, j]);
}
}
return pts.filter((_, i) => keep[i]);
}
/** Signed area -- used to discard specks by size rather than point count. */
function area(pts) {
let s = 0;
for (let i = 0, n = pts.length; i < n; i++) {
const [x1, y1] = pts[i];
const [x2, y2] = pts[(i + 1) % n];
s += x1 * y2 - x2 * y1;
}
return Math.abs(s) / 2;
}
/**
* Trace RGBA pixels into an SVG whose viewBox is exactly the ink's bounding box.
*
* Because the viewBox hugs the ink, this needs none of the sprite-cropping the
* raster path required: `mask: url(...) center / contain` fills its box, and
* `aspect` is the artwork's true aspect ratio.
*
* @returns {{svg:string, d:string, aspect:number, ink:{w:number,h:number}}|null}
*/
export function traceToSvg(px, w, h, opts = {}) {
const {
eps = 0.22, // simplify tolerance in source px; sub-pixel, so keep small
minArea = 6, // drop specks (source px^2)
} = opts;
const loops = contours(inkField(px, w, h), w, h)
.filter((l) => area(l) >= minArea)
.map((l) => simplify(l, eps));
if (!loops.length) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const l of loops)
for (const [x, y] of l) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
const bw = maxX - minX;
const bh = maxY - minY;
if (!(bw > 0 && bh > 0)) return null;
const n = (v) => Math.round(v * 100) / 100;
const d = loops
.map(
(l) =>
"M" + l.map(([x, y]) => `${n(x - minX)} ${n(y - minY)}`).join("L") + "Z",
)
.join("");
// Filled black: a CSS mask consumes the SVG's alpha, so only coverage
// matters. evenodd keeps letter counters (the hole in an "o") open.
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n(bw)} ${n(bh)}">` +
`<path fill="#000" fill-rule="evenodd" d="${d}"/></svg>`;
return { svg, d, aspect: bw / bh, ink: { w: bw, h: bh } };
}
/** SVG source -> a data: URL usable in `mask-image`. */
export function svgDataUrl(svg) {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
+5
View File
@@ -0,0 +1,5 @@
import "./styles/global.css";
import { mount } from "svelte";
import App from "./App.svelte";
mount(App, { target: document.getElementById("app") });
+191
View File
@@ -0,0 +1,191 @@
/* SoulStream — Spotify-style dark theme */
/* Bundled fonts (same set as the TriSign app) */
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/assets/fonts/nunito-400.woff2") format("woff2");
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("/assets/fonts/nunito-600.woff2") format("woff2");
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/assets/fonts/nunito-700.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/assets/fonts/inter-400.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("/assets/fonts/inter-600.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/assets/fonts/inter-700.woff2") format("woff2");
}
/* Typed accent registrations let scoped overrides (the artist page's
browse accent) CSS-transition; the global rAF crossfade is unaffected. */
@property --accent {
syntax: "<color>";
inherits: true;
initial-value: #1f9ad6;
}
@property --accent-hover {
syntax: "<color>";
inherits: true;
initial-value: #41b7ee;
}
:root {
--app-font: "Nunito", -apple-system, "Segoe UI", sans-serif;
--bg-base: #121212;
--bg-sidebar: #000000;
--bg-elevated: #181818;
--bg-highlight: #242424;
--bg-press: #2a2a2a;
--bg-player: #181818;
--text-primary: #ffffff;
--text-secondary: #b3b3b3;
--text-muted: #7a7a7a;
--accent: #1f9ad6;
--accent-hover: #41b7ee;
--accent-soft: color-mix(in srgb, var(--accent) 15%, transparent);
--border: #2a2a2a;
--radius: 8px;
--sidebar-width: 232px;
--player-height: 88px;
--nowplaying-width: 340px;
}
/* Floating overlay scrollbar (see src/lib/scrollbar.js). The native bar is
hidden so it never reserves width or reflows content; a thin thumb floats
in the container's right padding, fading in on scroll/hover, out when idle. */
.float-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
.float-scroll::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
/* Zero-height sticky rail: the compositor keeps it pinned to the scrollport
top, so the thumb inside never needs JS counter-scrolling (which jitters on
composited-scrolling panes — see scrollbar.js). In flex/grid scrollers its
row-gap contribution is cancelled by an inline negative margin-bottom. */
.float-rail {
position: sticky;
top: 0;
height: 0;
z-index: 60;
pointer-events: none;
}
.float-thumb {
position: absolute;
top: 0;
right: 3px; /* refined inline: 3px inside the scroller's padding box */
width: 5px;
border-radius: 2.5px;
/* Song-name text tone (--text-secondary), nudged a little lighter. */
background: color-mix(in srgb, var(--text-secondary) 75%, #ffffff);
opacity: 0;
pointer-events: none;
/* Slides along the track via translateY (composited — no layout/paint),
so give it its own layer. Only opacity is animated. */
will-change: transform;
transition: opacity 0.15s ease;
}
/* Order matters (equal specificity, last wins): a faint hint on hover, more
present while scrolling, solid while dragging. hover < show < dragging. */
.float-thumb.hover {
opacity: 0.35;
pointer-events: auto;
}
.float-thumb.show {
opacity: 0.8;
pointer-events: auto;
}
.float-thumb.dragging {
opacity: 1;
pointer-events: auto;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
overflow: hidden;
/* Kill WebView2's document-level elastic overscroll (it visibly shifts the
whole view). Scoped to the root + the real scroll panes below — NOT `*`:
applying it to every element turns each `overflow: hidden`/`auto` box
(cards, panels, popovers, the album expander) into a scroll container
that swallows the wheel instead of chaining it to the page, so hovering
one froze scrolling. Inner regions now default to `auto` and chain. */
overscroll-behavior: none;
background: var(--bg-base);
color: var(--text-primary);
font-family: var(--app-font);
font-size: 14px;
-webkit-font-smoothing: antialiased;
user-select: none;
}
#app {
height: 100%;
}
button {
font: inherit;
color: inherit;
background: none;
border: none;
cursor: pointer;
}
input {
font: inherit;
}
::-webkit-scrollbar {
width: 12px;
}
::-webkit-scrollbar-thumb {
background: hsla(0, 0%, 100%, 0.3);
border-radius: 6px;
background-clip: padding-box;
border: 3px solid transparent;
}
::-webkit-scrollbar-thumb:hover {
background: hsla(0, 0%, 100%, 0.5);
background-clip: padding-box;
border: 3px solid transparent;
}
::-webkit-scrollbar-track {
background: transparent;
}
+380
View File
@@ -0,0 +1,380 @@
<script>
import {
catalog,
libraryStore,
streamSong,
saveSong,
streamCatalogAlbum,
saveCatalogAlbum,
resolver,
player,
openArtistByName,
artBroken,
coverUpgrades,
resolveCovers,
coverLoadError,
} from "../lib/state.svelte.js";
import Icon from "../components/Icon.svelte";
const page = $derived(catalog.albumPage); // { album, tracks }
const album = $derived(page?.album ?? null);
// Upgrade this album's cover to the highest verified-same-artwork source.
let coveredId = null;
$effect(() => {
if (album?.cover && album.id !== coveredId) {
coveredId = album.id;
resolveCovers([album], album.artist);
}
});
const nowPlaying = (kind, id) =>
player.status !== "idle" && player.catalogRef[kind] === id;
function handleCoverError(node) {
const fallback = coverLoadError(
album?.cover,
node?.currentSrc || node?.src || null,
album?.artist ?? "",
album?.title ?? "",
);
if (fallback && node?.src !== fallback) node.src = fallback;
}
const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
const inLibrary = $derived.by(() => {
if (!album) return false;
const a = norm(album.artist);
const t = norm(album.title);
return libraryStore.albums.some((x) => norm(x.artist) === a && norm(x.title) === t);
});
// An upcoming album that still has unreleased songs can't be streamed or
// saved as a whole — only its already-released singles play (per track).
const hasUnreleased = $derived((page?.tracks ?? []).some((t) => t.unreleased));
// Multi-disc releases (e.g. anniversary box sets) get "Disc N" headers, and
// each disc's tracks are numbered from 1 (Deezer's per-disc track_position).
const multiDisc = $derived(
new Set((page?.tracks ?? []).map((t) => t.disk_number ?? 1)).size > 1,
);
let saved = $state({});
function flagSaveError(key) {
saved[key] = "err";
setTimeout(() => saved[key] === "err" && (saved[key] = ""), 3000);
}
async function saveAlbumAction() {
if (!album || saved.album) return;
saved.album = "…";
if (await saveCatalogAlbum(album)) saved.album = "ok";
else flagSaveError("album");
}
async function saveTrackAction(t) {
if (saved[`t${t.id}`]) return;
saved[`t${t.id}`] = "…";
if (await saveSong(album.artist, t.title)) saved[`t${t.id}`] = "ok";
else flagSaveError(`t${t.id}`);
}
const playTrack = (t) =>
streamSong(album.artist, t.title, {
cover: t.cover ?? album.cover,
trackId: t.id,
albumId: album.id,
album: album.title || null,
expectSecs: t.duration_secs ?? null,
});
function year(a) {
return a?.release_date?.slice(0, 4) ?? "";
}
function fmtDuration(s) {
if (s == null) return "";
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
// Scroll to (and briefly flash) the track the now-playing song link targets.
let flashId = $state(null);
$effect(() => {
const tid = catalog.pendingTrackId;
if (tid == null || !page) return;
requestAnimationFrame(() => {
const el = document.getElementById(`catrack-${tid}`);
if (el) el.scrollIntoView({ block: "center", behavior: "smooth" });
});
flashId = tid;
const t = setTimeout(() => (flashId === tid) && (flashId = null), 1800);
return () => clearTimeout(t);
});
</script>
<div class="album-page">
<button class="back" onclick={() => openArtistByName(album?.artist)}>
<Icon name="chevron-left" size={16} /> {album?.artist ?? "Back"}
</button>
{#if resolver.active}
<p class="resolving"><span class="pulse">●</span> {resolver.label} — finding the best source…</p>
{:else if resolver.error}
<p class="resolving error">{resolver.error}</p>
{/if}
{#if catalog.albumLoading}
<p class="hint">Loading album…</p>
{:else if !page}
<p class="hint">{catalog.error || "Album not found."}</p>
{:else}
<header class="hero">
{#if album.cover && !artBroken[album.cover]}
<img class="cover" src={coverUpgrades[album.cover] ?? album.cover} alt="" draggable="false" onerror={(event) => handleCoverError(event.currentTarget)} />
{:else}
<div class="cover placeholder"><Icon name="disc" size={40} /></div>
{/if}
<div class="hero-info">
<p class="kind">{(album.record_type ?? "album").toUpperCase()}</p>
<h1>{album.title}</h1>
<p class="meta">
<button class="artist-link" onclick={() => openArtistByName(album.artist)}>{album.artist}</button>
{#if year(album)}· {year(album)}{/if}
{#if album.track_count}· {album.track_count} tracks{/if}
{#if inLibrary}· <span class="owned">in your library</span>{/if}
</p>
<div class="actions">
<button
class="pill"
disabled={hasUnreleased}
title={hasUnreleased ? "Not released yet" : ""}
onclick={() => streamCatalogAlbum(album)}
>▶ Play</button>
<button class="pill ghost" disabled={hasUnreleased} onclick={saveAlbumAction}>
{saved.album === "ok" ? "✓ Saved" : saved.album === "err" ? "✗ No sources" : "⤓ Save"}
</button>
</div>
</div>
</header>
<div class="tracklist">
{#each page.tracks as t, i (t.id)}
{#if multiDisc && t.disk_number !== (page.tracks[i - 1]?.disk_number ?? (i === 0 ? null : 1))}
<div class="disc-head">Disc {t.disk_number ?? 1}</div>
{/if}
{@const locked = t.unreleased}
{@const play = () => { if (!locked) playTrack(t); }}
<div
id="catrack-{t.id}"
class="trow"
class:now={nowPlaying("trackId", t.id)}
class:flash={flashId === t.id}
class:locked
role="button"
tabindex="0"
title={locked ? "Not released yet" : "Play — streams from the best source"}
onclick={play}
onkeydown={(e) => e.key === "Enter" && play()}
>
<span class="tno">{nowPlaying("trackId", t.id) ? "♫" : (t.track_no ?? "·")}</span>
<span class="ttitle">{t.title}</span>
{#if locked}
<span class="tag-unreleased">Unreleased</span>
{:else}
<span class="tdur">{fmtDuration(t.duration_secs)}</span>
{/if}
<button class="mini-btn play" disabled={locked} title={locked ? "Not released yet" : "Play"} onclick={(e) => { e.stopPropagation(); play(); }}>
<Icon name="play" size={13} />
</button>
<button class="mini-get" disabled={locked} title={locked ? "Not released yet" : "Save to library"} onclick={(e) => { e.stopPropagation(); saveTrackAction(t); }}>
<Icon name={saved[`t${t.id}`] === "ok" ? "check" : saved[`t${t.id}`] === "err" ? "x" : "download"} size={13} />
</button>
</div>
{/each}
</div>
{/if}
</div>
<style>
.hint {
color: var(--text-secondary);
padding-top: 10vh;
text-align: center;
}
.back {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-secondary);
font-weight: 600;
margin-bottom: 16px;
}
.back:hover { color: var(--text-primary); }
.hero {
display: flex;
align-items: flex-end;
gap: 20px;
background: var(--bg-elevated);
border-radius: 10px;
padding: 20px;
}
.cover {
width: 150px;
height: 150px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
box-shadow: 0 12px 28px -12px rgba(0, 0, 0, 0.7);
}
.placeholder {
display: grid;
place-items: center;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
color: var(--text-muted);
}
.hero-info { min-width: 0; }
.kind {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--text-secondary);
}
h1 {
font-size: 30px;
font-weight: 800;
margin: 4px 0 6px;
}
.meta {
color: var(--text-secondary);
font-size: 12px;
}
.artist-link {
color: var(--text-primary);
font-weight: 600;
}
.artist-link:hover { text-decoration: underline; }
.owned { color: var(--accent-hover); }
.actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
.pill {
background: var(--accent);
color: #000;
font-size: 11px;
font-weight: 700;
padding: 7px 16px;
border-radius: 14px;
white-space: nowrap;
}
.pill:hover { background: var(--accent-hover); }
.pill.ghost {
background: var(--bg-highlight);
color: var(--text-primary);
}
.pill.ghost:hover { background: var(--bg-press, #333); }
.pill:disabled {
background: var(--bg-highlight);
color: var(--text-muted);
cursor: default;
opacity: 0.6;
}
.pill:disabled:hover { background: var(--bg-highlight); }
.tracklist {
margin-top: 18px;
display: flex;
flex-direction: column;
}
.disc-head {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
padding: 14px 8px 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
margin-bottom: 4px;
}
.disc-head:first-child { padding-top: 4px; }
.trow {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 8px;
border-radius: 5px;
cursor: pointer;
}
.trow:hover { background: var(--bg-highlight); }
.trow.now .ttitle,
.trow.now .tno { color: var(--accent-hover); }
.trow.locked { cursor: default; }
.trow.locked:hover { background: transparent; }
.trow.locked .ttitle { color: var(--text-muted); }
.tag-unreleased {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-muted);
border: 1px solid var(--border, #3a3a3a);
border-radius: 4px;
padding: 1px 6px;
flex: 0 0 auto;
}
.mini-btn:disabled,
.mini-get:disabled {
color: var(--text-muted);
opacity: 0.4;
cursor: default;
}
.mini-btn:disabled:hover,
.mini-get:disabled:hover { color: var(--text-muted); }
.trow.flash { animation: flash 1.8s ease; }
@keyframes flash {
0%, 30% { background: color-mix(in srgb, var(--accent) 22%, transparent); }
100% { background: transparent; }
}
@media (prefers-reduced-motion: reduce) {
.trow.flash { animation: none; }
}
.tno {
width: 22px;
text-align: right;
color: var(--text-muted);
font-size: 12px;
flex: 0 0 auto;
}
.ttitle {
flex: 1;
min-width: 0;
font-size: 13.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tdur {
color: var(--text-muted);
font-size: 11px;
flex: 0 0 auto;
}
.mini-btn.play { color: var(--accent-hover); }
.mini-btn,
.mini-get { color: var(--text-secondary); flex: 0 0 auto; }
.mini-btn:hover,
.mini-get:hover { color: var(--accent-hover); }
.resolving {
color: var(--text-secondary);
font-size: 12px;
margin-bottom: 12px;
}
.resolving .pulse { color: var(--accent); }
.resolving.error { color: #f15e6c; }
</style>
File diff suppressed because it is too large. Load diff
+485
View File
@@ -0,0 +1,485 @@
<script>
import {
catalog,
openCatalogArtist,
streamSong,
saveSong,
streamCatalogAlbum,
saveCatalogAlbum,
resolver,
player,
artBroken,
markArtBroken,
} from "../lib/state.svelte.js";
import Icon from "../components/Icon.svelte";
const playSong = (t) =>
streamSong(t.artist, t.title, {
cover: t.cover,
trackId: t.id,
albumId: t.album_id,
album: t.album || null,
expectSecs: t.duration_secs ?? null,
});
const nowPlaying = (kind, id) =>
player.status !== "idle" && player.catalogRef[kind] === id;
const top = $derived(catalog.results?.artists[0] ?? null);
const artists = $derived(catalog.results?.artists.slice(0, 6) ?? []);
const albums = $derived(catalog.results?.albums.slice(0, 6) ?? []);
const tracks = $derived(catalog.results?.tracks.slice(0, 5) ?? []);
let saved = $state({});
// A failed save flashes ✗ on the button (the resolver banner up top is
// easy to miss) before returning to the idle icon.
function flagSaveError(key) {
saved[key] = "err";
setTimeout(() => {
if (saved[key] === "err") saved[key] = "";
}, 3000);
}
async function saveTrack(t) {
if (saved[`t${t.id}`]) return;
saved[`t${t.id}`] = "…";
if (await saveSong(t.artist, t.title)) saved[`t${t.id}`] = "ok";
else flagSaveError(`t${t.id}`);
}
async function saveAlbum(al) {
if (saved[`a${al.id}`]) return;
saved[`a${al.id}`] = "…";
if (await saveCatalogAlbum(al)) saved[`a${al.id}`] = "ok";
else flagSaveError(`a${al.id}`);
}
function fmtDuration(s) {
if (s == null) return "";
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function albumMeta(album) {
const year = album.release_date?.slice(0, 4);
const type = album.record_type ? album.record_type[0].toUpperCase() + album.record_type.slice(1) : "Album";
return [year, type].filter(Boolean).join(" · ");
}
</script>
<div class="catalog">
{#if catalog.loading}
<p class="hint">Searching the catalog for “{catalog.query}”…</p>
{:else if catalog.error}
<p class="hint">Catalog search failed: {catalog.error}</p>
{:else if !catalog.results}
<p class="hint">Search for an artist, album or song above.</p>
{:else}
{#if resolver.active}
<p class="resolving"><span class="pulse">●</span> {resolver.label} — finding the best source…</p>
{:else if resolver.error}
<p class="resolving error">{resolver.error}</p>
{/if}
<div class="lead">
{#if top}
<section class="top-result">
<h2>Top result</h2>
<button class="top-card" onclick={() => openCatalogArtist(top.id, top)}>
{#if top.picture}
<img class="round" src={top.picture} alt="" draggable="false" />
{:else}
<div class="round placeholder"><Icon name="music" size={26} /></div>
{/if}
<div class="top-name">{top.name}</div>
<span class="badge">ARTIST</span>
</button>
</section>
{/if}
{#if tracks.length > 0}
<section class="songs">
<h2>Songs</h2>
<div class="song-list">
{#each tracks as t (t.id)}
<div
class="song"
class:now={nowPlaying("trackId", t.id)}
role="button"
tabindex="0"
title="Play — streams from the best source"
onclick={() => playSong(t)}
onkeydown={(e) => e.key === "Enter" && playSong(t)}
>
{#if t.cover && !artBroken[t.cover]}
<img class="mini-cover" src={t.cover} alt="" draggable="false" onerror={() => markArtBroken(t.cover)} />
{:else}
<div class="mini-cover placeholder"></div>
{/if}
<div class="song-info">
<div class="song-title">
{#if nowPlaying("trackId", t.id)}<span class="eq">♫</span>{/if}
{t.title}
</div>
<div class="song-sub">{t.artist} · {t.album}</div>
</div>
<span class="dur">{fmtDuration(t.duration_secs)}</span>
<button
class="mini-btn play"
title="Play"
onclick={(e) => { e.stopPropagation(); playSong(t); }}
><Icon name="play" size={13} /></button>
<button
class="mini-btn"
title="Save to library"
onclick={(e) => { e.stopPropagation(); saveTrack(t); }}
><Icon name={saved[`t${t.id}`] === "ok" ? "check" : saved[`t${t.id}`] === "err" ? "x" : "download"} size={13} /></button>
</div>
{/each}
</div>
</section>
{/if}
</div>
{#if artists.length > 0}
<h2>Artists</h2>
<div class="artist-row">
{#each artists as a (a.id)}
<button class="artist" onclick={() => openCatalogArtist(a.id, a)}>
{#if a.picture}
<img class="round sm" src={a.picture} alt="" draggable="false" />
{:else}
<div class="round sm placeholder"><Icon name="music" size={20} /></div>
{/if}
<div class="artist-name">{a.name}</div>
</button>
{/each}
</div>
{/if}
{#if albums.length > 0}
<h2>Albums</h2>
<div class="album-row">
{#each albums as al (al.id)}
<div class="album" class:now={nowPlaying("albumId", al.id)}>
<div class="cover-wrap">
{#if nowPlaying("albumId", al.id)}
<span class="now-badge">♫</span>
{/if}
{#if al.cover && !artBroken[al.cover]}
<img class="cover" src={al.cover} alt="" draggable="false" onerror={() => markArtBroken(al.cover)} />
{:else}
<div class="cover placeholder"><Icon name="disc" size={30} /></div>
{/if}
<div class="overlay">
<button
class="act"
title="Play — streams from the best source"
onclick={() => streamCatalogAlbum(al)}
><Icon name="play" size={15} /></button>
<button
class="act"
title="Save to library"
onclick={() => saveAlbum(al)}
><Icon name={saved[`a${al.id}`] === "ok" ? "check" : saved[`a${al.id}`] === "err" ? "x" : "download"} size={15} /></button>
</div>
</div>
<div class="album-title" title={al.title}>{al.title}</div>
<div class="album-sub">{albumMeta(al)}</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
<style>
.catalog {
display: flex;
flex-direction: column;
}
.hint {
color: var(--text-secondary);
padding-top: 10vh;
text-align: center;
}
h2 {
font-size: 15px;
font-weight: 700;
margin: 20px 0 10px;
}
.lead {
display: grid;
grid-template-columns: 240px 1fr;
gap: 16px;
align-items: start;
}
.lead h2 {
margin-top: 0;
}
.top-card {
display: block;
width: 100%;
text-align: left;
background: var(--bg-elevated);
border-radius: 8px;
padding: 16px;
transition: background 0.2s;
}
.top-card:hover {
background: var(--bg-highlight);
}
.round {
width: 84px;
height: 84px;
border-radius: 50%;
object-fit: cover;
display: block;
}
.round.sm {
width: 90px;
height: 90px;
margin: 0 auto;
}
.placeholder {
display: grid;
place-items: center;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
color: var(--text-muted);
}
.top-name {
font-size: 17px;
font-weight: 700;
margin-top: 12px;
}
.badge {
display: inline-block;
margin-top: 8px;
background: var(--bg-highlight);
color: var(--text-secondary);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
padding: 4px 10px;
border-radius: 10px;
}
.song-list {
background: var(--bg-elevated);
border-radius: 8px;
padding: 6px 8px;
}
.song {
display: flex;
align-items: center;
gap: 11px;
padding: 6px;
border-radius: 5px;
}
.song:hover {
background: var(--bg-highlight);
}
.mini-cover {
width: 34px;
height: 34px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
.song-info {
flex: 1;
min-width: 0;
}
.song-title {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.song-sub {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dur {
color: var(--text-muted);
font-size: 11px;
flex-shrink: 0;
}
.mini-btn {
color: var(--text-secondary);
flex-shrink: 0;
}
.mini-btn:hover {
color: var(--accent-hover);
}
.resolving {
color: var(--text-secondary);
font-size: 12px;
margin-bottom: 14px;
}
.resolving .pulse {
color: var(--accent);
}
.resolving.error {
color: #f15e6c;
}
.song {
cursor: pointer;
}
.song.now .song-title {
color: var(--accent-hover);
}
.eq {
color: var(--accent-hover);
margin-right: 3px;
}
.album.now .cover {
outline: 2px solid var(--accent);
}
.now-badge {
position: absolute;
right: 7px;
bottom: 7px;
z-index: 1;
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent);
color: #000;
font-size: 11px;
}
.mini-btn.play {
color: var(--accent-hover);
}
.act {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent);
color: #000;
margin: 0 5px;
}
.act:hover {
background: var(--accent-hover);
transform: scale(1.06);
}
.artist-row,
.album-row {
display: grid;
/* Fixed tracks — fluid minmax(...,1fr) sawtooths card widths while
the window or side panels resize (see Home.svelte). */
grid-template-columns: repeat(auto-fill, 124px);
gap: 16px;
}
.artist {
text-align: center;
padding: 10px 6px;
border-radius: 8px;
transition: background 0.2s;
}
.artist:hover {
background: var(--bg-highlight);
}
.artist-name {
font-size: 12px;
font-weight: 600;
margin-top: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cover-wrap {
position: relative;
}
.cover {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 6px;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
border-radius: 6px;
opacity: 0;
transition: opacity 0.15s;
}
.cover-wrap:hover .overlay {
opacity: 1;
}
.album-title {
font-size: 12px;
font-weight: 600;
margin-top: 7px;
line-height: 1.25;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 6;
}
.album-sub {
font-size: 11px;
color: var(--text-secondary);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.album {
display: flex;
flex-direction: column;
min-height: 245px;
}
</style>
+308
View File
@@ -0,0 +1,308 @@
<script>
import { downloads, network, playLocalFile } from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
const uploads = $derived(
Object.values(network.uploads)
.sort((a, b) => b.id - a.id)
.slice(0, 20),
);
const items = $derived(
Object.values(downloads.items).sort((a, b) => b.id - a.id),
);
const active = $derived(
items.filter((d) => !["complete", "failed", "cancelled"].includes(d.state)),
);
const finished = $derived(
items.filter((d) => ["complete", "failed", "cancelled"].includes(d.state)),
);
function pct(d) {
return d.total > 0 ? Math.min(100, (d.received / d.total) * 100) : 0;
}
function fmtSize(bytes) {
if (bytes >= 1e9) return (bytes / 1e9).toFixed(2) + " GB";
return (bytes / 1e6).toFixed(1) + " MB";
}
function stateLabel(d) {
switch (d.state) {
case "connecting": return "connecting…";
case "requested": return "requested…";
case "queued":
return d.queue_place != null ? `in queue (#${d.queue_place})` : "in queue";
case "transferring":
return `${fmtSize(d.received)} / ${fmtSize(d.total)} · ${(d.speed_bps / 1e6).toFixed(1)} MB/s`;
case "complete": return "done";
case "failed": return d.error || "failed";
case "cancelled": return "cancelled";
default: return d.state;
}
}
</script>
<div class="downloads">
<h1>Downloads</h1>
{#if network.shareFiles > 0}
<div class="sharing-banner">
<span class="dot"></span>
Sharing {network.shareFiles} files with the network
{#if network.respondedCount > 0}
· answered {network.respondedCount} searches
{/if}
{#if uploads.length > 0}
· {uploads.filter((u) => u.state === "transferring").length} uploading
{/if}
</div>
{/if}
{#if uploads.length > 0}
<h2>Uploads</h2>
<div class="list">
{#each uploads as u (u.id)}
<div class="row">
<div class="info">
<div class="name">{u.name}</div>
<div class="sub">
to {u.username} ·
<span class:ok={u.state === "complete"} class:err={u.state === "failed"}>
{u.state === "transferring" && u.total > 0
? `${((u.sent / u.total) * 100).toFixed(0)}%`
: u.state}
</span>
</div>
{#if u.state === "transferring"}
<div class="bar">
<div class="fill up" style="width: {u.total > 0 ? (u.sent / u.total) * 100 : 0}%"></div>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{#if items.length === 0}
{#if uploads.length === 0}
<div class="empty">
<p class="big">No downloads yet</p>
<p class="sub">Grab something from Search — files land in your library folder.</p>
</div>
{/if}
{:else}
{#if active.length > 0}
<h2>Active</h2>
<div class="list">
{#each active as d (d.id)}
<div class="row">
<div class="info">
<div class="name">{d.name}</div>
<div class="sub">{d.username} · {stateLabel(d)}</div>
<div class="bar">
<div
class="fill"
class:indeterminate={d.state !== "transferring"}
style="width: {d.state === 'transferring' ? pct(d) : 100}%"
></div>
</div>
</div>
<button class="cancel" title="Cancel" onclick={() => api.cancelDownload(d.id)}>
<Icon name="x" size={16} />
</button>
</div>
{/each}
</div>
{/if}
{#if finished.length > 0}
<h2>Finished</h2>
<div class="list">
{#each finished as d (d.id)}
<div class="row done-row">
<div class="info">
<div class="name" class:dim={d.state !== "complete"}>{d.name}</div>
<div class="sub">
{d.username} ·
<span class:ok={d.state === "complete"} class:err={d.state === "failed"}>
{stateLabel(d)}
</span>
</div>
</div>
{#if d.state === "complete" && d.local_path}
<button
class="reveal"
title="Play"
onclick={() =>
playLocalFile(
d.local_path,
d.name.replace(/\.[^.]+$/, ""),
d.username,
)}
><Icon name="play" size={15} /></button>
<button
class="reveal"
title="Show in folder"
onclick={() => api.showInFolder(d.local_path)}
><Icon name="folder-open" size={16} /></button>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
</div>
<style>
h1 {
font-size: 28px;
font-weight: 800;
margin-bottom: 24px;
}
h2 {
font-size: 14px;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 18px 0 10px;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-top: 15vh;
text-align: center;
}
.big {
font-size: 22px;
font-weight: 700;
}
.sub {
color: var(--text-secondary);
font-size: 12px;
}
.list {
display: flex;
flex-direction: column;
gap: 6px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius);
background: var(--bg-elevated);
}
.row.done-row {
background: transparent;
}
.row.done-row:hover {
background: var(--bg-highlight);
}
.info {
flex: 1;
min-width: 0;
}
.name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.name.dim {
color: var(--text-muted);
}
.bar {
margin-top: 8px;
height: 4px;
border-radius: 2px;
background: hsla(0, 0%, 100%, 0.15);
overflow: hidden;
}
.fill {
height: 100%;
border-radius: 2px;
background: var(--accent);
transition: width 0.25s linear;
}
.fill.indeterminate {
opacity: 0.25;
animation: pulse 1.4s ease-in-out infinite;
}
.fill.up {
background: var(--accent-hover);
}
.sharing-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 20px;
border-radius: var(--radius);
background: var(--accent-soft);
color: var(--text-secondary);
font-size: 13px;
}
.sharing-banner .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
flex-shrink: 0;
}
@keyframes pulse {
50% { opacity: 0.55; }
}
.ok {
color: var(--accent-hover);
}
.err {
color: #f15e6c;
}
.cancel {
color: var(--text-muted);
font-size: 14px;
padding: 6px;
}
.cancel:hover {
color: #f15e6c;
}
.reveal {
font-size: 15px;
opacity: 0.8;
}
.reveal:hover {
opacity: 1;
}
</style>
+91
View File
@@ -0,0 +1,91 @@
<script>
import { libraryStore, ui } from "../lib/state.svelte.js";
import AlbumCard from "../components/AlbumCard.svelte";
const recent = $derived(libraryStore.albums.slice(0, 8));
const greeting = (() => {
const h = new Date().getHours();
if (h < 12) return "Good morning";
if (h < 18) return "Good afternoon";
return "Good evening";
})();
</script>
<div class="home">
<h1>{greeting}</h1>
{#if recent.length === 0}
<div class="empty">
<p class="big">Welcome to SoulStream</p>
<p class="sub">
Search the network, stream instantly, and downloaded albums
will show up here.
</p>
<button class="cta" onclick={() => (ui.view = "search")}>Start searching</button>
</div>
{:else}
<section>
<h2>Recently added</h2>
<div class="grid">
{#each recent as a (a.id)}
<AlbumCard album={a} />
{/each}
</div>
</section>
{/if}
</div>
<style>
h1 {
font-size: 28px;
font-weight: 800;
margin-bottom: 24px;
}
h2 {
font-size: 20px;
font-weight: 700;
margin-bottom: 16px;
}
.grid {
display: grid;
/* Fixed tracks, not minmax(...,1fr): fluid tracks make every card
resize in a sawtooth as the column count snaps during window/panel
resizing — visible jitter. Fixed tracks only add/remove a column. */
grid-template-columns: repeat(auto-fill, 180px);
gap: 18px;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding-top: 15vh;
text-align: center;
}
.big {
font-size: 22px;
font-weight: 700;
}
.sub {
color: var(--text-secondary);
}
.cta {
margin-top: 10px;
padding: 10px 24px;
border-radius: 24px;
background: var(--accent);
color: #000;
font-weight: 700;
}
.cta:hover {
background: var(--accent-hover);
}
</style>
+800
View File
@@ -0,0 +1,800 @@
<script>
import { tick } from "svelte";
import { slide } from "svelte/transition";
import {
libraryStore,
ui,
getArt,
evictArt,
playAlbum,
player,
playlistStore,
refreshPlaylists,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import AlbumCard from "../components/AlbumCard.svelte";
import Icon from "../components/Icon.svelte";
let showPlaylistMenu = $state(false);
let expandedArtist = $state(null);
let inlineAlbumId = $state(null);
let inlineTracks = $state([]);
let artistArtUrls = $state({});
const artistArtOrder = [];
const artistArtInFlight = new Map();
const visibleArtistArts = new Set();
const artistArtPaths = new Map();
const ARTIST_ART_URL_MAX = 64;
const ARTIST_ART_CHAR_MAX = 24 * 1024 * 1024;
let artistArtChars = 0;
const artists = $derived.by(() => {
const grouped = new Map();
for (const album of libraryStore.albums) {
const key = album.artist || "Unknown Artist";
const entry = grouped.get(key) ?? { name: key, albums: [], artist_art_path: null };
entry.albums.push(album);
if (!entry.artist_art_path) entry.artist_art_path = album.artist_art_path || album.art_path;
grouped.set(key, entry);
}
return [...grouped.values()].sort((a, b) => a.name.localeCompare(b.name));
});
function touchArtistArt(name) {
const index = artistArtOrder.indexOf(name);
if (index >= 0) artistArtOrder.splice(index, 1);
artistArtOrder.push(name);
}
function forgetArtistArt(name) {
const url = artistArtUrls[name];
if (typeof url === "string") artistArtChars -= url.length;
delete artistArtUrls[name];
artistArtPaths.delete(name);
const index = artistArtOrder.indexOf(name);
if (index >= 0) artistArtOrder.splice(index, 1);
}
function rememberArtistArt(name, path, url) {
const previous = artistArtUrls[name];
if (typeof previous === "string") artistArtChars -= previous.length;
artistArtUrls[name] = url;
artistArtPaths.set(name, path);
artistArtChars += url.length;
touchArtistArt(name);
// Keep at least the just-painted image even if one unusual payload is
// larger than the budget; all older card references can be reloaded
// lazily from the separately bounded shared art cache.
while (
artistArtOrder.length > 1
&& (
artistArtOrder.length > ARTIST_ART_URL_MAX
|| artistArtChars > ARTIST_ART_CHAR_MAX
)
) {
const evictIndex = artistArtOrder.findIndex(
(artistName) => !visibleArtistArts.has(artistName),
);
// Preserve every currently painted card; the byte budget is a
// soft ceiling only when an unusually large visible row exceeds
// it by itself.
if (evictIndex < 0) break;
const [oldest] = artistArtOrder.splice(evictIndex, 1);
const oldUrl = artistArtUrls[oldest];
if (typeof oldUrl === "string") artistArtChars -= oldUrl.length;
delete artistArtUrls[oldest];
artistArtPaths.delete(oldest);
}
}
// Load only cards near the scroll viewport. The old whole-library effect
// eagerly retained every base64 image and, because it read/write the same
// reactive object, reattached callbacks to every still-pending promise on
// each completion.
function loadArtistArt(node, artist) {
let current = artist;
let alive = true;
let intersecting = false;
const load = () => {
const { name, artist_art_path: path } = current;
if (!path) return;
if (
artistArtUrls[name] !== undefined
&& artistArtPaths.get(name) === path
) {
touchArtistArt(name);
return;
}
if (artistArtUrls[name] !== undefined) forgetArtistArt(name);
let flight = artistArtInFlight.get(name);
if (!flight || flight.path !== path) {
const pending = getArt(path);
flight = { path, pending };
artistArtInFlight.set(name, flight);
pending.finally(() => {
if (artistArtInFlight.get(name) === flight) {
artistArtInFlight.delete(name);
}
});
}
flight.pending.then((url) => {
if (
alive
&& current.name === name
&& current.artist_art_path === path
&& url
) {
rememberArtistArt(name, path, url);
}
});
};
const observer = typeof IntersectionObserver === "undefined"
? null
: new IntersectionObserver(
(entries) => {
intersecting = entries.some((entry) => entry.isIntersecting);
if (intersecting) {
visibleArtistArts.add(current.name);
load();
} else {
visibleArtistArts.delete(current.name);
}
},
{ root: node.closest(".content"), rootMargin: "300px" },
);
if (observer) observer.observe(node);
else load();
return {
update(next) {
const previousName = current.name;
const pathChanged = current.artist_art_path !== next.artist_art_path;
current = next;
if (intersecting && previousName !== next.name) {
visibleArtistArts.delete(previousName);
visibleArtistArts.add(next.name);
}
if (pathChanged) {
forgetArtistArt(next.name);
if (intersecting) load();
} else if (artistArtUrls[next.name] !== undefined) {
touchArtistArt(next.name);
}
},
destroy() {
alive = false;
visibleArtistArts.delete(current.name);
observer?.disconnect();
},
};
}
function toggleArtist(name) {
expandedArtist = expandedArtist === name ? null : name;
inlineAlbumId = null;
}
function selectInlineAlbum(album) {
inlineAlbumId = inlineAlbumId === album.id ? null : album.id;
if (inlineAlbumId !== null) revealInlineTracklist();
}
const inlineAlbum = $derived(
libraryStore.albums.find((a) => a.id === inlineAlbumId) ?? null,
);
$effect(() => {
if (!inlineAlbum) {
inlineTracks = [];
return;
}
const id = inlineAlbum.id;
// Keep the previous album's rows rendered while the new ones load:
// blanking them collapses the open panel, which shrinks .content's
// scrollHeight and makes the browser clamp scrollTop upward — the
// "jump to the top" when switching albums while scrolled deep.
api.albumTracks(id).then((tracks) => {
if (inlineAlbumId !== id) return; // album changed while loading
inlineTracks = tracks;
revealInlineTracklist();
});
});
let revealRaf = null;
// Scroll .content down just enough to keep the inline tracklist in view
// while it grows (slide transition, track rows arriving). One-shot
// measurement doesn't work here: at tick() the panel is still ~0px tall,
// so we follow it frame by frame until its size settles. Never scrolls
// up, and never pushes the tracklist heading above the top of the pane.
async function revealInlineTracklist() {
await tick();
if (revealRaf) cancelAnimationFrame(revealRaf);
const scroller = document.querySelector(".content");
const target = document.querySelector(".inline-tracklist");
if (!scroller || !target) return;
const margin = 16;
const start = performance.now();
let lastBottom = -1;
let stableSince = start;
const step = (now) => {
revealRaf = null;
if (!target.isConnected) return;
const viewport = scroller.getBoundingClientRect();
const rect = target.getBoundingClientRect();
const delta = Math.min(
rect.bottom - viewport.bottom + margin,
rect.top - viewport.top - margin,
);
if (delta > 0) scroller.scrollBy({ top: delta });
if (rect.bottom !== lastBottom) {
lastBottom = rect.bottom;
stableSince = now;
}
// Keep following briefly after the last size change, bounded hard.
if (now - stableSince < 150 && now - start < 900) {
revealRaf = requestAnimationFrame(step);
}
};
revealRaf = requestAnimationFrame(step);
}
async function addAlbumToPlaylist(playlistId) {
showPlaylistMenu = false;
const payload = tracks.map((t) => ({
title: t.title,
artist: t.artist,
duration_secs: t.duration_secs ?? null,
path: t.path,
art_path: album?.art_path ?? null,
}));
await api.addToPlaylist(playlistId, payload);
await refreshPlaylists();
}
async function newPlaylistWithAlbum() {
const id = await api.createPlaylist(album.title);
await addAlbumToPlaylist(id);
}
let tracks = $state([]);
let art = $state(null);
let refreshingArt = $state(false);
let artError = $state("");
async function refreshArt() {
if (!album || refreshingArt) return;
refreshingArt = true;
artError = "";
try {
const path = await api.refreshAlbumArt(album.id);
evictArt(path);
forgetArtistArt(album.artist);
art = await getArt(path);
} catch (e) {
artError = String(e);
} finally {
refreshingArt = false;
}
}
const album = $derived(libraryStore.albums.find((a) => a.id === ui.albumId) ?? null);
$effect(() => {
if (!album) return;
tracks = [];
art = null;
api.albumTracks(album.id).then((t) => (tracks = t));
getArt(album.art_path).then((url) => (art = url));
});
function fmtDuration(s) {
if (s == null) return "";
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
const nowPlayingPath = $derived(
player.queueIndex >= 0 ? player.queue[player.queueIndex]?.path : null,
);
</script>
{#if !album}
<div class="library">
<h1>Artists</h1>
{#if libraryStore.albums.length === 0}
<div class="empty">
<p class="big">Your music will live here</p>
<p class="sub">Download an album from Search and it lands here, tagged with art.</p>
</div>
{:else}
<p class="artist-count">{artists.length} artists</p>
<div class="artist-grid">
{#each artists as artist (artist.name)}
<button
class="artist-card"
use:loadArtistArt={artist}
class:selected={expandedArtist === artist.name}
aria-expanded={expandedArtist === artist.name}
onclick={() => toggleArtist(artist.name)}
>
{#if artistArtUrls[artist.name]}
<img class="artist-art" src={artistArtUrls[artist.name]} alt="" draggable="false" />
{:else}
<div class="artist-art placeholder"><Icon name="music" size={30} /></div>
{/if}
<span class="artist-name">{artist.name}</span>
<span class="artist-meta">{artist.albums.length} {artist.albums.length === 1 ? "album" : "albums"}</span>
</button>
{/each}
</div>
{@const selected = artists.find((a) => a.name === expandedArtist)}
<section class="artist-albums" class:open={!!selected} aria-hidden={!selected}>
{#if selected}
<div class="artist-albums-head">
<div><h2>{selected.name}</h2><p>{selected.albums.length} albums</p></div>
<button class="close-expand" onclick={() => (expandedArtist = null)}>×</button>
</div>
<div class="album-strip">
{#each selected.albums as a (a.id)}
<AlbumCard album={a} onselect={selectInlineAlbum} />
{/each}
</div>
{#if inlineAlbum && selected.albums.some((a) => a.id === inlineAlbum.id)}
<div class="inline-tracklist" transition:slide={{ duration: 180 }}>
<div class="inline-track-head">
<div>
<h3>{inlineAlbum.title}</h3>
<p>{inlineAlbum.track_count} tracks{inlineAlbum.year ? ` · ${inlineAlbum.year}` : ""}</p>
</div>
<button
class="inline-play"
onclick={() => playAlbum(inlineTracks, `${inlineAlbum.artist} — ${inlineAlbum.title}`, 0, inlineAlbum.art_path)}
disabled={inlineTracks.length === 0}
>▶ Play</button>
</div>
<div class="inline-tracks">
{#each inlineTracks as t, i (t.id)}
<button
class="inline-track"
onclick={() => playAlbum(inlineTracks, `${inlineAlbum.artist} — ${inlineAlbum.title}`, i, inlineAlbum.art_path)}
>
<span class="inline-tno">{t.track_no ?? "·"}</span>
<span class="inline-ttitle">{t.title}</span>
<span class="inline-tmeta">{fmtDuration(t.duration_secs)}</span>
</button>
{/each}
</div>
</div>
{/if}
{/if}
</section>
{/if}
</div>
{:else}
<div class="album-page">
<button class="back" onclick={() => (ui.albumId = null)}>
<Icon name="chevron-left" size={16} /> Library
</button>
<header>
{#if art}
<img class="cover" src={art} alt="" draggable="false" />
{:else}
<div class="cover placeholder"><Icon name="disc" size={56} /></div>
{/if}
<div class="head-meta">
<p class="kind">Album</p>
<h1>{album.title}</h1>
<p class="line">
<strong>{album.artist}</strong>
{album.year ? ` · ${album.year}` : ""} · {album.track_count} tracks
</p>
<div class="actions">
<button
class="play-all"
onclick={() =>
playAlbum(tracks, `${album.artist} — ${album.title}`, 0, album.art_path)}
disabled={tracks.length === 0}
>▶ Play</button>
<div class="add-menu">
<button
class="refresh-art"
title="Add album to a playlist"
onclick={() => (showPlaylistMenu = !showPlaylistMenu)}
disabled={tracks.length === 0}
><Icon name="list-plus" size={15} /> Playlist</button>
{#if showPlaylistMenu}
<div class="menu">
<button class="menu-item new" onclick={newPlaylistWithAlbum}>
+ New playlist
</button>
{#each playlistStore.playlists as pl (pl.id)}
<button
class="menu-item"
onclick={() => addAlbumToPlaylist(pl.id)}
>{pl.name}</button>
{/each}
</div>
{/if}
</div>
<button
class="refresh-art"
title="Wrong cover? Refetch the album art"
onclick={refreshArt}
disabled={refreshingArt}
>
<Icon name="refresh" size={14} />
{refreshingArt ? "fetching…" : "art"}
</button>
</div>
{#if artError}
<p class="art-error">{artError}</p>
{/if}
</div>
</header>
<div class="tracklist">
{#each tracks as t, i (t.id)}
<button
class="trow"
class:playing={nowPlayingPath === t.path}
onclick={() =>
playAlbum(tracks, `${album.artist} — ${album.title}`, i, album.art_path)}
>
<span class="tno">{t.track_no ?? "·"}</span>
<span class="ttitle">{t.title}</span>
<span class="tmeta">
{t.bitrate_kbps ? `${t.bitrate_kbps} kbps · ` : ""}{fmtDuration(t.duration_secs)}
</span>
</button>
{/each}
</div>
</div>
{/if}
<style>
h1 {
font-size: 28px;
font-weight: 800;
margin-bottom: 24px;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-top: 15vh;
text-align: center;
}
.big {
font-size: 22px;
font-weight: 700;
}
.sub {
color: var(--text-secondary);
}
.artist-count {
color: var(--text-secondary);
font-size: 13px;
margin-top: -16px;
margin-bottom: 18px;
}
.artist-grid {
display: grid;
/* Fixed tracks — fluid minmax(...,1fr) sawtooths card widths while
the window or side panels resize (see Home.svelte). */
grid-template-columns: repeat(auto-fill, 156px);
gap: 22px 18px;
}
.artist-card {
display: block;
width: 100%;
padding: 8px;
text-align: left;
color: inherit;
border-radius: 8px;
overflow: hidden;
transition: background 0.18s ease, transform 0.18s ease;
}
.artist-card:hover,
.artist-card.selected {
background: var(--bg-highlight);
transform: translateY(-2px);
}
.artist-art {
display: block;
width: 100%;
aspect-ratio: 1.28;
object-fit: cover;
border-radius: 8px;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
}
.artist-art.placeholder {
display: grid;
place-items: center;
color: var(--text-muted);
}
.artist-name,
.artist-meta {
display: block;
padding: 0 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.artist-name { margin-top: 9px; font-weight: 600; }
.artist-meta { margin-top: 4px; color: var(--text-secondary); font-size: 12px; padding-bottom: 10px; }
.artist-albums {
max-height: 0;
margin-top: 0;
padding: 0 18px;
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
overflow: hidden;
transition: max-height 260ms ease, margin-top 260ms ease, padding 260ms ease, opacity 180ms ease, transform 260ms ease;
background: var(--bg-elevated);
border-radius: var(--radius);
border-top: 0 solid transparent;
overflow-anchor: none;
}
.artist-albums.open {
max-height: 900px;
margin-top: 28px;
padding: 18px;
opacity: 1;
transform: translateY(0);
pointer-events: auto;
border-top: 2px solid var(--accent);
}
.artist-albums-head {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 16px;
}
.artist-albums-head h2 { font-size: 20px; margin: 0 0 4px; }
.artist-albums-head p { color: var(--text-secondary); font-size: 12px; }
.close-expand { color: var(--text-secondary); font-size: 24px; line-height: 1; padding: 0 4px; }
.close-expand:hover { color: var(--text-primary); }
.album-strip { display: grid; grid-template-columns: repeat(auto-fill, 146px); gap: 14px; }
.album-strip :global(.card) { padding: 10px; }
.inline-tracklist {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.inline-track-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.inline-track-head h3 { font-size: 16px; margin: 0 0 3px; }
.inline-track-head p { color: var(--text-secondary); font-size: 12px; }
.inline-play { padding: 7px 15px; border-radius: 18px; background: var(--accent); color: #000; font-size: 12px; font-weight: 700; }
.inline-play:disabled { opacity: 0.45; }
.inline-tracks { display: flex; flex-direction: column; }
.inline-track { display: flex; align-items: center; gap: 12px; width: 100%; padding: 7px 8px; border-radius: 5px; text-align: left; }
.inline-track:hover { background: var(--bg-highlight); }
.inline-tno { width: 22px; text-align: right; color: var(--text-muted); font-size: 12px; }
.inline-ttitle { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inline-tmeta { color: var(--text-muted); font-size: 12px; }
/* album page */
.back {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-secondary);
font-weight: 600;
margin-bottom: 18px;
}
.back:hover {
color: var(--text-primary);
}
header {
display: flex;
gap: 24px;
align-items: flex-end;
margin-bottom: 28px;
}
.cover {
width: 200px;
height: 200px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
}
.cover.placeholder {
display: grid;
place-items: center;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
color: var(--text-muted);
font-size: 56px;
}
.kind {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-secondary);
letter-spacing: 0.08em;
}
.head-meta h1 {
margin: 6px 0 10px;
font-size: 40px;
}
.line {
color: var(--text-secondary);
font-size: 13px;
}
.actions {
display: flex;
gap: 10px;
align-items: center;
margin-top: 16px;
}
.play-all {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 10px 26px;
border-radius: 24px;
background: var(--accent);
color: #000;
font-weight: 700;
}
.refresh-art {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 16px;
border-radius: 24px;
background: var(--bg-highlight);
color: var(--text-secondary);
font-weight: 600;
font-size: 12px;
}
.refresh-art:hover:not(:disabled) {
color: var(--text-primary);
}
.refresh-art:disabled {
opacity: 0.4;
}
.add-menu {
position: relative;
}
.menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 20;
min-width: 200px;
max-height: 320px;
overflow-y: auto;
background: var(--bg-press);
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
}
.menu-item {
display: block;
width: 100%;
text-align: left;
padding: 9px 12px;
border-radius: 5px;
color: var(--text-primary);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-item:hover {
background: var(--bg-highlight);
}
.menu-item.new {
color: var(--accent-hover);
font-weight: 600;
border-bottom: 1px solid var(--border);
border-radius: 5px 5px 0 0;
margin-bottom: 4px;
}
.art-error {
margin-top: 8px;
font-size: 12px;
color: #f15e6c;
}
.play-all:hover:not(:disabled) {
background: var(--accent-hover);
transform: scale(1.02);
}
.play-all:disabled {
opacity: 0.5;
}
.tracklist {
display: flex;
flex-direction: column;
}
.trow {
display: flex;
align-items: center;
gap: 14px;
padding: 9px 12px;
border-radius: 6px;
text-align: left;
}
.trow:hover {
background: var(--bg-highlight);
}
.trow.playing .ttitle {
color: var(--accent-hover);
}
.tno {
width: 22px;
text-align: right;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.ttitle {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.tmeta {
color: var(--text-muted);
font-size: 12px;
flex-shrink: 0;
}
</style>
+234
View File
@@ -0,0 +1,234 @@
<script>
import { ui, playlistStore, refreshPlaylists, playAlbum, player } from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
let tracks = $state([]);
let renaming = $state(false);
let nameInput = $state("");
const playlist = $derived(
playlistStore.playlists.find((p) => p.id === ui.playlistId) ?? null,
);
$effect(() => {
if (ui.playlistId == null) return;
api.playlistTracks(ui.playlistId).then((t) => (tracks = t));
});
function fmtDuration(s) {
if (s == null) return "";
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function play(start = 0) {
playAlbum(
tracks.map((t) => ({ ...t, duration_secs: t.duration_secs })),
playlist?.name ?? "Playlist",
start,
);
}
async function removeTrack(position) {
await api.removeFromPlaylist(ui.playlistId, position);
tracks = await api.playlistTracks(ui.playlistId);
await refreshPlaylists();
}
async function del() {
await api.deletePlaylist(ui.playlistId);
await refreshPlaylists();
ui.view = "library";
ui.playlistId = null;
}
const nowPlayingPath = $derived(
player.queueIndex >= 0 ? player.queue[player.queueIndex]?.path : null,
);
</script>
{#if playlist}
<div class="playlist-page">
<header>
<div class="cover"><Icon name="music" size={64} /></div>
<div class="head-meta">
<p class="kind">Playlist</p>
<h1>{playlist.name}</h1>
<p class="line">{tracks.length} tracks</p>
<div class="actions">
<button class="play-all" onclick={() => play()} disabled={tracks.length === 0}>
<Icon name="play" size={15} /> Play
</button>
<button class="del" onclick={del}>Delete</button>
</div>
</div>
</header>
{#if tracks.length === 0}
<p class="empty-note">
This playlist is empty. Open an album in your Library and use “Add to playlist”.
</p>
{:else}
<div class="tracklist">
{#each tracks as t (t.position)}
<div class="trow" class:playing={nowPlayingPath === t.path}>
<button class="ttitle-wrap" onclick={() => play(tracks.indexOf(t))}>
<span class="ttitle">{t.title}</span>
<span class="tartist">{t.artist}</span>
</button>
<span class="tdur">{fmtDuration(t.duration_secs)}</span>
<button class="trm" title="Remove" onclick={() => removeTrack(t.position)}>
<Icon name="x" size={15} />
</button>
</div>
{/each}
</div>
{/if}
</div>
{:else}
<p class="empty-note">Playlist not found.</p>
{/if}
<style>
header {
display: flex;
gap: 24px;
align-items: flex-end;
margin-bottom: 28px;
}
.cover {
width: 200px;
height: 200px;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
display: grid;
place-items: center;
font-size: 64px;
color: rgba(0, 0, 0, 0.6);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
}
.kind {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-secondary);
letter-spacing: 0.08em;
}
h1 {
margin: 6px 0 10px;
font-size: 40px;
font-weight: 800;
}
.line {
color: var(--text-secondary);
font-size: 13px;
}
.actions {
display: flex;
gap: 10px;
align-items: center;
margin-top: 16px;
}
.play-all {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 10px 26px;
border-radius: 24px;
background: var(--accent);
color: #000;
font-weight: 700;
}
.play-all:hover:not(:disabled) {
background: var(--accent-hover);
transform: scale(1.02);
}
.play-all:disabled {
opacity: 0.5;
}
.del {
padding: 9px 16px;
border-radius: 24px;
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
font-size: 13px;
}
.del:hover {
color: #f15e6c;
border-color: #f15e6c;
}
.empty-note {
color: var(--text-secondary);
padding-top: 10vh;
text-align: center;
}
.tracklist {
display: flex;
flex-direction: column;
}
.trow {
display: flex;
align-items: center;
gap: 14px;
padding: 8px 12px;
border-radius: 6px;
}
.trow:hover {
background: var(--bg-highlight);
}
.trow.playing .ttitle {
color: var(--accent-hover);
}
.ttitle-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
text-align: left;
}
.ttitle {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tartist {
font-size: 12px;
color: var(--text-secondary);
}
.tdur {
color: var(--text-muted);
font-size: 12px;
flex-shrink: 0;
}
.trm {
color: var(--text-muted);
flex-shrink: 0;
}
.trm:hover {
color: #f15e6c;
}
</style>
+385
View File
@@ -0,0 +1,385 @@
<script>
import {
app,
search,
runSearch,
downloadFile,
downloadFolder,
streamFile,
filesForGroup,
} from "../lib/state.svelte.js";
import Icon from "../components/Icon.svelte";
let queuedKeys = $state({});
// Seeded from the store so "get on soulseek" hand-offs show their query.
let query = $state(search.query);
let expanded = $state({});
const groups = $derived(
Object.values(search.groups).sort(
(a, b) => (b.hasFreeSlot - a.hasFreeSlot) || (b.speed - a.speed),
),
);
async function submit(e) {
e.preventDefault();
const q = query.trim();
if (!q) return;
if (app.connection !== "online") {
app.showLogin = true;
return;
}
expanded = {};
try {
await runSearch(q);
} catch (err) {
app.loginError = String(err);
app.showLogin = true;
}
}
function fmtSize(bytes) {
if (bytes >= 1e9) return (bytes / 1e9).toFixed(2) + " GB";
return (bytes / 1e6).toFixed(1) + " MB";
}
function fmtDuration(s) {
if (s == null) return "";
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function fmtSpeed(bps) {
if (!bps) return "";
return (bps / 1e6).toFixed(1) + " MB/s";
}
// "FLAC", "MP3 320"… from the group's incrementally-kept summary fields.
function quality(g) {
if (g.topExt === "flac") return "FLAC";
const label = g.topExt.toUpperCase();
return g.maxBitrate ? `${label} ${g.maxBitrate}` : label;
}
</script>
<div class="search">
<form onsubmit={submit}>
<input
type="text"
placeholder="What do you want to listen to?"
bind:value={query}
spellcheck="false"
/>
</form>
{#if search.token == null}
<div class="empty">
<p class="big">Search the Soulseek network</p>
<p class="sub">
{app.connection === "online"
? `Connected as ${app.username} — search for an artist or album.`
: "You'll be asked to log in on your first search."}
</p>
</div>
{:else}
<div class="meta">
<span>
{groups.length} folders · {search.responses} users
{#if search.searching}<span class="pulse">· searching…</span>{/if}
</span>
</div>
<div class="results">
{#each groups.slice(0, 120) as g (g.key)}
<div class="group">
<button
class="group-head"
onclick={() => (expanded[g.key] = !expanded[g.key])}
>
<div class="art-mini"><Icon name="music" size={20} /></div>
<div class="ginfo">
<div class="gtitle" title={g.folder}>{g.folderName}</div>
<div class="gsub">
{g.username} · {g.audioCount} tracks · {fmtSize(g.totalSize)}
</div>
</div>
<div class="badges">
{#if quality(g)}
<span class="badge quality">{quality(g)}</span>
{/if}
{#if g.hasFreeSlot}
<span class="badge slot">open slot</span>
{:else}
<span class="badge queue">queue {g.queue}</span>
{/if}
{#if g.speed}
<span class="badge">{fmtSpeed(g.speed)}</span>
{/if}
</div>
<span
class="dl-all"
class:queued={queuedKeys[g.key]}
title="Download folder"
role="button"
tabindex="0"
onclick={(e) => {
e.stopPropagation();
if (queuedKeys[g.key]) return;
queuedKeys[g.key] = true;
downloadFolder(g);
}}
onkeydown={(e) => e.key === "Enter" && e.currentTarget.click()}
>
{#if queuedKeys[g.key]}
<Icon name="check" size={13} /> queued
{:else}
<Icon name="download" size={13} /> all
{/if}
</span>
</button>
{#if expanded[g.key]}
<div class="files">
<!-- g.fileCount in the key keeps this list refreshing
as new (non-reactive) files arrive for the group -->
{#each filesForGroup(g.key, g.fileCount) as f (f.path)}
<div class="file">
<span class="fname" title={f.name}>{f.name}</span>
<span class="fmeta">
{f.bitrate ? `${f.bitrate} kbps` : ""}
{f.duration != null ? " · " + fmtDuration(f.duration) : ""}
· {fmtSize(f.size)}
</span>
<button
class="mini live"
title="Play (streams while downloading)"
onclick={() => streamFile(g.username, f, g.folderName)}
><Icon name="play" size={13} /></button>
<button
class="mini live"
title="Download"
onclick={() => {
queuedKeys[f.path] = true;
downloadFile(g.username, f);
}}
>
<Icon name={queuedKeys[f.path] ? "check" : "download"} size={14} />
</button>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<style>
form {
margin-bottom: 20px;
}
input {
width: min(100%, 420px);
padding: 12px 18px;
border: none;
border-radius: 24px;
background: var(--bg-highlight);
color: var(--text-primary);
outline: none;
}
input::placeholder {
color: var(--text-muted);
}
input:focus {
box-shadow: 0 0 0 2px var(--text-primary);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-top: 15vh;
text-align: center;
}
.big {
font-size: 22px;
font-weight: 700;
}
.sub {
color: var(--text-secondary);
}
.meta {
color: var(--text-secondary);
font-size: 12px;
margin-bottom: 12px;
}
.pulse {
color: var(--accent);
}
.results {
display: flex;
flex-direction: column;
gap: 4px;
}
.group {
border-radius: var(--radius);
background: transparent;
}
.group-head {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 8px 10px;
border-radius: var(--radius);
text-align: left;
}
.group-head:hover {
background: var(--bg-highlight);
}
.art-mini {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 6px;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
color: var(--text-secondary);
font-size: 18px;
flex-shrink: 0;
}
.ginfo {
flex: 1;
min-width: 0;
}
.gtitle {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gsub {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badges {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.badge {
padding: 3px 8px;
border-radius: 10px;
background: var(--bg-highlight);
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
}
.badge.quality {
background: var(--accent-soft);
color: var(--accent-hover);
}
.badge.slot {
color: var(--accent-hover);
}
.badge.queue {
color: #e8a33d;
}
.files {
padding: 2px 10px 8px 66px;
display: flex;
flex-direction: column;
}
.file {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 8px;
border-radius: 4px;
font-size: 13px;
}
.file:hover {
background: var(--bg-highlight);
}
.fname {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fmeta {
color: var(--text-muted);
font-size: 11px;
flex-shrink: 0;
}
.mini {
color: var(--text-secondary);
opacity: 0.5;
flex-shrink: 0;
}
.mini.live {
opacity: 1;
}
.mini.live:hover {
color: var(--accent-hover);
}
.dl-all {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 12px;
background: var(--accent);
color: #000;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.dl-all:hover {
background: var(--accent-hover);
}
.dl-all.queued {
background: var(--bg-highlight);
color: var(--accent-hover);
cursor: default;
}
</style>
+718
View File
@@ -0,0 +1,718 @@
<script>
import { app, clearCatalogHeroArtCache, refreshSourcePrefs } from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
let settings = $state(null);
let saving = $state(false);
let savedFlash = $state(false);
let saveError = $state("");
let refreshingArtists = $state(false);
let artistRefreshResult = $state("");
let refreshingAlbums = $state(false);
let albumRefreshResult = $state("");
async function load() {
try {
settings = await api.getSettings();
} catch {
settings = null;
}
}
load();
async function commit(patch) {
saving = true;
saveError = "";
try {
await api.setSettings(patch);
await load();
savedFlash = true;
setTimeout(() => (savedFlash = false), 1500);
return true;
} catch (e) {
saveError = `Could not save: ${e}`;
return false;
} finally {
saving = false;
}
}
async function chooseDownloadDir() {
const dir = await api.pickFolder();
if (dir) await commit({ downloadDir: dir });
}
async function addSharedFolder() {
const dir = await api.pickFolder();
if (dir && !settings.shared_folders.includes(dir)) {
await commit({ sharedFolders: [...settings.shared_folders, dir] });
}
}
async function setLibraryLayout(value) {
if (value !== settings.library_layout) await commit({ libraryLayout: value });
}
async function removeSharedFolder(folder) {
await commit({
sharedFolders: settings.shared_folders.filter((f) => f !== folder),
});
}
async function setSlots(n) {
await commit({ uploadSlots: Math.max(1, Math.min(10, n)) });
}
async function setAudioOutput(device) {
if (device !== (settings.audio_output_device ?? "")) {
await commit({ audioOutputDevice: device });
}
}
const SOURCE_PREF_DEFAULTS = { max: "any", min: "any", priority: "quality" };
function sourcePrefsFor(mode) {
const stored = mode === "stream"
? settings.stream_source_prefs
: settings.download_source_prefs;
return { ...SOURCE_PREF_DEFAULTS, ...(stored ?? {}) };
}
async function setSourcePref(mode, field, value) {
const next = { ...sourcePrefsFor(mode), [field]: value };
const patch = mode === "stream"
? { streamSourcePrefs: next }
: { downloadSourcePrefs: next };
if (await commit(patch)) await refreshSourcePrefs();
}
async function setArtistHeroUpscaler(upscaler) {
if (upscaler === settings.artist_hero_upscaler) return;
if (await commit({ artistHeroUpscaler: upscaler })) {
clearCatalogHeroArtCache();
}
}
async function forget() {
await api.forgetCredentials();
app.username = "";
await load();
}
async function setStreamRetention(value) {
if (value === "forever") {
if (!settings.stream_keep_forever) {
await commit({ streamKeepForever: true });
}
} else {
const days = Number(value);
if (settings.stream_keep_forever || days !== settings.stream_retention_days) {
await commit({ streamKeepForever: false, streamRetentionDays: days });
}
}
}
async function setStreamCacheMax(value) {
const mb = Number(value);
if (mb !== settings.stream_cache_max_mb) {
await commit({ streamCacheMaxMb: mb });
}
}
async function setReplaceEmbeddedArt(value) {
const next = value === "replace";
if (next !== settings.replace_embedded_art) {
await commit({ replaceEmbeddedArt: next });
}
}
async function setEmbedArtMaxEdge(value) {
const next = Number(value);
if (next !== settings.embed_art_max_edge) {
await commit({ embedArtMaxEdge: next });
}
}
async function setWriteFolderCover(value) {
const next = value === "write";
if (next !== settings.write_folder_cover) {
await commit({ writeFolderCover: next });
}
}
// Sequential (one album ≈ one second: MusicBrainz is throttled), so a big
// library takes a while — the button stays busy until the sweep finishes.
async function refreshAllAlbumArt() {
if (refreshingAlbums) return;
refreshingAlbums = true;
albumRefreshResult = "";
try {
const count = await api.refreshAllAlbumArt();
albumRefreshResult = `Upgraded ${count} album ${count === 1 ? "cover" : "covers"}.`;
} catch (e) {
albumRefreshResult = `Refresh failed: ${e}`;
} finally {
refreshingAlbums = false;
}
}
async function refreshArtistArt() {
if (refreshingArtists) return;
refreshingArtists = true;
artistRefreshResult = "";
try {
const count = await api.refreshArtistArt();
artistRefreshResult = `Refreshed ${count} artist ${count === 1 ? "portrait" : "portraits"}.`;
} catch (e) {
artistRefreshResult = `Refresh failed: ${e}`;
} finally {
refreshingArtists = false;
}
}
</script>
<div class="settings">
<h1>Settings</h1>
{#if !settings}
<p class="muted">Loading…</p>
{:else}
<section>
<h2>Account</h2>
<div class="row">
<div class="label">
<div class="name">Soulseek account</div>
<div class="hint">
{settings.username
? `Signed in as ${settings.username}`
: "Not signed in"}
{#if settings.connected}<span class="online"> · online</span>{/if}
</div>
</div>
<div class="controls">
{#if app.connection !== "online"}
<button class="btn" onclick={() => (app.showLogin = true)}>Connect</button>
{/if}
{#if settings.has_password}
<button class="btn ghost" onclick={forget}>Forget credentials</button>
{/if}
</div>
</div>
<p class="stored-note">
<Icon name="lock" size={13} />
Your password is stored in the Windows Credential Manager, not in a plain file.
</p>
</section>
<section>
<h2>Playback</h2>
<div class="row">
<div class="label">
<div class="name">Audio output</div>
<div class="hint">System default follows Windows when headphones or speakers change.</div>
</div>
<select
class="device-select"
value={settings.audio_output_device ?? ""}
disabled={saving}
onchange={(e) => setAudioOutput(e.currentTarget.value)}
>
<option value="">System default (recommended)</option>
{#if settings.audio_output_device && !settings.audio_output_devices.includes(settings.audio_output_device)}
<option value={settings.audio_output_device}>{settings.audio_output_device} (unavailable)</option>
{/if}
{#each settings.audio_output_devices as device (device)}
<option value={device}>{device}</option>
{/each}
</select>
</div>
<p class="muted small">Changes apply immediately and keep the current track near the same position.</p>
</section>
<section>
<h2>Streaming</h2>
<div class="row">
<div class="label">
<div class="name">Keep streamed tracks</div>
<div class="hint">Streamed songs stay in a private cache for instant replay — not in your library. Choose Forever to add everything you stream to the library instead.</div>
</div>
<select
class="device-select"
value={settings.stream_keep_forever ? "forever" : String(settings.stream_retention_days)}
disabled={saving}
onchange={(e) => setStreamRetention(e.currentTarget.value)}
>
<option value="1">1 day</option>
<option value="3">3 days</option>
<option value="7">1 week</option>
<option value="14">2 weeks</option>
<option value="30">1 month (recommended)</option>
<option value="90">3 months</option>
<option value="forever">Forever — save to library</option>
</select>
</div>
<div class="row">
<div class="label">
<div class="name">Cache size limit</div>
<div class="hint">Oldest streamed tracks are removed first once the cache passes this size. Saved and downloaded music is never touched.</div>
</div>
<select
class="device-select"
value={String(settings.stream_cache_max_mb)}
disabled={saving}
onchange={(e) => setStreamCacheMax(e.currentTarget.value)}
>
<option value="512">512 MB</option>
<option value="1024">1 GB</option>
<option value="2048">2 GB (recommended)</option>
<option value="5120">5 GB</option>
<option value="10240">10 GB</option>
<option value="20480">20 GB</option>
<option value="0">Unlimited</option>
</select>
</div>
<p class="muted small">Retention counts from the last time a track was played. Tracks you save stay in your library regardless.</p>
</section>
<section>
<h2>Library & downloads</h2>
<div class="row">
<div class="label">
<div class="name">Library folder</div>
<div class="hint path">{settings.download_dir}</div>
</div>
<button class="btn" onclick={chooseDownloadDir}>Change…</button>
</div>
<p class="muted small">This is your downloaded-music library and is always shared. Folder changes apply after reconnecting.</p>
<div class="layout-row">
<div class="label">
<div class="name">Album folder format</div>
<div class="hint">Use {"{artist}"}, {"{album}"}, {"{date}"}, or {"{year}"}; "\" starts a subfolder. Full dates display as DD-MM-YYYY.</div>
</div>
<input
class="layout-input"
value={settings.library_layout}
onkeydown={(e) => e.key === "Enter" && e.currentTarget.blur()}
onchange={(e) => setLibraryLayout(e.currentTarget.value)}
/>
</div>
<p class="muted small">New downloads are organized into this format; existing files are not moved.</p>
</section>
<section>
<h2>Sharing</h2>
<p class="muted small">
Sharing keeps you a good citizen — leechers get banned. Your download
folder is always shared ({settings.share_files} files indexed).
Add extra folders below.
</p>
{#if settings.shared_folders.length > 0}
<div class="folder-list">
{#each settings.shared_folders as folder (folder)}
<div class="folder">
<span class="path">{folder}</span>
<button
class="remove"
title="Stop sharing"
onclick={() => removeSharedFolder(folder)}
><Icon name="x" size={15} /></button>
</div>
{/each}
</div>
{/if}
<button class="btn" onclick={addSharedFolder}>Add folder…</button>
</section>
<section>
<h2>Album artwork</h2>
<p class="muted small">Covers are resolved from Deezer, Apple, and the Cover Art Archive; the artwork shown in the catalog wins, at the highest resolution any source serves. One canonical image is used for the library, the embedded tags, and the album folder.</p>
<div class="row">
<div class="label">
<div class="name">Art inside downloaded files</div>
<div class="hint">Replace whatever picture the uploader embedded with the canonical cover, or keep theirs when present.</div>
</div>
<select
class="device-select"
value={settings.replace_embedded_art ? "replace" : "keep"}
disabled={saving}
onchange={(e) => setReplaceEmbeddedArt(e.currentTarget.value)}
>
<option value="replace">Replace with canonical (recommended)</option>
<option value="keep">Keep uploader's art</option>
</select>
</div>
<div class="row">
<div class="label">
<div class="name">Embedded cover size</div>
<div class="hint">Longest edge of the copy written into each audio file. The full-resolution original is always kept in the album folder and art cache.</div>
</div>
<select
class="device-select"
value={String(settings.embed_art_max_edge)}
disabled={saving}
onchange={(e) => setEmbedArtMaxEdge(e.currentTarget.value)}
>
<option value="0">Original (largest files)</option>
<option value="1500">1500 px (recommended)</option>
<option value="1000">1000 px</option>
</select>
</div>
<div class="row">
<div class="label">
<div class="name">Album folder cover</div>
<div class="hint">Write the canonical cover as cover.jpg and folder.jpg so Explorer and other players show the same artwork.</div>
</div>
<select
class="device-select"
value={settings.write_folder_cover ? "write" : "skip"}
disabled={saving}
onchange={(e) => setWriteFolderCover(e.currentTarget.value)}
>
<option value="write">Write cover.jpg + folder.jpg</option>
<option value="skip">Leave folders untouched</option>
</select>
</div>
<div class="row">
<div class="label">
<div class="name">Re-fetch all album covers</div>
<div class="hint">Upgrade every library album to the canonical high-resolution cover — cache, embedded tags, and folder files. Roughly one album per second.</div>
</div>
<button class="btn" onclick={refreshAllAlbumArt} disabled={refreshingAlbums}>
{refreshingAlbums ? "Upgrading…" : "Re-fetch covers"}
</button>
</div>
{#if albumRefreshResult}<p class="muted small refresh-result">{albumRefreshResult}</p>{/if}
</section>
<section>
<h2>Artist artwork</h2>
<div class="row">
<div class="label">
<div class="name">Hero renderer</div>
<div class="hint">Live GPU creates one native-4K master, then mip-filters it cleanly for every window size.</div>
</div>
<select
class="device-select"
value={settings.artist_hero_upscaler ?? "live_gpu"}
disabled={saving}
onchange={(e) => setArtistHeroUpscaler(e.currentTarget.value)}
>
<option value="live_gpu">Live GPU 4K (recommended)</option>
<option value="fsr_legacy">Original baked FSR 1</option>
<option value="lanczos3">Baked Lanczos3</option>
</select>
</div>
<p class="muted small">Live GPU applies crop ? EASU ? RCAS ? color/fade/grain once at 3840?2160. Resizing only re-samples that fixed master, preserving grain scale and avoiding baked-file latency.</p>
<div class="row">
<div class="label">
<div class="name">Refresh artist portraits</div>
<div class="hint">Fetch portraits for artists already in your library.</div>
</div>
<button class="btn" onclick={refreshArtistArt} disabled={refreshingArtists}>
{refreshingArtists ? "Refreshing…" : "Refresh artwork"}
</button>
</div>
{#if artistRefreshResult}<p class="muted small refresh-result">{artistRefreshResult}</p>{/if}
</section>
<section>
<h2>Source quality</h2>
<p class="muted small">
Independent preferences for streaming and downloading. The ceiling and floor
bound which peer files qualify; priority decides whether the resolver picks
the best format or the fastest transfer among them.
</p>
{#each [
{ key: "stream", label: "Streaming" },
{ key: "download", label: "Downloads" },
] as mode (mode.key)}
<div class="row">
<div class="label">
<div class="name">{mode.label} — maximum</div>
<div class="hint">Skip files above this ceiling (lossy-only avoids huge lossless transfers).</div>
</div>
<select
class="device-select"
value={sourcePrefsFor(mode.key).max}
disabled={saving}
onchange={(e) => setSourcePref(mode.key, "max", e.currentTarget.value)}
>
<option value="any">No limit (lossless preferred)</option>
<option value="lossy_only">Lossy only (≤ 320 kbps)</option>
</select>
</div>
<div class="row">
<div class="label">
<div class="name">{mode.label} — minimum</div>
<div class="hint">Reject files below this floor. Lossy files must report a bitrate to qualify.</div>
</div>
<select
class="device-select"
value={sourcePrefsFor(mode.key).min}
disabled={saving}
onchange={(e) => setSourcePref(mode.key, "min", e.currentTarget.value)}
>
<option value="any">Any quality</option>
<option value="kbps192">192 kbps and up</option>
<option value="kbps256">256 kbps and up</option>
<option value="kbps320">320 kbps and up</option>
<option value="lossless">Lossless only</option>
</select>
</div>
<div class="row">
<div class="label">
<div class="name">{mode.label} — priority</div>
<div class="hint">
Fastest transfer estimates arrival time from file size ÷ peer speed
(plus queue wait) and picks whatever lands soonest — a large FLAC from a
fast peer can win. Only files inside your min/max window compete.
</div>
</div>
<select
class="device-select"
value={sourcePrefsFor(mode.key).priority}
disabled={saving}
onchange={(e) => setSourcePref(mode.key, "priority", e.currentTarget.value)}
>
<option value="quality">Best quality</option>
<option value="speed">Fastest transfer (est. time)</option>
</select>
</div>
{/each}
</section>
<section>
<h2>Uploads</h2>
<div class="row">
<div class="label">
<div class="name">Upload slots</div>
<div class="hint">How many peers can download from you at once.</div>
</div>
<div class="stepper">
<button onclick={() => setSlots(settings.upload_slots - 1)}>
<Icon name="minus" size={16} />
</button>
<span>{settings.upload_slots}</span>
<button onclick={() => setSlots(settings.upload_slots + 1)}>
<Icon name="plus" size={16} />
</button>
</div>
</div>
</section>
{#if saving}
<p class="muted small">Saving…</p>
{:else if savedFlash}
<p class="saved small">✓ Saved</p>
{:else if saveError}
<p class="save-error small">{saveError}</p>
{/if}
{/if}
</div>
<style>
.settings {
max-width: 720px;
}
h1 {
font-size: 28px;
font-weight: 800;
margin-bottom: 24px;
}
section {
margin-bottom: 32px;
}
h2 {
font-size: 16px;
font-weight: 700;
margin-bottom: 12px;
color: var(--text-primary);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
background: var(--bg-elevated);
border-radius: var(--radius);
}
.label {
min-width: 0;
}
.name {
font-weight: 600;
}
.hint {
font-size: 12px;
color: var(--text-secondary);
margin-top: 3px;
}
.path {
font-family: "Cascadia Code", "Consolas", monospace;
font-size: 11px;
word-break: break-all;
}
.online {
color: var(--accent-hover);
}
.controls {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn {
padding: 8px 16px;
border-radius: 20px;
background: var(--text-primary);
color: #000;
font-weight: 600;
font-size: 13px;
white-space: nowrap;
}
.btn:hover {
transform: scale(1.03);
}
.btn.ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn.ghost:hover {
color: var(--text-primary);
transform: none;
}
.stored-note {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
margin-top: 10px;
}
.muted {
color: var(--text-secondary);
}
.small {
font-size: 12px;
}
.folder-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.layout-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 10px;
padding: 14px 16px;
background: var(--bg-elevated);
border-radius: var(--radius);
}
.layout-input {
width: 270px;
max-width: 48%;
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-base);
color: var(--text-primary);
font-family: "Cascadia Code", Consolas, monospace;
font-size: 12px;
outline: none;
}
.layout-input:focus { border-color: var(--text-secondary); }
.device-select {
width: 310px;
max-width: 52%;
padding: 9px 34px 9px 11px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-base);
color: var(--text-primary);
font-size: 12px;
outline: none;
}
.device-select:focus { border-color: var(--text-secondary); }
.device-select:disabled { opacity: 0.6; }
.folder {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
background: var(--bg-elevated);
border-radius: 6px;
}
.remove {
color: var(--text-muted);
flex-shrink: 0;
}
.remove:hover {
color: #f15e6c;
}
.stepper {
display: flex;
align-items: center;
gap: 14px;
}
.stepper button {
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--bg-highlight);
color: var(--text-primary);
font-size: 18px;
display: grid;
place-items: center;
}
.stepper button:hover {
background: var(--bg-press);
}
.stepper span {
min-width: 20px;
text-align: center;
font-weight: 700;
}
.saved {
color: var(--accent-hover);
}
.save-error {
color: #f15e6c;
}
</style>