Files
SoulStream/src/views/Library.svelte
T
KeelrLeek a667b4b28e 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.
2026-07-22 17:57:00 -05:00

801 lines
27 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>