forked from Keelrleek/SoulStream
Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
commit
a667b4b28e
161 files changed
+53170
No files matched your search
@@ -0,0 +1,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