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