Baseline: full tree snapshot after git repository loss

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

No files matched your search

+380
View File
@@ -0,0 +1,380 @@
<script>
import {
catalog,
libraryStore,
streamSong,
saveSong,
streamCatalogAlbum,
saveCatalogAlbum,
resolver,
player,
openArtistByName,
artBroken,
coverUpgrades,
resolveCovers,
coverLoadError,
} from "../lib/state.svelte.js";
import Icon from "../components/Icon.svelte";
const page = $derived(catalog.albumPage); // { album, tracks }
const album = $derived(page?.album ?? null);
// Upgrade this album's cover to the highest verified-same-artwork source.
let coveredId = null;
$effect(() => {
if (album?.cover && album.id !== coveredId) {
coveredId = album.id;
resolveCovers([album], album.artist);
}
});
const nowPlaying = (kind, id) =>
player.status !== "idle" && player.catalogRef[kind] === id;
function handleCoverError(node) {
const fallback = coverLoadError(
album?.cover,
node?.currentSrc || node?.src || null,
album?.artist ?? "",
album?.title ?? "",
);
if (fallback && node?.src !== fallback) node.src = fallback;
}
const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
const inLibrary = $derived.by(() => {
if (!album) return false;
const a = norm(album.artist);
const t = norm(album.title);
return libraryStore.albums.some((x) => norm(x.artist) === a && norm(x.title) === t);
});
// An upcoming album that still has unreleased songs can't be streamed or
// saved as a whole — only its already-released singles play (per track).
const hasUnreleased = $derived((page?.tracks ?? []).some((t) => t.unreleased));
// Multi-disc releases (e.g. anniversary box sets) get "Disc N" headers, and
// each disc's tracks are numbered from 1 (Deezer's per-disc track_position).
const multiDisc = $derived(
new Set((page?.tracks ?? []).map((t) => t.disk_number ?? 1)).size > 1,
);
let saved = $state({});
function flagSaveError(key) {
saved[key] = "err";
setTimeout(() => saved[key] === "err" && (saved[key] = ""), 3000);
}
async function saveAlbumAction() {
if (!album || saved.album) return;
saved.album = "…";
if (await saveCatalogAlbum(album)) saved.album = "ok";
else flagSaveError("album");
}
async function saveTrackAction(t) {
if (saved[`t${t.id}`]) return;
saved[`t${t.id}`] = "…";
if (await saveSong(album.artist, t.title)) saved[`t${t.id}`] = "ok";
else flagSaveError(`t${t.id}`);
}
const playTrack = (t) =>
streamSong(album.artist, t.title, {
cover: t.cover ?? album.cover,
trackId: t.id,
albumId: album.id,
album: album.title || null,
expectSecs: t.duration_secs ?? null,
});
function year(a) {
return a?.release_date?.slice(0, 4) ?? "";
}
function fmtDuration(s) {
if (s == null) return "";
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
// Scroll to (and briefly flash) the track the now-playing song link targets.
let flashId = $state(null);
$effect(() => {
const tid = catalog.pendingTrackId;
if (tid == null || !page) return;
requestAnimationFrame(() => {
const el = document.getElementById(`catrack-${tid}`);
if (el) el.scrollIntoView({ block: "center", behavior: "smooth" });
});
flashId = tid;
const t = setTimeout(() => (flashId === tid) && (flashId = null), 1800);
return () => clearTimeout(t);
});
</script>
<div class="album-page">
<button class="back" onclick={() => openArtistByName(album?.artist)}>
<Icon name="chevron-left" size={16} /> {album?.artist ?? "Back"}
</button>
{#if resolver.active}
<p class="resolving"><span class="pulse">●</span> {resolver.label} — finding the best source…</p>
{:else if resolver.error}
<p class="resolving error">{resolver.error}</p>
{/if}
{#if catalog.albumLoading}
<p class="hint">Loading album…</p>
{:else if !page}
<p class="hint">{catalog.error || "Album not found."}</p>
{:else}
<header class="hero">
{#if album.cover && !artBroken[album.cover]}
<img class="cover" src={coverUpgrades[album.cover] ?? album.cover} alt="" draggable="false" onerror={(event) => handleCoverError(event.currentTarget)} />
{:else}
<div class="cover placeholder"><Icon name="disc" size={40} /></div>
{/if}
<div class="hero-info">
<p class="kind">{(album.record_type ?? "album").toUpperCase()}</p>
<h1>{album.title}</h1>
<p class="meta">
<button class="artist-link" onclick={() => openArtistByName(album.artist)}>{album.artist}</button>
{#if year(album)}· {year(album)}{/if}
{#if album.track_count}· {album.track_count} tracks{/if}
{#if inLibrary}· <span class="owned">in your library</span>{/if}
</p>
<div class="actions">
<button
class="pill"
disabled={hasUnreleased}
title={hasUnreleased ? "Not released yet" : ""}
onclick={() => streamCatalogAlbum(album)}
>▶ Play</button>
<button class="pill ghost" disabled={hasUnreleased} onclick={saveAlbumAction}>
{saved.album === "ok" ? "✓ Saved" : saved.album === "err" ? "✗ No sources" : "⤓ Save"}
</button>
</div>
</div>
</header>
<div class="tracklist">
{#each page.tracks as t, i (t.id)}
{#if multiDisc && t.disk_number !== (page.tracks[i - 1]?.disk_number ?? (i === 0 ? null : 1))}
<div class="disc-head">Disc {t.disk_number ?? 1}</div>
{/if}
{@const locked = t.unreleased}
{@const play = () => { if (!locked) playTrack(t); }}
<div
id="catrack-{t.id}"
class="trow"
class:now={nowPlaying("trackId", t.id)}
class:flash={flashId === t.id}
class:locked
role="button"
tabindex="0"
title={locked ? "Not released yet" : "Play — streams from the best source"}
onclick={play}
onkeydown={(e) => e.key === "Enter" && play()}
>
<span class="tno">{nowPlaying("trackId", t.id) ? "♫" : (t.track_no ?? "·")}</span>
<span class="ttitle">{t.title}</span>
{#if locked}
<span class="tag-unreleased">Unreleased</span>
{:else}
<span class="tdur">{fmtDuration(t.duration_secs)}</span>
{/if}
<button class="mini-btn play" disabled={locked} title={locked ? "Not released yet" : "Play"} onclick={(e) => { e.stopPropagation(); play(); }}>
<Icon name="play" size={13} />
</button>
<button class="mini-get" disabled={locked} title={locked ? "Not released yet" : "Save to library"} onclick={(e) => { e.stopPropagation(); saveTrackAction(t); }}>
<Icon name={saved[`t${t.id}`] === "ok" ? "check" : saved[`t${t.id}`] === "err" ? "x" : "download"} size={13} />
</button>
</div>
{/each}
</div>
{/if}
</div>
<style>
.hint {
color: var(--text-secondary);
padding-top: 10vh;
text-align: center;
}
.back {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-secondary);
font-weight: 600;
margin-bottom: 16px;
}
.back:hover { color: var(--text-primary); }
.hero {
display: flex;
align-items: flex-end;
gap: 20px;
background: var(--bg-elevated);
border-radius: 10px;
padding: 20px;
}
.cover {
width: 150px;
height: 150px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
box-shadow: 0 12px 28px -12px rgba(0, 0, 0, 0.7);
}
.placeholder {
display: grid;
place-items: center;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
color: var(--text-muted);
}
.hero-info { min-width: 0; }
.kind {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--text-secondary);
}
h1 {
font-size: 30px;
font-weight: 800;
margin: 4px 0 6px;
}
.meta {
color: var(--text-secondary);
font-size: 12px;
}
.artist-link {
color: var(--text-primary);
font-weight: 600;
}
.artist-link:hover { text-decoration: underline; }
.owned { color: var(--accent-hover); }
.actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
.pill {
background: var(--accent);
color: #000;
font-size: 11px;
font-weight: 700;
padding: 7px 16px;
border-radius: 14px;
white-space: nowrap;
}
.pill:hover { background: var(--accent-hover); }
.pill.ghost {
background: var(--bg-highlight);
color: var(--text-primary);
}
.pill.ghost:hover { background: var(--bg-press, #333); }
.pill:disabled {
background: var(--bg-highlight);
color: var(--text-muted);
cursor: default;
opacity: 0.6;
}
.pill:disabled:hover { background: var(--bg-highlight); }
.tracklist {
margin-top: 18px;
display: flex;
flex-direction: column;
}
.disc-head {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
padding: 14px 8px 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
margin-bottom: 4px;
}
.disc-head:first-child { padding-top: 4px; }
.trow {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 8px;
border-radius: 5px;
cursor: pointer;
}
.trow:hover { background: var(--bg-highlight); }
.trow.now .ttitle,
.trow.now .tno { color: var(--accent-hover); }
.trow.locked { cursor: default; }
.trow.locked:hover { background: transparent; }
.trow.locked .ttitle { color: var(--text-muted); }
.tag-unreleased {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-muted);
border: 1px solid var(--border, #3a3a3a);
border-radius: 4px;
padding: 1px 6px;
flex: 0 0 auto;
}
.mini-btn:disabled,
.mini-get:disabled {
color: var(--text-muted);
opacity: 0.4;
cursor: default;
}
.mini-btn:disabled:hover,
.mini-get:disabled:hover { color: var(--text-muted); }
.trow.flash { animation: flash 1.8s ease; }
@keyframes flash {
0%, 30% { background: color-mix(in srgb, var(--accent) 22%, transparent); }
100% { background: transparent; }
}
@media (prefers-reduced-motion: reduce) {
.trow.flash { animation: none; }
}
.tno {
width: 22px;
text-align: right;
color: var(--text-muted);
font-size: 12px;
flex: 0 0 auto;
}
.ttitle {
flex: 1;
min-width: 0;
font-size: 13.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tdur {
color: var(--text-muted);
font-size: 11px;
flex: 0 0 auto;
}
.mini-btn.play { color: var(--accent-hover); }
.mini-btn,
.mini-get { color: var(--text-secondary); flex: 0 0 auto; }
.mini-btn:hover,
.mini-get:hover { color: var(--accent-hover); }
.resolving {
color: var(--text-secondary);
font-size: 12px;
margin-bottom: 12px;
}
.resolving .pulse { color: var(--accent); }
.resolving.error { color: #f15e6c; }
</style>
File diff suppressed because it is too large. Load diff
+485
View File
@@ -0,0 +1,485 @@
<script>
import {
catalog,
openCatalogArtist,
streamSong,
saveSong,
streamCatalogAlbum,
saveCatalogAlbum,
resolver,
player,
artBroken,
markArtBroken,
} from "../lib/state.svelte.js";
import Icon from "../components/Icon.svelte";
const playSong = (t) =>
streamSong(t.artist, t.title, {
cover: t.cover,
trackId: t.id,
albumId: t.album_id,
album: t.album || null,
expectSecs: t.duration_secs ?? null,
});
const nowPlaying = (kind, id) =>
player.status !== "idle" && player.catalogRef[kind] === id;
const top = $derived(catalog.results?.artists[0] ?? null);
const artists = $derived(catalog.results?.artists.slice(0, 6) ?? []);
const albums = $derived(catalog.results?.albums.slice(0, 6) ?? []);
const tracks = $derived(catalog.results?.tracks.slice(0, 5) ?? []);
let saved = $state({});
// A failed save flashes ✗ on the button (the resolver banner up top is
// easy to miss) before returning to the idle icon.
function flagSaveError(key) {
saved[key] = "err";
setTimeout(() => {
if (saved[key] === "err") saved[key] = "";
}, 3000);
}
async function saveTrack(t) {
if (saved[`t${t.id}`]) return;
saved[`t${t.id}`] = "…";
if (await saveSong(t.artist, t.title)) saved[`t${t.id}`] = "ok";
else flagSaveError(`t${t.id}`);
}
async function saveAlbum(al) {
if (saved[`a${al.id}`]) return;
saved[`a${al.id}`] = "…";
if (await saveCatalogAlbum(al)) saved[`a${al.id}`] = "ok";
else flagSaveError(`a${al.id}`);
}
function fmtDuration(s) {
if (s == null) return "";
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function albumMeta(album) {
const year = album.release_date?.slice(0, 4);
const type = album.record_type ? album.record_type[0].toUpperCase() + album.record_type.slice(1) : "Album";
return [year, type].filter(Boolean).join(" · ");
}
</script>
<div class="catalog">
{#if catalog.loading}
<p class="hint">Searching the catalog for “{catalog.query}”…</p>
{:else if catalog.error}
<p class="hint">Catalog search failed: {catalog.error}</p>
{:else if !catalog.results}
<p class="hint">Search for an artist, album or song above.</p>
{:else}
{#if resolver.active}
<p class="resolving"><span class="pulse">●</span> {resolver.label} — finding the best source…</p>
{:else if resolver.error}
<p class="resolving error">{resolver.error}</p>
{/if}
<div class="lead">
{#if top}
<section class="top-result">
<h2>Top result</h2>
<button class="top-card" onclick={() => openCatalogArtist(top.id, top)}>
{#if top.picture}
<img class="round" src={top.picture} alt="" draggable="false" />
{:else}
<div class="round placeholder"><Icon name="music" size={26} /></div>
{/if}
<div class="top-name">{top.name}</div>
<span class="badge">ARTIST</span>
</button>
</section>
{/if}
{#if tracks.length > 0}
<section class="songs">
<h2>Songs</h2>
<div class="song-list">
{#each tracks as t (t.id)}
<div
class="song"
class:now={nowPlaying("trackId", t.id)}
role="button"
tabindex="0"
title="Play — streams from the best source"
onclick={() => playSong(t)}
onkeydown={(e) => e.key === "Enter" && playSong(t)}
>
{#if t.cover && !artBroken[t.cover]}
<img class="mini-cover" src={t.cover} alt="" draggable="false" onerror={() => markArtBroken(t.cover)} />
{:else}
<div class="mini-cover placeholder"></div>
{/if}
<div class="song-info">
<div class="song-title">
{#if nowPlaying("trackId", t.id)}<span class="eq">♫</span>{/if}
{t.title}
</div>
<div class="song-sub">{t.artist} · {t.album}</div>
</div>
<span class="dur">{fmtDuration(t.duration_secs)}</span>
<button
class="mini-btn play"
title="Play"
onclick={(e) => { e.stopPropagation(); playSong(t); }}
><Icon name="play" size={13} /></button>
<button
class="mini-btn"
title="Save to library"
onclick={(e) => { e.stopPropagation(); saveTrack(t); }}
><Icon name={saved[`t${t.id}`] === "ok" ? "check" : saved[`t${t.id}`] === "err" ? "x" : "download"} size={13} /></button>
</div>
{/each}
</div>
</section>
{/if}
</div>
{#if artists.length > 0}
<h2>Artists</h2>
<div class="artist-row">
{#each artists as a (a.id)}
<button class="artist" onclick={() => openCatalogArtist(a.id, a)}>
{#if a.picture}
<img class="round sm" src={a.picture} alt="" draggable="false" />
{:else}
<div class="round sm placeholder"><Icon name="music" size={20} /></div>
{/if}
<div class="artist-name">{a.name}</div>
</button>
{/each}
</div>
{/if}
{#if albums.length > 0}
<h2>Albums</h2>
<div class="album-row">
{#each albums as al (al.id)}
<div class="album" class:now={nowPlaying("albumId", al.id)}>
<div class="cover-wrap">
{#if nowPlaying("albumId", al.id)}
<span class="now-badge">♫</span>
{/if}
{#if al.cover && !artBroken[al.cover]}
<img class="cover" src={al.cover} alt="" draggable="false" onerror={() => markArtBroken(al.cover)} />
{:else}
<div class="cover placeholder"><Icon name="disc" size={30} /></div>
{/if}
<div class="overlay">
<button
class="act"
title="Play — streams from the best source"
onclick={() => streamCatalogAlbum(al)}
><Icon name="play" size={15} /></button>
<button
class="act"
title="Save to library"
onclick={() => saveAlbum(al)}
><Icon name={saved[`a${al.id}`] === "ok" ? "check" : saved[`a${al.id}`] === "err" ? "x" : "download"} size={15} /></button>
</div>
</div>
<div class="album-title" title={al.title}>{al.title}</div>
<div class="album-sub">{albumMeta(al)}</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
<style>
.catalog {
display: flex;
flex-direction: column;
}
.hint {
color: var(--text-secondary);
padding-top: 10vh;
text-align: center;
}
h2 {
font-size: 15px;
font-weight: 700;
margin: 20px 0 10px;
}
.lead {
display: grid;
grid-template-columns: 240px 1fr;
gap: 16px;
align-items: start;
}
.lead h2 {
margin-top: 0;
}
.top-card {
display: block;
width: 100%;
text-align: left;
background: var(--bg-elevated);
border-radius: 8px;
padding: 16px;
transition: background 0.2s;
}
.top-card:hover {
background: var(--bg-highlight);
}
.round {
width: 84px;
height: 84px;
border-radius: 50%;
object-fit: cover;
display: block;
}
.round.sm {
width: 90px;
height: 90px;
margin: 0 auto;
}
.placeholder {
display: grid;
place-items: center;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
color: var(--text-muted);
}
.top-name {
font-size: 17px;
font-weight: 700;
margin-top: 12px;
}
.badge {
display: inline-block;
margin-top: 8px;
background: var(--bg-highlight);
color: var(--text-secondary);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
padding: 4px 10px;
border-radius: 10px;
}
.song-list {
background: var(--bg-elevated);
border-radius: 8px;
padding: 6px 8px;
}
.song {
display: flex;
align-items: center;
gap: 11px;
padding: 6px;
border-radius: 5px;
}
.song:hover {
background: var(--bg-highlight);
}
.mini-cover {
width: 34px;
height: 34px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
.song-info {
flex: 1;
min-width: 0;
}
.song-title {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.song-sub {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dur {
color: var(--text-muted);
font-size: 11px;
flex-shrink: 0;
}
.mini-btn {
color: var(--text-secondary);
flex-shrink: 0;
}
.mini-btn:hover {
color: var(--accent-hover);
}
.resolving {
color: var(--text-secondary);
font-size: 12px;
margin-bottom: 14px;
}
.resolving .pulse {
color: var(--accent);
}
.resolving.error {
color: #f15e6c;
}
.song {
cursor: pointer;
}
.song.now .song-title {
color: var(--accent-hover);
}
.eq {
color: var(--accent-hover);
margin-right: 3px;
}
.album.now .cover {
outline: 2px solid var(--accent);
}
.now-badge {
position: absolute;
right: 7px;
bottom: 7px;
z-index: 1;
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent);
color: #000;
font-size: 11px;
}
.mini-btn.play {
color: var(--accent-hover);
}
.act {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent);
color: #000;
margin: 0 5px;
}
.act:hover {
background: var(--accent-hover);
transform: scale(1.06);
}
.artist-row,
.album-row {
display: grid;
/* Fixed tracks — fluid minmax(...,1fr) sawtooths card widths while
the window or side panels resize (see Home.svelte). */
grid-template-columns: repeat(auto-fill, 124px);
gap: 16px;
}
.artist {
text-align: center;
padding: 10px 6px;
border-radius: 8px;
transition: background 0.2s;
}
.artist:hover {
background: var(--bg-highlight);
}
.artist-name {
font-size: 12px;
font-weight: 600;
margin-top: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cover-wrap {
position: relative;
}
.cover {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 6px;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
border-radius: 6px;
opacity: 0;
transition: opacity 0.15s;
}
.cover-wrap:hover .overlay {
opacity: 1;
}
.album-title {
font-size: 12px;
font-weight: 600;
margin-top: 7px;
line-height: 1.25;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 6;
}
.album-sub {
font-size: 11px;
color: var(--text-secondary);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.album {
display: flex;
flex-direction: column;
min-height: 245px;
}
</style>
+308
View File
@@ -0,0 +1,308 @@
<script>
import { downloads, network, playLocalFile } from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
const uploads = $derived(
Object.values(network.uploads)
.sort((a, b) => b.id - a.id)
.slice(0, 20),
);
const items = $derived(
Object.values(downloads.items).sort((a, b) => b.id - a.id),
);
const active = $derived(
items.filter((d) => !["complete", "failed", "cancelled"].includes(d.state)),
);
const finished = $derived(
items.filter((d) => ["complete", "failed", "cancelled"].includes(d.state)),
);
function pct(d) {
return d.total > 0 ? Math.min(100, (d.received / d.total) * 100) : 0;
}
function fmtSize(bytes) {
if (bytes >= 1e9) return (bytes / 1e9).toFixed(2) + " GB";
return (bytes / 1e6).toFixed(1) + " MB";
}
function stateLabel(d) {
switch (d.state) {
case "connecting": return "connecting…";
case "requested": return "requested…";
case "queued":
return d.queue_place != null ? `in queue (#${d.queue_place})` : "in queue";
case "transferring":
return `${fmtSize(d.received)} / ${fmtSize(d.total)} · ${(d.speed_bps / 1e6).toFixed(1)} MB/s`;
case "complete": return "done";
case "failed": return d.error || "failed";
case "cancelled": return "cancelled";
default: return d.state;
}
}
</script>
<div class="downloads">
<h1>Downloads</h1>
{#if network.shareFiles > 0}
<div class="sharing-banner">
<span class="dot"></span>
Sharing {network.shareFiles} files with the network
{#if network.respondedCount > 0}
· answered {network.respondedCount} searches
{/if}
{#if uploads.length > 0}
· {uploads.filter((u) => u.state === "transferring").length} uploading
{/if}
</div>
{/if}
{#if uploads.length > 0}
<h2>Uploads</h2>
<div class="list">
{#each uploads as u (u.id)}
<div class="row">
<div class="info">
<div class="name">{u.name}</div>
<div class="sub">
to {u.username} ·
<span class:ok={u.state === "complete"} class:err={u.state === "failed"}>
{u.state === "transferring" && u.total > 0
? `${((u.sent / u.total) * 100).toFixed(0)}%`
: u.state}
</span>
</div>
{#if u.state === "transferring"}
<div class="bar">
<div class="fill up" style="width: {u.total > 0 ? (u.sent / u.total) * 100 : 0}%"></div>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{#if items.length === 0}
{#if uploads.length === 0}
<div class="empty">
<p class="big">No downloads yet</p>
<p class="sub">Grab something from Search — files land in your library folder.</p>
</div>
{/if}
{:else}
{#if active.length > 0}
<h2>Active</h2>
<div class="list">
{#each active as d (d.id)}
<div class="row">
<div class="info">
<div class="name">{d.name}</div>
<div class="sub">{d.username} · {stateLabel(d)}</div>
<div class="bar">
<div
class="fill"
class:indeterminate={d.state !== "transferring"}
style="width: {d.state === 'transferring' ? pct(d) : 100}%"
></div>
</div>
</div>
<button class="cancel" title="Cancel" onclick={() => api.cancelDownload(d.id)}>
<Icon name="x" size={16} />
</button>
</div>
{/each}
</div>
{/if}
{#if finished.length > 0}
<h2>Finished</h2>
<div class="list">
{#each finished as d (d.id)}
<div class="row done-row">
<div class="info">
<div class="name" class:dim={d.state !== "complete"}>{d.name}</div>
<div class="sub">
{d.username} ·
<span class:ok={d.state === "complete"} class:err={d.state === "failed"}>
{stateLabel(d)}
</span>
</div>
</div>
{#if d.state === "complete" && d.local_path}
<button
class="reveal"
title="Play"
onclick={() =>
playLocalFile(
d.local_path,
d.name.replace(/\.[^.]+$/, ""),
d.username,
)}
><Icon name="play" size={15} /></button>
<button
class="reveal"
title="Show in folder"
onclick={() => api.showInFolder(d.local_path)}
><Icon name="folder-open" size={16} /></button>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
</div>
<style>
h1 {
font-size: 28px;
font-weight: 800;
margin-bottom: 24px;
}
h2 {
font-size: 14px;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 18px 0 10px;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-top: 15vh;
text-align: center;
}
.big {
font-size: 22px;
font-weight: 700;
}
.sub {
color: var(--text-secondary);
font-size: 12px;
}
.list {
display: flex;
flex-direction: column;
gap: 6px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius);
background: var(--bg-elevated);
}
.row.done-row {
background: transparent;
}
.row.done-row:hover {
background: var(--bg-highlight);
}
.info {
flex: 1;
min-width: 0;
}
.name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.name.dim {
color: var(--text-muted);
}
.bar {
margin-top: 8px;
height: 4px;
border-radius: 2px;
background: hsla(0, 0%, 100%, 0.15);
overflow: hidden;
}
.fill {
height: 100%;
border-radius: 2px;
background: var(--accent);
transition: width 0.25s linear;
}
.fill.indeterminate {
opacity: 0.25;
animation: pulse 1.4s ease-in-out infinite;
}
.fill.up {
background: var(--accent-hover);
}
.sharing-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 20px;
border-radius: var(--radius);
background: var(--accent-soft);
color: var(--text-secondary);
font-size: 13px;
}
.sharing-banner .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
flex-shrink: 0;
}
@keyframes pulse {
50% { opacity: 0.55; }
}
.ok {
color: var(--accent-hover);
}
.err {
color: #f15e6c;
}
.cancel {
color: var(--text-muted);
font-size: 14px;
padding: 6px;
}
.cancel:hover {
color: #f15e6c;
}
.reveal {
font-size: 15px;
opacity: 0.8;
}
.reveal:hover {
opacity: 1;
}
</style>
+91
View File
@@ -0,0 +1,91 @@
<script>
import { libraryStore, ui } from "../lib/state.svelte.js";
import AlbumCard from "../components/AlbumCard.svelte";
const recent = $derived(libraryStore.albums.slice(0, 8));
const greeting = (() => {
const h = new Date().getHours();
if (h < 12) return "Good morning";
if (h < 18) return "Good afternoon";
return "Good evening";
})();
</script>
<div class="home">
<h1>{greeting}</h1>
{#if recent.length === 0}
<div class="empty">
<p class="big">Welcome to SoulStream</p>
<p class="sub">
Search the network, stream instantly, and downloaded albums
will show up here.
</p>
<button class="cta" onclick={() => (ui.view = "search")}>Start searching</button>
</div>
{:else}
<section>
<h2>Recently added</h2>
<div class="grid">
{#each recent as a (a.id)}
<AlbumCard album={a} />
{/each}
</div>
</section>
{/if}
</div>
<style>
h1 {
font-size: 28px;
font-weight: 800;
margin-bottom: 24px;
}
h2 {
font-size: 20px;
font-weight: 700;
margin-bottom: 16px;
}
.grid {
display: grid;
/* Fixed tracks, not minmax(...,1fr): fluid tracks make every card
resize in a sawtooth as the column count snaps during window/panel
resizing — visible jitter. Fixed tracks only add/remove a column. */
grid-template-columns: repeat(auto-fill, 180px);
gap: 18px;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding-top: 15vh;
text-align: center;
}
.big {
font-size: 22px;
font-weight: 700;
}
.sub {
color: var(--text-secondary);
}
.cta {
margin-top: 10px;
padding: 10px 24px;
border-radius: 24px;
background: var(--accent);
color: #000;
font-weight: 700;
}
.cta:hover {
background: var(--accent-hover);
}
</style>
+800
View File
@@ -0,0 +1,800 @@
<script>
import { tick } from "svelte";
import { slide } from "svelte/transition";
import {
libraryStore,
ui,
getArt,
evictArt,
playAlbum,
player,
playlistStore,
refreshPlaylists,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import AlbumCard from "../components/AlbumCard.svelte";
import Icon from "../components/Icon.svelte";
let showPlaylistMenu = $state(false);
let expandedArtist = $state(null);
let inlineAlbumId = $state(null);
let inlineTracks = $state([]);
let artistArtUrls = $state({});
const artistArtOrder = [];
const artistArtInFlight = new Map();
const visibleArtistArts = new Set();
const artistArtPaths = new Map();
const ARTIST_ART_URL_MAX = 64;
const ARTIST_ART_CHAR_MAX = 24 * 1024 * 1024;
let artistArtChars = 0;
const artists = $derived.by(() => {
const grouped = new Map();
for (const album of libraryStore.albums) {
const key = album.artist || "Unknown Artist";
const entry = grouped.get(key) ?? { name: key, albums: [], artist_art_path: null };
entry.albums.push(album);
if (!entry.artist_art_path) entry.artist_art_path = album.artist_art_path || album.art_path;
grouped.set(key, entry);
}
return [...grouped.values()].sort((a, b) => a.name.localeCompare(b.name));
});
function touchArtistArt(name) {
const index = artistArtOrder.indexOf(name);
if (index >= 0) artistArtOrder.splice(index, 1);
artistArtOrder.push(name);
}
function forgetArtistArt(name) {
const url = artistArtUrls[name];
if (typeof url === "string") artistArtChars -= url.length;
delete artistArtUrls[name];
artistArtPaths.delete(name);
const index = artistArtOrder.indexOf(name);
if (index >= 0) artistArtOrder.splice(index, 1);
}
function rememberArtistArt(name, path, url) {
const previous = artistArtUrls[name];
if (typeof previous === "string") artistArtChars -= previous.length;
artistArtUrls[name] = url;
artistArtPaths.set(name, path);
artistArtChars += url.length;
touchArtistArt(name);
// Keep at least the just-painted image even if one unusual payload is
// larger than the budget; all older card references can be reloaded
// lazily from the separately bounded shared art cache.
while (
artistArtOrder.length > 1
&& (
artistArtOrder.length > ARTIST_ART_URL_MAX
|| artistArtChars > ARTIST_ART_CHAR_MAX
)
) {
const evictIndex = artistArtOrder.findIndex(
(artistName) => !visibleArtistArts.has(artistName),
);
// Preserve every currently painted card; the byte budget is a
// soft ceiling only when an unusually large visible row exceeds
// it by itself.
if (evictIndex < 0) break;
const [oldest] = artistArtOrder.splice(evictIndex, 1);
const oldUrl = artistArtUrls[oldest];
if (typeof oldUrl === "string") artistArtChars -= oldUrl.length;
delete artistArtUrls[oldest];
artistArtPaths.delete(oldest);
}
}
// Load only cards near the scroll viewport. The old whole-library effect
// eagerly retained every base64 image and, because it read/write the same
// reactive object, reattached callbacks to every still-pending promise on
// each completion.
function loadArtistArt(node, artist) {
let current = artist;
let alive = true;
let intersecting = false;
const load = () => {
const { name, artist_art_path: path } = current;
if (!path) return;
if (
artistArtUrls[name] !== undefined
&& artistArtPaths.get(name) === path
) {
touchArtistArt(name);
return;
}
if (artistArtUrls[name] !== undefined) forgetArtistArt(name);
let flight = artistArtInFlight.get(name);
if (!flight || flight.path !== path) {
const pending = getArt(path);
flight = { path, pending };
artistArtInFlight.set(name, flight);
pending.finally(() => {
if (artistArtInFlight.get(name) === flight) {
artistArtInFlight.delete(name);
}
});
}
flight.pending.then((url) => {
if (
alive
&& current.name === name
&& current.artist_art_path === path
&& url
) {
rememberArtistArt(name, path, url);
}
});
};
const observer = typeof IntersectionObserver === "undefined"
? null
: new IntersectionObserver(
(entries) => {
intersecting = entries.some((entry) => entry.isIntersecting);
if (intersecting) {
visibleArtistArts.add(current.name);
load();
} else {
visibleArtistArts.delete(current.name);
}
},
{ root: node.closest(".content"), rootMargin: "300px" },
);
if (observer) observer.observe(node);
else load();
return {
update(next) {
const previousName = current.name;
const pathChanged = current.artist_art_path !== next.artist_art_path;
current = next;
if (intersecting && previousName !== next.name) {
visibleArtistArts.delete(previousName);
visibleArtistArts.add(next.name);
}
if (pathChanged) {
forgetArtistArt(next.name);
if (intersecting) load();
} else if (artistArtUrls[next.name] !== undefined) {
touchArtistArt(next.name);
}
},
destroy() {
alive = false;
visibleArtistArts.delete(current.name);
observer?.disconnect();
},
};
}
function toggleArtist(name) {
expandedArtist = expandedArtist === name ? null : name;
inlineAlbumId = null;
}
function selectInlineAlbum(album) {
inlineAlbumId = inlineAlbumId === album.id ? null : album.id;
if (inlineAlbumId !== null) revealInlineTracklist();
}
const inlineAlbum = $derived(
libraryStore.albums.find((a) => a.id === inlineAlbumId) ?? null,
);
$effect(() => {
if (!inlineAlbum) {
inlineTracks = [];
return;
}
const id = inlineAlbum.id;
// Keep the previous album's rows rendered while the new ones load:
// blanking them collapses the open panel, which shrinks .content's
// scrollHeight and makes the browser clamp scrollTop upward — the
// "jump to the top" when switching albums while scrolled deep.
api.albumTracks(id).then((tracks) => {
if (inlineAlbumId !== id) return; // album changed while loading
inlineTracks = tracks;
revealInlineTracklist();
});
});
let revealRaf = null;
// Scroll .content down just enough to keep the inline tracklist in view
// while it grows (slide transition, track rows arriving). One-shot
// measurement doesn't work here: at tick() the panel is still ~0px tall,
// so we follow it frame by frame until its size settles. Never scrolls
// up, and never pushes the tracklist heading above the top of the pane.
async function revealInlineTracklist() {
await tick();
if (revealRaf) cancelAnimationFrame(revealRaf);
const scroller = document.querySelector(".content");
const target = document.querySelector(".inline-tracklist");
if (!scroller || !target) return;
const margin = 16;
const start = performance.now();
let lastBottom = -1;
let stableSince = start;
const step = (now) => {
revealRaf = null;
if (!target.isConnected) return;
const viewport = scroller.getBoundingClientRect();
const rect = target.getBoundingClientRect();
const delta = Math.min(
rect.bottom - viewport.bottom + margin,
rect.top - viewport.top - margin,
);
if (delta > 0) scroller.scrollBy({ top: delta });
if (rect.bottom !== lastBottom) {
lastBottom = rect.bottom;
stableSince = now;
}
// Keep following briefly after the last size change, bounded hard.
if (now - stableSince < 150 && now - start < 900) {
revealRaf = requestAnimationFrame(step);
}
};
revealRaf = requestAnimationFrame(step);
}
async function addAlbumToPlaylist(playlistId) {
showPlaylistMenu = false;
const payload = tracks.map((t) => ({
title: t.title,
artist: t.artist,
duration_secs: t.duration_secs ?? null,
path: t.path,
art_path: album?.art_path ?? null,
}));
await api.addToPlaylist(playlistId, payload);
await refreshPlaylists();
}
async function newPlaylistWithAlbum() {
const id = await api.createPlaylist(album.title);
await addAlbumToPlaylist(id);
}
let tracks = $state([]);
let art = $state(null);
let refreshingArt = $state(false);
let artError = $state("");
async function refreshArt() {
if (!album || refreshingArt) return;
refreshingArt = true;
artError = "";
try {
const path = await api.refreshAlbumArt(album.id);
evictArt(path);
forgetArtistArt(album.artist);
art = await getArt(path);
} catch (e) {
artError = String(e);
} finally {
refreshingArt = false;
}
}
const album = $derived(libraryStore.albums.find((a) => a.id === ui.albumId) ?? null);
$effect(() => {
if (!album) return;
tracks = [];
art = null;
api.albumTracks(album.id).then((t) => (tracks = t));
getArt(album.art_path).then((url) => (art = url));
});
function fmtDuration(s) {
if (s == null) return "";
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
const nowPlayingPath = $derived(
player.queueIndex >= 0 ? player.queue[player.queueIndex]?.path : null,
);
</script>
{#if !album}
<div class="library">
<h1>Artists</h1>
{#if libraryStore.albums.length === 0}
<div class="empty">
<p class="big">Your music will live here</p>
<p class="sub">Download an album from Search and it lands here, tagged with art.</p>
</div>
{:else}
<p class="artist-count">{artists.length} artists</p>
<div class="artist-grid">
{#each artists as artist (artist.name)}
<button
class="artist-card"
use:loadArtistArt={artist}
class:selected={expandedArtist === artist.name}
aria-expanded={expandedArtist === artist.name}
onclick={() => toggleArtist(artist.name)}
>
{#if artistArtUrls[artist.name]}
<img class="artist-art" src={artistArtUrls[artist.name]} alt="" draggable="false" />
{:else}
<div class="artist-art placeholder"><Icon name="music" size={30} /></div>
{/if}
<span class="artist-name">{artist.name}</span>
<span class="artist-meta">{artist.albums.length} {artist.albums.length === 1 ? "album" : "albums"}</span>
</button>
{/each}
</div>
{@const selected = artists.find((a) => a.name === expandedArtist)}
<section class="artist-albums" class:open={!!selected} aria-hidden={!selected}>
{#if selected}
<div class="artist-albums-head">
<div><h2>{selected.name}</h2><p>{selected.albums.length} albums</p></div>
<button class="close-expand" onclick={() => (expandedArtist = null)}>×</button>
</div>
<div class="album-strip">
{#each selected.albums as a (a.id)}
<AlbumCard album={a} onselect={selectInlineAlbum} />
{/each}
</div>
{#if inlineAlbum && selected.albums.some((a) => a.id === inlineAlbum.id)}
<div class="inline-tracklist" transition:slide={{ duration: 180 }}>
<div class="inline-track-head">
<div>
<h3>{inlineAlbum.title}</h3>
<p>{inlineAlbum.track_count} tracks{inlineAlbum.year ? ` · ${inlineAlbum.year}` : ""}</p>
</div>
<button
class="inline-play"
onclick={() => playAlbum(inlineTracks, `${inlineAlbum.artist} — ${inlineAlbum.title}`, 0, inlineAlbum.art_path)}
disabled={inlineTracks.length === 0}
>▶ Play</button>
</div>
<div class="inline-tracks">
{#each inlineTracks as t, i (t.id)}
<button
class="inline-track"
onclick={() => playAlbum(inlineTracks, `${inlineAlbum.artist} — ${inlineAlbum.title}`, i, inlineAlbum.art_path)}
>
<span class="inline-tno">{t.track_no ?? "·"}</span>
<span class="inline-ttitle">{t.title}</span>
<span class="inline-tmeta">{fmtDuration(t.duration_secs)}</span>
</button>
{/each}
</div>
</div>
{/if}
{/if}
</section>
{/if}
</div>
{:else}
<div class="album-page">
<button class="back" onclick={() => (ui.albumId = null)}>
<Icon name="chevron-left" size={16} /> Library
</button>
<header>
{#if art}
<img class="cover" src={art} alt="" draggable="false" />
{:else}
<div class="cover placeholder"><Icon name="disc" size={56} /></div>
{/if}
<div class="head-meta">
<p class="kind">Album</p>
<h1>{album.title}</h1>
<p class="line">
<strong>{album.artist}</strong>
{album.year ? ` · ${album.year}` : ""} · {album.track_count} tracks
</p>
<div class="actions">
<button
class="play-all"
onclick={() =>
playAlbum(tracks, `${album.artist} — ${album.title}`, 0, album.art_path)}
disabled={tracks.length === 0}
>▶ Play</button>
<div class="add-menu">
<button
class="refresh-art"
title="Add album to a playlist"
onclick={() => (showPlaylistMenu = !showPlaylistMenu)}
disabled={tracks.length === 0}
><Icon name="list-plus" size={15} /> Playlist</button>
{#if showPlaylistMenu}
<div class="menu">
<button class="menu-item new" onclick={newPlaylistWithAlbum}>
+ New playlist
</button>
{#each playlistStore.playlists as pl (pl.id)}
<button
class="menu-item"
onclick={() => addAlbumToPlaylist(pl.id)}
>{pl.name}</button>
{/each}
</div>
{/if}
</div>
<button
class="refresh-art"
title="Wrong cover? Refetch the album art"
onclick={refreshArt}
disabled={refreshingArt}
>
<Icon name="refresh" size={14} />
{refreshingArt ? "fetching…" : "art"}
</button>
</div>
{#if artError}
<p class="art-error">{artError}</p>
{/if}
</div>
</header>
<div class="tracklist">
{#each tracks as t, i (t.id)}
<button
class="trow"
class:playing={nowPlayingPath === t.path}
onclick={() =>
playAlbum(tracks, `${album.artist} — ${album.title}`, i, album.art_path)}
>
<span class="tno">{t.track_no ?? "·"}</span>
<span class="ttitle">{t.title}</span>
<span class="tmeta">
{t.bitrate_kbps ? `${t.bitrate_kbps} kbps · ` : ""}{fmtDuration(t.duration_secs)}
</span>
</button>
{/each}
</div>
</div>
{/if}
<style>
h1 {
font-size: 28px;
font-weight: 800;
margin-bottom: 24px;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-top: 15vh;
text-align: center;
}
.big {
font-size: 22px;
font-weight: 700;
}
.sub {
color: var(--text-secondary);
}
.artist-count {
color: var(--text-secondary);
font-size: 13px;
margin-top: -16px;
margin-bottom: 18px;
}
.artist-grid {
display: grid;
/* Fixed tracks — fluid minmax(...,1fr) sawtooths card widths while
the window or side panels resize (see Home.svelte). */
grid-template-columns: repeat(auto-fill, 156px);
gap: 22px 18px;
}
.artist-card {
display: block;
width: 100%;
padding: 8px;
text-align: left;
color: inherit;
border-radius: 8px;
overflow: hidden;
transition: background 0.18s ease, transform 0.18s ease;
}
.artist-card:hover,
.artist-card.selected {
background: var(--bg-highlight);
transform: translateY(-2px);
}
.artist-art {
display: block;
width: 100%;
aspect-ratio: 1.28;
object-fit: cover;
border-radius: 8px;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
}
.artist-art.placeholder {
display: grid;
place-items: center;
color: var(--text-muted);
}
.artist-name,
.artist-meta {
display: block;
padding: 0 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.artist-name { margin-top: 9px; font-weight: 600; }
.artist-meta { margin-top: 4px; color: var(--text-secondary); font-size: 12px; padding-bottom: 10px; }
.artist-albums {
max-height: 0;
margin-top: 0;
padding: 0 18px;
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
overflow: hidden;
transition: max-height 260ms ease, margin-top 260ms ease, padding 260ms ease, opacity 180ms ease, transform 260ms ease;
background: var(--bg-elevated);
border-radius: var(--radius);
border-top: 0 solid transparent;
overflow-anchor: none;
}
.artist-albums.open {
max-height: 900px;
margin-top: 28px;
padding: 18px;
opacity: 1;
transform: translateY(0);
pointer-events: auto;
border-top: 2px solid var(--accent);
}
.artist-albums-head {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 16px;
}
.artist-albums-head h2 { font-size: 20px; margin: 0 0 4px; }
.artist-albums-head p { color: var(--text-secondary); font-size: 12px; }
.close-expand { color: var(--text-secondary); font-size: 24px; line-height: 1; padding: 0 4px; }
.close-expand:hover { color: var(--text-primary); }
.album-strip { display: grid; grid-template-columns: repeat(auto-fill, 146px); gap: 14px; }
.album-strip :global(.card) { padding: 10px; }
.inline-tracklist {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.inline-track-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.inline-track-head h3 { font-size: 16px; margin: 0 0 3px; }
.inline-track-head p { color: var(--text-secondary); font-size: 12px; }
.inline-play { padding: 7px 15px; border-radius: 18px; background: var(--accent); color: #000; font-size: 12px; font-weight: 700; }
.inline-play:disabled { opacity: 0.45; }
.inline-tracks { display: flex; flex-direction: column; }
.inline-track { display: flex; align-items: center; gap: 12px; width: 100%; padding: 7px 8px; border-radius: 5px; text-align: left; }
.inline-track:hover { background: var(--bg-highlight); }
.inline-tno { width: 22px; text-align: right; color: var(--text-muted); font-size: 12px; }
.inline-ttitle { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inline-tmeta { color: var(--text-muted); font-size: 12px; }
/* album page */
.back {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-secondary);
font-weight: 600;
margin-bottom: 18px;
}
.back:hover {
color: var(--text-primary);
}
header {
display: flex;
gap: 24px;
align-items: flex-end;
margin-bottom: 28px;
}
.cover {
width: 200px;
height: 200px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
}
.cover.placeholder {
display: grid;
place-items: center;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
color: var(--text-muted);
font-size: 56px;
}
.kind {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-secondary);
letter-spacing: 0.08em;
}
.head-meta h1 {
margin: 6px 0 10px;
font-size: 40px;
}
.line {
color: var(--text-secondary);
font-size: 13px;
}
.actions {
display: flex;
gap: 10px;
align-items: center;
margin-top: 16px;
}
.play-all {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 10px 26px;
border-radius: 24px;
background: var(--accent);
color: #000;
font-weight: 700;
}
.refresh-art {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 16px;
border-radius: 24px;
background: var(--bg-highlight);
color: var(--text-secondary);
font-weight: 600;
font-size: 12px;
}
.refresh-art:hover:not(:disabled) {
color: var(--text-primary);
}
.refresh-art:disabled {
opacity: 0.4;
}
.add-menu {
position: relative;
}
.menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 20;
min-width: 200px;
max-height: 320px;
overflow-y: auto;
background: var(--bg-press);
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
}
.menu-item {
display: block;
width: 100%;
text-align: left;
padding: 9px 12px;
border-radius: 5px;
color: var(--text-primary);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-item:hover {
background: var(--bg-highlight);
}
.menu-item.new {
color: var(--accent-hover);
font-weight: 600;
border-bottom: 1px solid var(--border);
border-radius: 5px 5px 0 0;
margin-bottom: 4px;
}
.art-error {
margin-top: 8px;
font-size: 12px;
color: #f15e6c;
}
.play-all:hover:not(:disabled) {
background: var(--accent-hover);
transform: scale(1.02);
}
.play-all:disabled {
opacity: 0.5;
}
.tracklist {
display: flex;
flex-direction: column;
}
.trow {
display: flex;
align-items: center;
gap: 14px;
padding: 9px 12px;
border-radius: 6px;
text-align: left;
}
.trow:hover {
background: var(--bg-highlight);
}
.trow.playing .ttitle {
color: var(--accent-hover);
}
.tno {
width: 22px;
text-align: right;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.ttitle {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.tmeta {
color: var(--text-muted);
font-size: 12px;
flex-shrink: 0;
}
</style>
+234
View File
@@ -0,0 +1,234 @@
<script>
import { ui, playlistStore, refreshPlaylists, playAlbum, player } from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
let tracks = $state([]);
let renaming = $state(false);
let nameInput = $state("");
const playlist = $derived(
playlistStore.playlists.find((p) => p.id === ui.playlistId) ?? null,
);
$effect(() => {
if (ui.playlistId == null) return;
api.playlistTracks(ui.playlistId).then((t) => (tracks = t));
});
function fmtDuration(s) {
if (s == null) return "";
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function play(start = 0) {
playAlbum(
tracks.map((t) => ({ ...t, duration_secs: t.duration_secs })),
playlist?.name ?? "Playlist",
start,
);
}
async function removeTrack(position) {
await api.removeFromPlaylist(ui.playlistId, position);
tracks = await api.playlistTracks(ui.playlistId);
await refreshPlaylists();
}
async function del() {
await api.deletePlaylist(ui.playlistId);
await refreshPlaylists();
ui.view = "library";
ui.playlistId = null;
}
const nowPlayingPath = $derived(
player.queueIndex >= 0 ? player.queue[player.queueIndex]?.path : null,
);
</script>
{#if playlist}
<div class="playlist-page">
<header>
<div class="cover"><Icon name="music" size={64} /></div>
<div class="head-meta">
<p class="kind">Playlist</p>
<h1>{playlist.name}</h1>
<p class="line">{tracks.length} tracks</p>
<div class="actions">
<button class="play-all" onclick={() => play()} disabled={tracks.length === 0}>
<Icon name="play" size={15} /> Play
</button>
<button class="del" onclick={del}>Delete</button>
</div>
</div>
</header>
{#if tracks.length === 0}
<p class="empty-note">
This playlist is empty. Open an album in your Library and use “Add to playlist”.
</p>
{:else}
<div class="tracklist">
{#each tracks as t (t.position)}
<div class="trow" class:playing={nowPlayingPath === t.path}>
<button class="ttitle-wrap" onclick={() => play(tracks.indexOf(t))}>
<span class="ttitle">{t.title}</span>
<span class="tartist">{t.artist}</span>
</button>
<span class="tdur">{fmtDuration(t.duration_secs)}</span>
<button class="trm" title="Remove" onclick={() => removeTrack(t.position)}>
<Icon name="x" size={15} />
</button>
</div>
{/each}
</div>
{/if}
</div>
{:else}
<p class="empty-note">Playlist not found.</p>
{/if}
<style>
header {
display: flex;
gap: 24px;
align-items: flex-end;
margin-bottom: 28px;
}
.cover {
width: 200px;
height: 200px;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
display: grid;
place-items: center;
font-size: 64px;
color: rgba(0, 0, 0, 0.6);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
}
.kind {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-secondary);
letter-spacing: 0.08em;
}
h1 {
margin: 6px 0 10px;
font-size: 40px;
font-weight: 800;
}
.line {
color: var(--text-secondary);
font-size: 13px;
}
.actions {
display: flex;
gap: 10px;
align-items: center;
margin-top: 16px;
}
.play-all {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 10px 26px;
border-radius: 24px;
background: var(--accent);
color: #000;
font-weight: 700;
}
.play-all:hover:not(:disabled) {
background: var(--accent-hover);
transform: scale(1.02);
}
.play-all:disabled {
opacity: 0.5;
}
.del {
padding: 9px 16px;
border-radius: 24px;
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
font-size: 13px;
}
.del:hover {
color: #f15e6c;
border-color: #f15e6c;
}
.empty-note {
color: var(--text-secondary);
padding-top: 10vh;
text-align: center;
}
.tracklist {
display: flex;
flex-direction: column;
}
.trow {
display: flex;
align-items: center;
gap: 14px;
padding: 8px 12px;
border-radius: 6px;
}
.trow:hover {
background: var(--bg-highlight);
}
.trow.playing .ttitle {
color: var(--accent-hover);
}
.ttitle-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
text-align: left;
}
.ttitle {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tartist {
font-size: 12px;
color: var(--text-secondary);
}
.tdur {
color: var(--text-muted);
font-size: 12px;
flex-shrink: 0;
}
.trm {
color: var(--text-muted);
flex-shrink: 0;
}
.trm:hover {
color: #f15e6c;
}
</style>
+385
View File
@@ -0,0 +1,385 @@
<script>
import {
app,
search,
runSearch,
downloadFile,
downloadFolder,
streamFile,
filesForGroup,
} from "../lib/state.svelte.js";
import Icon from "../components/Icon.svelte";
let queuedKeys = $state({});
// Seeded from the store so "get on soulseek" hand-offs show their query.
let query = $state(search.query);
let expanded = $state({});
const groups = $derived(
Object.values(search.groups).sort(
(a, b) => (b.hasFreeSlot - a.hasFreeSlot) || (b.speed - a.speed),
),
);
async function submit(e) {
e.preventDefault();
const q = query.trim();
if (!q) return;
if (app.connection !== "online") {
app.showLogin = true;
return;
}
expanded = {};
try {
await runSearch(q);
} catch (err) {
app.loginError = String(err);
app.showLogin = true;
}
}
function fmtSize(bytes) {
if (bytes >= 1e9) return (bytes / 1e9).toFixed(2) + " GB";
return (bytes / 1e6).toFixed(1) + " MB";
}
function fmtDuration(s) {
if (s == null) return "";
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function fmtSpeed(bps) {
if (!bps) return "";
return (bps / 1e6).toFixed(1) + " MB/s";
}
// "FLAC", "MP3 320"… from the group's incrementally-kept summary fields.
function quality(g) {
if (g.topExt === "flac") return "FLAC";
const label = g.topExt.toUpperCase();
return g.maxBitrate ? `${label} ${g.maxBitrate}` : label;
}
</script>
<div class="search">
<form onsubmit={submit}>
<input
type="text"
placeholder="What do you want to listen to?"
bind:value={query}
spellcheck="false"
/>
</form>
{#if search.token == null}
<div class="empty">
<p class="big">Search the Soulseek network</p>
<p class="sub">
{app.connection === "online"
? `Connected as ${app.username} — search for an artist or album.`
: "You'll be asked to log in on your first search."}
</p>
</div>
{:else}
<div class="meta">
<span>
{groups.length} folders · {search.responses} users
{#if search.searching}<span class="pulse">· searching…</span>{/if}
</span>
</div>
<div class="results">
{#each groups.slice(0, 120) as g (g.key)}
<div class="group">
<button
class="group-head"
onclick={() => (expanded[g.key] = !expanded[g.key])}
>
<div class="art-mini"><Icon name="music" size={20} /></div>
<div class="ginfo">
<div class="gtitle" title={g.folder}>{g.folderName}</div>
<div class="gsub">
{g.username} · {g.audioCount} tracks · {fmtSize(g.totalSize)}
</div>
</div>
<div class="badges">
{#if quality(g)}
<span class="badge quality">{quality(g)}</span>
{/if}
{#if g.hasFreeSlot}
<span class="badge slot">open slot</span>
{:else}
<span class="badge queue">queue {g.queue}</span>
{/if}
{#if g.speed}
<span class="badge">{fmtSpeed(g.speed)}</span>
{/if}
</div>
<span
class="dl-all"
class:queued={queuedKeys[g.key]}
title="Download folder"
role="button"
tabindex="0"
onclick={(e) => {
e.stopPropagation();
if (queuedKeys[g.key]) return;
queuedKeys[g.key] = true;
downloadFolder(g);
}}
onkeydown={(e) => e.key === "Enter" && e.currentTarget.click()}
>
{#if queuedKeys[g.key]}
<Icon name="check" size={13} /> queued
{:else}
<Icon name="download" size={13} /> all
{/if}
</span>
</button>
{#if expanded[g.key]}
<div class="files">
<!-- g.fileCount in the key keeps this list refreshing
as new (non-reactive) files arrive for the group -->
{#each filesForGroup(g.key, g.fileCount) as f (f.path)}
<div class="file">
<span class="fname" title={f.name}>{f.name}</span>
<span class="fmeta">
{f.bitrate ? `${f.bitrate} kbps` : ""}
{f.duration != null ? " · " + fmtDuration(f.duration) : ""}
· {fmtSize(f.size)}
</span>
<button
class="mini live"
title="Play (streams while downloading)"
onclick={() => streamFile(g.username, f, g.folderName)}
><Icon name="play" size={13} /></button>
<button
class="mini live"
title="Download"
onclick={() => {
queuedKeys[f.path] = true;
downloadFile(g.username, f);
}}
>
<Icon name={queuedKeys[f.path] ? "check" : "download"} size={14} />
</button>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<style>
form {
margin-bottom: 20px;
}
input {
width: min(100%, 420px);
padding: 12px 18px;
border: none;
border-radius: 24px;
background: var(--bg-highlight);
color: var(--text-primary);
outline: none;
}
input::placeholder {
color: var(--text-muted);
}
input:focus {
box-shadow: 0 0 0 2px var(--text-primary);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-top: 15vh;
text-align: center;
}
.big {
font-size: 22px;
font-weight: 700;
}
.sub {
color: var(--text-secondary);
}
.meta {
color: var(--text-secondary);
font-size: 12px;
margin-bottom: 12px;
}
.pulse {
color: var(--accent);
}
.results {
display: flex;
flex-direction: column;
gap: 4px;
}
.group {
border-radius: var(--radius);
background: transparent;
}
.group-head {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 8px 10px;
border-radius: var(--radius);
text-align: left;
}
.group-head:hover {
background: var(--bg-highlight);
}
.art-mini {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 6px;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
color: var(--text-secondary);
font-size: 18px;
flex-shrink: 0;
}
.ginfo {
flex: 1;
min-width: 0;
}
.gtitle {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gsub {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badges {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.badge {
padding: 3px 8px;
border-radius: 10px;
background: var(--bg-highlight);
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
}
.badge.quality {
background: var(--accent-soft);
color: var(--accent-hover);
}
.badge.slot {
color: var(--accent-hover);
}
.badge.queue {
color: #e8a33d;
}
.files {
padding: 2px 10px 8px 66px;
display: flex;
flex-direction: column;
}
.file {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 8px;
border-radius: 4px;
font-size: 13px;
}
.file:hover {
background: var(--bg-highlight);
}
.fname {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fmeta {
color: var(--text-muted);
font-size: 11px;
flex-shrink: 0;
}
.mini {
color: var(--text-secondary);
opacity: 0.5;
flex-shrink: 0;
}
.mini.live {
opacity: 1;
}
.mini.live:hover {
color: var(--accent-hover);
}
.dl-all {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 12px;
background: var(--accent);
color: #000;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.dl-all:hover {
background: var(--accent-hover);
}
.dl-all.queued {
background: var(--bg-highlight);
color: var(--accent-hover);
cursor: default;
}
</style>
+718
View File
@@ -0,0 +1,718 @@
<script>
import { app, clearCatalogHeroArtCache, refreshSourcePrefs } from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
let settings = $state(null);
let saving = $state(false);
let savedFlash = $state(false);
let saveError = $state("");
let refreshingArtists = $state(false);
let artistRefreshResult = $state("");
let refreshingAlbums = $state(false);
let albumRefreshResult = $state("");
async function load() {
try {
settings = await api.getSettings();
} catch {
settings = null;
}
}
load();
async function commit(patch) {
saving = true;
saveError = "";
try {
await api.setSettings(patch);
await load();
savedFlash = true;
setTimeout(() => (savedFlash = false), 1500);
return true;
} catch (e) {
saveError = `Could not save: ${e}`;
return false;
} finally {
saving = false;
}
}
async function chooseDownloadDir() {
const dir = await api.pickFolder();
if (dir) await commit({ downloadDir: dir });
}
async function addSharedFolder() {
const dir = await api.pickFolder();
if (dir && !settings.shared_folders.includes(dir)) {
await commit({ sharedFolders: [...settings.shared_folders, dir] });
}
}
async function setLibraryLayout(value) {
if (value !== settings.library_layout) await commit({ libraryLayout: value });
}
async function removeSharedFolder(folder) {
await commit({
sharedFolders: settings.shared_folders.filter((f) => f !== folder),
});
}
async function setSlots(n) {
await commit({ uploadSlots: Math.max(1, Math.min(10, n)) });
}
async function setAudioOutput(device) {
if (device !== (settings.audio_output_device ?? "")) {
await commit({ audioOutputDevice: device });
}
}
const SOURCE_PREF_DEFAULTS = { max: "any", min: "any", priority: "quality" };
function sourcePrefsFor(mode) {
const stored = mode === "stream"
? settings.stream_source_prefs
: settings.download_source_prefs;
return { ...SOURCE_PREF_DEFAULTS, ...(stored ?? {}) };
}
async function setSourcePref(mode, field, value) {
const next = { ...sourcePrefsFor(mode), [field]: value };
const patch = mode === "stream"
? { streamSourcePrefs: next }
: { downloadSourcePrefs: next };
if (await commit(patch)) await refreshSourcePrefs();
}
async function setArtistHeroUpscaler(upscaler) {
if (upscaler === settings.artist_hero_upscaler) return;
if (await commit({ artistHeroUpscaler: upscaler })) {
clearCatalogHeroArtCache();
}
}
async function forget() {
await api.forgetCredentials();
app.username = "";
await load();
}
async function setStreamRetention(value) {
if (value === "forever") {
if (!settings.stream_keep_forever) {
await commit({ streamKeepForever: true });
}
} else {
const days = Number(value);
if (settings.stream_keep_forever || days !== settings.stream_retention_days) {
await commit({ streamKeepForever: false, streamRetentionDays: days });
}
}
}
async function setStreamCacheMax(value) {
const mb = Number(value);
if (mb !== settings.stream_cache_max_mb) {
await commit({ streamCacheMaxMb: mb });
}
}
async function setReplaceEmbeddedArt(value) {
const next = value === "replace";
if (next !== settings.replace_embedded_art) {
await commit({ replaceEmbeddedArt: next });
}
}
async function setEmbedArtMaxEdge(value) {
const next = Number(value);
if (next !== settings.embed_art_max_edge) {
await commit({ embedArtMaxEdge: next });
}
}
async function setWriteFolderCover(value) {
const next = value === "write";
if (next !== settings.write_folder_cover) {
await commit({ writeFolderCover: next });
}
}
// Sequential (one album ≈ one second: MusicBrainz is throttled), so a big
// library takes a while — the button stays busy until the sweep finishes.
async function refreshAllAlbumArt() {
if (refreshingAlbums) return;
refreshingAlbums = true;
albumRefreshResult = "";
try {
const count = await api.refreshAllAlbumArt();
albumRefreshResult = `Upgraded ${count} album ${count === 1 ? "cover" : "covers"}.`;
} catch (e) {
albumRefreshResult = `Refresh failed: ${e}`;
} finally {
refreshingAlbums = false;
}
}
async function refreshArtistArt() {
if (refreshingArtists) return;
refreshingArtists = true;
artistRefreshResult = "";
try {
const count = await api.refreshArtistArt();
artistRefreshResult = `Refreshed ${count} artist ${count === 1 ? "portrait" : "portraits"}.`;
} catch (e) {
artistRefreshResult = `Refresh failed: ${e}`;
} finally {
refreshingArtists = false;
}
}
</script>
<div class="settings">
<h1>Settings</h1>
{#if !settings}
<p class="muted">Loading…</p>
{:else}
<section>
<h2>Account</h2>
<div class="row">
<div class="label">
<div class="name">Soulseek account</div>
<div class="hint">
{settings.username
? `Signed in as ${settings.username}`
: "Not signed in"}
{#if settings.connected}<span class="online"> · online</span>{/if}
</div>
</div>
<div class="controls">
{#if app.connection !== "online"}
<button class="btn" onclick={() => (app.showLogin = true)}>Connect</button>
{/if}
{#if settings.has_password}
<button class="btn ghost" onclick={forget}>Forget credentials</button>
{/if}
</div>
</div>
<p class="stored-note">
<Icon name="lock" size={13} />
Your password is stored in the Windows Credential Manager, not in a plain file.
</p>
</section>
<section>
<h2>Playback</h2>
<div class="row">
<div class="label">
<div class="name">Audio output</div>
<div class="hint">System default follows Windows when headphones or speakers change.</div>
</div>
<select
class="device-select"
value={settings.audio_output_device ?? ""}
disabled={saving}
onchange={(e) => setAudioOutput(e.currentTarget.value)}
>
<option value="">System default (recommended)</option>
{#if settings.audio_output_device && !settings.audio_output_devices.includes(settings.audio_output_device)}
<option value={settings.audio_output_device}>{settings.audio_output_device} (unavailable)</option>
{/if}
{#each settings.audio_output_devices as device (device)}
<option value={device}>{device}</option>
{/each}
</select>
</div>
<p class="muted small">Changes apply immediately and keep the current track near the same position.</p>
</section>
<section>
<h2>Streaming</h2>
<div class="row">
<div class="label">
<div class="name">Keep streamed tracks</div>
<div class="hint">Streamed songs stay in a private cache for instant replay — not in your library. Choose Forever to add everything you stream to the library instead.</div>
</div>
<select
class="device-select"
value={settings.stream_keep_forever ? "forever" : String(settings.stream_retention_days)}
disabled={saving}
onchange={(e) => setStreamRetention(e.currentTarget.value)}
>
<option value="1">1 day</option>
<option value="3">3 days</option>
<option value="7">1 week</option>
<option value="14">2 weeks</option>
<option value="30">1 month (recommended)</option>
<option value="90">3 months</option>
<option value="forever">Forever — save to library</option>
</select>
</div>
<div class="row">
<div class="label">
<div class="name">Cache size limit</div>
<div class="hint">Oldest streamed tracks are removed first once the cache passes this size. Saved and downloaded music is never touched.</div>
</div>
<select
class="device-select"
value={String(settings.stream_cache_max_mb)}
disabled={saving}
onchange={(e) => setStreamCacheMax(e.currentTarget.value)}
>
<option value="512">512 MB</option>
<option value="1024">1 GB</option>
<option value="2048">2 GB (recommended)</option>
<option value="5120">5 GB</option>
<option value="10240">10 GB</option>
<option value="20480">20 GB</option>
<option value="0">Unlimited</option>
</select>
</div>
<p class="muted small">Retention counts from the last time a track was played. Tracks you save stay in your library regardless.</p>
</section>
<section>
<h2>Library & downloads</h2>
<div class="row">
<div class="label">
<div class="name">Library folder</div>
<div class="hint path">{settings.download_dir}</div>
</div>
<button class="btn" onclick={chooseDownloadDir}>Change…</button>
</div>
<p class="muted small">This is your downloaded-music library and is always shared. Folder changes apply after reconnecting.</p>
<div class="layout-row">
<div class="label">
<div class="name">Album folder format</div>
<div class="hint">Use {"{artist}"}, {"{album}"}, {"{date}"}, or {"{year}"}; "\" starts a subfolder. Full dates display as DD-MM-YYYY.</div>
</div>
<input
class="layout-input"
value={settings.library_layout}
onkeydown={(e) => e.key === "Enter" && e.currentTarget.blur()}
onchange={(e) => setLibraryLayout(e.currentTarget.value)}
/>
</div>
<p class="muted small">New downloads are organized into this format; existing files are not moved.</p>
</section>
<section>
<h2>Sharing</h2>
<p class="muted small">
Sharing keeps you a good citizen — leechers get banned. Your download
folder is always shared ({settings.share_files} files indexed).
Add extra folders below.
</p>
{#if settings.shared_folders.length > 0}
<div class="folder-list">
{#each settings.shared_folders as folder (folder)}
<div class="folder">
<span class="path">{folder}</span>
<button
class="remove"
title="Stop sharing"
onclick={() => removeSharedFolder(folder)}
><Icon name="x" size={15} /></button>
</div>
{/each}
</div>
{/if}
<button class="btn" onclick={addSharedFolder}>Add folder…</button>
</section>
<section>
<h2>Album artwork</h2>
<p class="muted small">Covers are resolved from Deezer, Apple, and the Cover Art Archive; the artwork shown in the catalog wins, at the highest resolution any source serves. One canonical image is used for the library, the embedded tags, and the album folder.</p>
<div class="row">
<div class="label">
<div class="name">Art inside downloaded files</div>
<div class="hint">Replace whatever picture the uploader embedded with the canonical cover, or keep theirs when present.</div>
</div>
<select
class="device-select"
value={settings.replace_embedded_art ? "replace" : "keep"}
disabled={saving}
onchange={(e) => setReplaceEmbeddedArt(e.currentTarget.value)}
>
<option value="replace">Replace with canonical (recommended)</option>
<option value="keep">Keep uploader's art</option>
</select>
</div>
<div class="row">
<div class="label">
<div class="name">Embedded cover size</div>
<div class="hint">Longest edge of the copy written into each audio file. The full-resolution original is always kept in the album folder and art cache.</div>
</div>
<select
class="device-select"
value={String(settings.embed_art_max_edge)}
disabled={saving}
onchange={(e) => setEmbedArtMaxEdge(e.currentTarget.value)}
>
<option value="0">Original (largest files)</option>
<option value="1500">1500 px (recommended)</option>
<option value="1000">1000 px</option>
</select>
</div>
<div class="row">
<div class="label">
<div class="name">Album folder cover</div>
<div class="hint">Write the canonical cover as cover.jpg and folder.jpg so Explorer and other players show the same artwork.</div>
</div>
<select
class="device-select"
value={settings.write_folder_cover ? "write" : "skip"}
disabled={saving}
onchange={(e) => setWriteFolderCover(e.currentTarget.value)}
>
<option value="write">Write cover.jpg + folder.jpg</option>
<option value="skip">Leave folders untouched</option>
</select>
</div>
<div class="row">
<div class="label">
<div class="name">Re-fetch all album covers</div>
<div class="hint">Upgrade every library album to the canonical high-resolution cover — cache, embedded tags, and folder files. Roughly one album per second.</div>
</div>
<button class="btn" onclick={refreshAllAlbumArt} disabled={refreshingAlbums}>
{refreshingAlbums ? "Upgrading…" : "Re-fetch covers"}
</button>
</div>
{#if albumRefreshResult}<p class="muted small refresh-result">{albumRefreshResult}</p>{/if}
</section>
<section>
<h2>Artist artwork</h2>
<div class="row">
<div class="label">
<div class="name">Hero renderer</div>
<div class="hint">Live GPU creates one native-4K master, then mip-filters it cleanly for every window size.</div>
</div>
<select
class="device-select"
value={settings.artist_hero_upscaler ?? "live_gpu"}
disabled={saving}
onchange={(e) => setArtistHeroUpscaler(e.currentTarget.value)}
>
<option value="live_gpu">Live GPU 4K (recommended)</option>
<option value="fsr_legacy">Original baked FSR 1</option>
<option value="lanczos3">Baked Lanczos3</option>
</select>
</div>
<p class="muted small">Live GPU applies crop ? EASU ? RCAS ? color/fade/grain once at 3840?2160. Resizing only re-samples that fixed master, preserving grain scale and avoiding baked-file latency.</p>
<div class="row">
<div class="label">
<div class="name">Refresh artist portraits</div>
<div class="hint">Fetch portraits for artists already in your library.</div>
</div>
<button class="btn" onclick={refreshArtistArt} disabled={refreshingArtists}>
{refreshingArtists ? "Refreshing…" : "Refresh artwork"}
</button>
</div>
{#if artistRefreshResult}<p class="muted small refresh-result">{artistRefreshResult}</p>{/if}
</section>
<section>
<h2>Source quality</h2>
<p class="muted small">
Independent preferences for streaming and downloading. The ceiling and floor
bound which peer files qualify; priority decides whether the resolver picks
the best format or the fastest transfer among them.
</p>
{#each [
{ key: "stream", label: "Streaming" },
{ key: "download", label: "Downloads" },
] as mode (mode.key)}
<div class="row">
<div class="label">
<div class="name">{mode.label} — maximum</div>
<div class="hint">Skip files above this ceiling (lossy-only avoids huge lossless transfers).</div>
</div>
<select
class="device-select"
value={sourcePrefsFor(mode.key).max}
disabled={saving}
onchange={(e) => setSourcePref(mode.key, "max", e.currentTarget.value)}
>
<option value="any">No limit (lossless preferred)</option>
<option value="lossy_only">Lossy only (≤ 320 kbps)</option>
</select>
</div>
<div class="row">
<div class="label">
<div class="name">{mode.label} — minimum</div>
<div class="hint">Reject files below this floor. Lossy files must report a bitrate to qualify.</div>
</div>
<select
class="device-select"
value={sourcePrefsFor(mode.key).min}
disabled={saving}
onchange={(e) => setSourcePref(mode.key, "min", e.currentTarget.value)}
>
<option value="any">Any quality</option>
<option value="kbps192">192 kbps and up</option>
<option value="kbps256">256 kbps and up</option>
<option value="kbps320">320 kbps and up</option>
<option value="lossless">Lossless only</option>
</select>
</div>
<div class="row">
<div class="label">
<div class="name">{mode.label} — priority</div>
<div class="hint">
Fastest transfer estimates arrival time from file size ÷ peer speed
(plus queue wait) and picks whatever lands soonest — a large FLAC from a
fast peer can win. Only files inside your min/max window compete.
</div>
</div>
<select
class="device-select"
value={sourcePrefsFor(mode.key).priority}
disabled={saving}
onchange={(e) => setSourcePref(mode.key, "priority", e.currentTarget.value)}
>
<option value="quality">Best quality</option>
<option value="speed">Fastest transfer (est. time)</option>
</select>
</div>
{/each}
</section>
<section>
<h2>Uploads</h2>
<div class="row">
<div class="label">
<div class="name">Upload slots</div>
<div class="hint">How many peers can download from you at once.</div>
</div>
<div class="stepper">
<button onclick={() => setSlots(settings.upload_slots - 1)}>
<Icon name="minus" size={16} />
</button>
<span>{settings.upload_slots}</span>
<button onclick={() => setSlots(settings.upload_slots + 1)}>
<Icon name="plus" size={16} />
</button>
</div>
</div>
</section>
{#if saving}
<p class="muted small">Saving…</p>
{:else if savedFlash}
<p class="saved small">✓ Saved</p>
{:else if saveError}
<p class="save-error small">{saveError}</p>
{/if}
{/if}
</div>
<style>
.settings {
max-width: 720px;
}
h1 {
font-size: 28px;
font-weight: 800;
margin-bottom: 24px;
}
section {
margin-bottom: 32px;
}
h2 {
font-size: 16px;
font-weight: 700;
margin-bottom: 12px;
color: var(--text-primary);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
background: var(--bg-elevated);
border-radius: var(--radius);
}
.label {
min-width: 0;
}
.name {
font-weight: 600;
}
.hint {
font-size: 12px;
color: var(--text-secondary);
margin-top: 3px;
}
.path {
font-family: "Cascadia Code", "Consolas", monospace;
font-size: 11px;
word-break: break-all;
}
.online {
color: var(--accent-hover);
}
.controls {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn {
padding: 8px 16px;
border-radius: 20px;
background: var(--text-primary);
color: #000;
font-weight: 600;
font-size: 13px;
white-space: nowrap;
}
.btn:hover {
transform: scale(1.03);
}
.btn.ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn.ghost:hover {
color: var(--text-primary);
transform: none;
}
.stored-note {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
margin-top: 10px;
}
.muted {
color: var(--text-secondary);
}
.small {
font-size: 12px;
}
.folder-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.layout-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 10px;
padding: 14px 16px;
background: var(--bg-elevated);
border-radius: var(--radius);
}
.layout-input {
width: 270px;
max-width: 48%;
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-base);
color: var(--text-primary);
font-family: "Cascadia Code", Consolas, monospace;
font-size: 12px;
outline: none;
}
.layout-input:focus { border-color: var(--text-secondary); }
.device-select {
width: 310px;
max-width: 52%;
padding: 9px 34px 9px 11px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-base);
color: var(--text-primary);
font-size: 12px;
outline: none;
}
.device-select:focus { border-color: var(--text-secondary); }
.device-select:disabled { opacity: 0.6; }
.folder {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
background: var(--bg-elevated);
border-radius: 6px;
}
.remove {
color: var(--text-muted);
flex-shrink: 0;
}
.remove:hover {
color: #f15e6c;
}
.stepper {
display: flex;
align-items: center;
gap: 14px;
}
.stepper button {
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--bg-highlight);
color: var(--text-primary);
font-size: 18px;
display: grid;
place-items: center;
}
.stepper button:hover {
background: var(--bg-press);
}
.stepper span {
min-width: 20px;
text-align: center;
font-weight: 700;
}
.saved {
color: var(--accent-hover);
}
.save-error {
color: #f15e6c;
}
</style>