Baseline: full tree snapshot after git repository loss

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

No files matched your search

+800
View File
@@ -0,0 +1,800 @@
<script>
import { tick } from "svelte";
import { slide } from "svelte/transition";
import {
libraryStore,
ui,
getArt,
evictArt,
playAlbum,
player,
playlistStore,
refreshPlaylists,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import AlbumCard from "../components/AlbumCard.svelte";
import Icon from "../components/Icon.svelte";
let showPlaylistMenu = $state(false);
let expandedArtist = $state(null);
let inlineAlbumId = $state(null);
let inlineTracks = $state([]);
let artistArtUrls = $state({});
const artistArtOrder = [];
const artistArtInFlight = new Map();
const visibleArtistArts = new Set();
const artistArtPaths = new Map();
const ARTIST_ART_URL_MAX = 64;
const ARTIST_ART_CHAR_MAX = 24 * 1024 * 1024;
let artistArtChars = 0;
const artists = $derived.by(() => {
const grouped = new Map();
for (const album of libraryStore.albums) {
const key = album.artist || "Unknown Artist";
const entry = grouped.get(key) ?? { name: key, albums: [], artist_art_path: null };
entry.albums.push(album);
if (!entry.artist_art_path) entry.artist_art_path = album.artist_art_path || album.art_path;
grouped.set(key, entry);
}
return [...grouped.values()].sort((a, b) => a.name.localeCompare(b.name));
});
function touchArtistArt(name) {
const index = artistArtOrder.indexOf(name);
if (index >= 0) artistArtOrder.splice(index, 1);
artistArtOrder.push(name);
}
function forgetArtistArt(name) {
const url = artistArtUrls[name];
if (typeof url === "string") artistArtChars -= url.length;
delete artistArtUrls[name];
artistArtPaths.delete(name);
const index = artistArtOrder.indexOf(name);
if (index >= 0) artistArtOrder.splice(index, 1);
}
function rememberArtistArt(name, path, url) {
const previous = artistArtUrls[name];
if (typeof previous === "string") artistArtChars -= previous.length;
artistArtUrls[name] = url;
artistArtPaths.set(name, path);
artistArtChars += url.length;
touchArtistArt(name);
// Keep at least the just-painted image even if one unusual payload is
// larger than the budget; all older card references can be reloaded
// lazily from the separately bounded shared art cache.
while (
artistArtOrder.length > 1
&& (
artistArtOrder.length > ARTIST_ART_URL_MAX
|| artistArtChars > ARTIST_ART_CHAR_MAX
)
) {
const evictIndex = artistArtOrder.findIndex(
(artistName) => !visibleArtistArts.has(artistName),
);
// Preserve every currently painted card; the byte budget is a
// soft ceiling only when an unusually large visible row exceeds
// it by itself.
if (evictIndex < 0) break;
const [oldest] = artistArtOrder.splice(evictIndex, 1);
const oldUrl = artistArtUrls[oldest];
if (typeof oldUrl === "string") artistArtChars -= oldUrl.length;
delete artistArtUrls[oldest];
artistArtPaths.delete(oldest);
}
}
// Load only cards near the scroll viewport. The old whole-library effect
// eagerly retained every base64 image and, because it read/write the same
// reactive object, reattached callbacks to every still-pending promise on
// each completion.
function loadArtistArt(node, artist) {
let current = artist;
let alive = true;
let intersecting = false;
const load = () => {
const { name, artist_art_path: path } = current;
if (!path) return;
if (
artistArtUrls[name] !== undefined
&& artistArtPaths.get(name) === path
) {
touchArtistArt(name);
return;
}
if (artistArtUrls[name] !== undefined) forgetArtistArt(name);
let flight = artistArtInFlight.get(name);
if (!flight || flight.path !== path) {
const pending = getArt(path);
flight = { path, pending };
artistArtInFlight.set(name, flight);
pending.finally(() => {
if (artistArtInFlight.get(name) === flight) {
artistArtInFlight.delete(name);
}
});
}
flight.pending.then((url) => {
if (
alive
&& current.name === name
&& current.artist_art_path === path
&& url
) {
rememberArtistArt(name, path, url);
}
});
};
const observer = typeof IntersectionObserver === "undefined"
? null
: new IntersectionObserver(
(entries) => {
intersecting = entries.some((entry) => entry.isIntersecting);
if (intersecting) {
visibleArtistArts.add(current.name);
load();
} else {
visibleArtistArts.delete(current.name);
}
},
{ root: node.closest(".content"), rootMargin: "300px" },
);
if (observer) observer.observe(node);
else load();
return {
update(next) {
const previousName = current.name;
const pathChanged = current.artist_art_path !== next.artist_art_path;
current = next;
if (intersecting && previousName !== next.name) {
visibleArtistArts.delete(previousName);
visibleArtistArts.add(next.name);
}
if (pathChanged) {
forgetArtistArt(next.name);
if (intersecting) load();
} else if (artistArtUrls[next.name] !== undefined) {
touchArtistArt(next.name);
}
},
destroy() {
alive = false;
visibleArtistArts.delete(current.name);
observer?.disconnect();
},
};
}
function toggleArtist(name) {
expandedArtist = expandedArtist === name ? null : name;
inlineAlbumId = null;
}
function selectInlineAlbum(album) {
inlineAlbumId = inlineAlbumId === album.id ? null : album.id;
if (inlineAlbumId !== null) revealInlineTracklist();
}
const inlineAlbum = $derived(
libraryStore.albums.find((a) => a.id === inlineAlbumId) ?? null,
);
$effect(() => {
if (!inlineAlbum) {
inlineTracks = [];
return;
}
const id = inlineAlbum.id;
// Keep the previous album's rows rendered while the new ones load:
// blanking them collapses the open panel, which shrinks .content's
// scrollHeight and makes the browser clamp scrollTop upward — the
// "jump to the top" when switching albums while scrolled deep.
api.albumTracks(id).then((tracks) => {
if (inlineAlbumId !== id) return; // album changed while loading
inlineTracks = tracks;
revealInlineTracklist();
});
});
let revealRaf = null;
// Scroll .content down just enough to keep the inline tracklist in view
// while it grows (slide transition, track rows arriving). One-shot
// measurement doesn't work here: at tick() the panel is still ~0px tall,
// so we follow it frame by frame until its size settles. Never scrolls
// up, and never pushes the tracklist heading above the top of the pane.
async function revealInlineTracklist() {
await tick();
if (revealRaf) cancelAnimationFrame(revealRaf);
const scroller = document.querySelector(".content");
const target = document.querySelector(".inline-tracklist");
if (!scroller || !target) return;
const margin = 16;
const start = performance.now();
let lastBottom = -1;
let stableSince = start;
const step = (now) => {
revealRaf = null;
if (!target.isConnected) return;
const viewport = scroller.getBoundingClientRect();
const rect = target.getBoundingClientRect();
const delta = Math.min(
rect.bottom - viewport.bottom + margin,
rect.top - viewport.top - margin,
);
if (delta > 0) scroller.scrollBy({ top: delta });
if (rect.bottom !== lastBottom) {
lastBottom = rect.bottom;
stableSince = now;
}
// Keep following briefly after the last size change, bounded hard.
if (now - stableSince < 150 && now - start < 900) {
revealRaf = requestAnimationFrame(step);
}
};
revealRaf = requestAnimationFrame(step);
}
async function addAlbumToPlaylist(playlistId) {
showPlaylistMenu = false;
const payload = tracks.map((t) => ({
title: t.title,
artist: t.artist,
duration_secs: t.duration_secs ?? null,
path: t.path,
art_path: album?.art_path ?? null,
}));
await api.addToPlaylist(playlistId, payload);
await refreshPlaylists();
}
async function newPlaylistWithAlbum() {
const id = await api.createPlaylist(album.title);
await addAlbumToPlaylist(id);
}
let tracks = $state([]);
let art = $state(null);
let refreshingArt = $state(false);
let artError = $state("");
async function refreshArt() {
if (!album || refreshingArt) return;
refreshingArt = true;
artError = "";
try {
const path = await api.refreshAlbumArt(album.id);
evictArt(path);
forgetArtistArt(album.artist);
art = await getArt(path);
} catch (e) {
artError = String(e);
} finally {
refreshingArt = false;
}
}
const album = $derived(libraryStore.albums.find((a) => a.id === ui.albumId) ?? null);
$effect(() => {
if (!album) return;
tracks = [];
art = null;
api.albumTracks(album.id).then((t) => (tracks = t));
getArt(album.art_path).then((url) => (art = url));
});
function fmtDuration(s) {
if (s == null) return "";
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
const nowPlayingPath = $derived(
player.queueIndex >= 0 ? player.queue[player.queueIndex]?.path : null,
);
</script>
{#if !album}
<div class="library">
<h1>Artists</h1>
{#if libraryStore.albums.length === 0}
<div class="empty">
<p class="big">Your music will live here</p>
<p class="sub">Download an album from Search and it lands here, tagged with art.</p>
</div>
{:else}
<p class="artist-count">{artists.length} artists</p>
<div class="artist-grid">
{#each artists as artist (artist.name)}
<button
class="artist-card"
use:loadArtistArt={artist}
class:selected={expandedArtist === artist.name}
aria-expanded={expandedArtist === artist.name}
onclick={() => toggleArtist(artist.name)}
>
{#if artistArtUrls[artist.name]}
<img class="artist-art" src={artistArtUrls[artist.name]} alt="" draggable="false" />
{:else}
<div class="artist-art placeholder"><Icon name="music" size={30} /></div>
{/if}
<span class="artist-name">{artist.name}</span>
<span class="artist-meta">{artist.albums.length} {artist.albums.length === 1 ? "album" : "albums"}</span>
</button>
{/each}
</div>
{@const selected = artists.find((a) => a.name === expandedArtist)}
<section class="artist-albums" class:open={!!selected} aria-hidden={!selected}>
{#if selected}
<div class="artist-albums-head">
<div><h2>{selected.name}</h2><p>{selected.albums.length} albums</p></div>
<button class="close-expand" onclick={() => (expandedArtist = null)}>×</button>
</div>
<div class="album-strip">
{#each selected.albums as a (a.id)}
<AlbumCard album={a} onselect={selectInlineAlbum} />
{/each}
</div>
{#if inlineAlbum && selected.albums.some((a) => a.id === inlineAlbum.id)}
<div class="inline-tracklist" transition:slide={{ duration: 180 }}>
<div class="inline-track-head">
<div>
<h3>{inlineAlbum.title}</h3>
<p>{inlineAlbum.track_count} tracks{inlineAlbum.year ? ` · ${inlineAlbum.year}` : ""}</p>
</div>
<button
class="inline-play"
onclick={() => playAlbum(inlineTracks, `${inlineAlbum.artist} — ${inlineAlbum.title}`, 0, inlineAlbum.art_path)}
disabled={inlineTracks.length === 0}
>▶ Play</button>
</div>
<div class="inline-tracks">
{#each inlineTracks as t, i (t.id)}
<button
class="inline-track"
onclick={() => playAlbum(inlineTracks, `${inlineAlbum.artist} — ${inlineAlbum.title}`, i, inlineAlbum.art_path)}
>
<span class="inline-tno">{t.track_no ?? "·"}</span>
<span class="inline-ttitle">{t.title}</span>
<span class="inline-tmeta">{fmtDuration(t.duration_secs)}</span>
</button>
{/each}
</div>
</div>
{/if}
{/if}
</section>
{/if}
</div>
{:else}
<div class="album-page">
<button class="back" onclick={() => (ui.albumId = null)}>
<Icon name="chevron-left" size={16} /> Library
</button>
<header>
{#if art}
<img class="cover" src={art} alt="" draggable="false" />
{:else}
<div class="cover placeholder"><Icon name="disc" size={56} /></div>
{/if}
<div class="head-meta">
<p class="kind">Album</p>
<h1>{album.title}</h1>
<p class="line">
<strong>{album.artist}</strong>
{album.year ? ` · ${album.year}` : ""} · {album.track_count} tracks
</p>
<div class="actions">
<button
class="play-all"
onclick={() =>
playAlbum(tracks, `${album.artist} — ${album.title}`, 0, album.art_path)}
disabled={tracks.length === 0}
>▶ Play</button>
<div class="add-menu">
<button
class="refresh-art"
title="Add album to a playlist"
onclick={() => (showPlaylistMenu = !showPlaylistMenu)}
disabled={tracks.length === 0}
><Icon name="list-plus" size={15} /> Playlist</button>
{#if showPlaylistMenu}
<div class="menu">
<button class="menu-item new" onclick={newPlaylistWithAlbum}>
+ New playlist
</button>
{#each playlistStore.playlists as pl (pl.id)}
<button
class="menu-item"
onclick={() => addAlbumToPlaylist(pl.id)}
>{pl.name}</button>
{/each}
</div>
{/if}
</div>
<button
class="refresh-art"
title="Wrong cover? Refetch the album art"
onclick={refreshArt}
disabled={refreshingArt}
>
<Icon name="refresh" size={14} />
{refreshingArt ? "fetching…" : "art"}
</button>
</div>
{#if artError}
<p class="art-error">{artError}</p>
{/if}
</div>
</header>
<div class="tracklist">
{#each tracks as t, i (t.id)}
<button
class="trow"
class:playing={nowPlayingPath === t.path}
onclick={() =>
playAlbum(tracks, `${album.artist} — ${album.title}`, i, album.art_path)}
>
<span class="tno">{t.track_no ?? "·"}</span>
<span class="ttitle">{t.title}</span>
<span class="tmeta">
{t.bitrate_kbps ? `${t.bitrate_kbps} kbps · ` : ""}{fmtDuration(t.duration_secs)}
</span>
</button>
{/each}
</div>
</div>
{/if}
<style>
h1 {
font-size: 28px;
font-weight: 800;
margin-bottom: 24px;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-top: 15vh;
text-align: center;
}
.big {
font-size: 22px;
font-weight: 700;
}
.sub {
color: var(--text-secondary);
}
.artist-count {
color: var(--text-secondary);
font-size: 13px;
margin-top: -16px;
margin-bottom: 18px;
}
.artist-grid {
display: grid;
/* Fixed tracks — fluid minmax(...,1fr) sawtooths card widths while
the window or side panels resize (see Home.svelte). */
grid-template-columns: repeat(auto-fill, 156px);
gap: 22px 18px;
}
.artist-card {
display: block;
width: 100%;
padding: 8px;
text-align: left;
color: inherit;
border-radius: 8px;
overflow: hidden;
transition: background 0.18s ease, transform 0.18s ease;
}
.artist-card:hover,
.artist-card.selected {
background: var(--bg-highlight);
transform: translateY(-2px);
}
.artist-art {
display: block;
width: 100%;
aspect-ratio: 1.28;
object-fit: cover;
border-radius: 8px;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
}
.artist-art.placeholder {
display: grid;
place-items: center;
color: var(--text-muted);
}
.artist-name,
.artist-meta {
display: block;
padding: 0 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.artist-name { margin-top: 9px; font-weight: 600; }
.artist-meta { margin-top: 4px; color: var(--text-secondary); font-size: 12px; padding-bottom: 10px; }
.artist-albums {
max-height: 0;
margin-top: 0;
padding: 0 18px;
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
overflow: hidden;
transition: max-height 260ms ease, margin-top 260ms ease, padding 260ms ease, opacity 180ms ease, transform 260ms ease;
background: var(--bg-elevated);
border-radius: var(--radius);
border-top: 0 solid transparent;
overflow-anchor: none;
}
.artist-albums.open {
max-height: 900px;
margin-top: 28px;
padding: 18px;
opacity: 1;
transform: translateY(0);
pointer-events: auto;
border-top: 2px solid var(--accent);
}
.artist-albums-head {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 16px;
}
.artist-albums-head h2 { font-size: 20px; margin: 0 0 4px; }
.artist-albums-head p { color: var(--text-secondary); font-size: 12px; }
.close-expand { color: var(--text-secondary); font-size: 24px; line-height: 1; padding: 0 4px; }
.close-expand:hover { color: var(--text-primary); }
.album-strip { display: grid; grid-template-columns: repeat(auto-fill, 146px); gap: 14px; }
.album-strip :global(.card) { padding: 10px; }
.inline-tracklist {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.inline-track-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.inline-track-head h3 { font-size: 16px; margin: 0 0 3px; }
.inline-track-head p { color: var(--text-secondary); font-size: 12px; }
.inline-play { padding: 7px 15px; border-radius: 18px; background: var(--accent); color: #000; font-size: 12px; font-weight: 700; }
.inline-play:disabled { opacity: 0.45; }
.inline-tracks { display: flex; flex-direction: column; }
.inline-track { display: flex; align-items: center; gap: 12px; width: 100%; padding: 7px 8px; border-radius: 5px; text-align: left; }
.inline-track:hover { background: var(--bg-highlight); }
.inline-tno { width: 22px; text-align: right; color: var(--text-muted); font-size: 12px; }
.inline-ttitle { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inline-tmeta { color: var(--text-muted); font-size: 12px; }
/* album page */
.back {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-secondary);
font-weight: 600;
margin-bottom: 18px;
}
.back:hover {
color: var(--text-primary);
}
header {
display: flex;
gap: 24px;
align-items: flex-end;
margin-bottom: 28px;
}
.cover {
width: 200px;
height: 200px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
}
.cover.placeholder {
display: grid;
place-items: center;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
color: var(--text-muted);
font-size: 56px;
}
.kind {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-secondary);
letter-spacing: 0.08em;
}
.head-meta h1 {
margin: 6px 0 10px;
font-size: 40px;
}
.line {
color: var(--text-secondary);
font-size: 13px;
}
.actions {
display: flex;
gap: 10px;
align-items: center;
margin-top: 16px;
}
.play-all {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 10px 26px;
border-radius: 24px;
background: var(--accent);
color: #000;
font-weight: 700;
}
.refresh-art {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 16px;
border-radius: 24px;
background: var(--bg-highlight);
color: var(--text-secondary);
font-weight: 600;
font-size: 12px;
}
.refresh-art:hover:not(:disabled) {
color: var(--text-primary);
}
.refresh-art:disabled {
opacity: 0.4;
}
.add-menu {
position: relative;
}
.menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 20;
min-width: 200px;
max-height: 320px;
overflow-y: auto;
background: var(--bg-press);
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
}
.menu-item {
display: block;
width: 100%;
text-align: left;
padding: 9px 12px;
border-radius: 5px;
color: var(--text-primary);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-item:hover {
background: var(--bg-highlight);
}
.menu-item.new {
color: var(--accent-hover);
font-weight: 600;
border-bottom: 1px solid var(--border);
border-radius: 5px 5px 0 0;
margin-bottom: 4px;
}
.art-error {
margin-top: 8px;
font-size: 12px;
color: #f15e6c;
}
.play-all:hover:not(:disabled) {
background: var(--accent-hover);
transform: scale(1.02);
}
.play-all:disabled {
opacity: 0.5;
}
.tracklist {
display: flex;
flex-direction: column;
}
.trow {
display: flex;
align-items: center;
gap: 14px;
padding: 9px 12px;
border-radius: 6px;
text-align: left;
}
.trow:hover {
background: var(--bg-highlight);
}
.trow.playing .ttitle {
color: var(--accent-hover);
}
.tno {
width: 22px;
text-align: right;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.ttitle {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.tmeta {
color: var(--text-muted);
font-size: 12px;
flex-shrink: 0;
}
</style>