Files
SoulStream/src/mobile/screens/Library.svelte
T
KeelrLeek 991d52d0cc android: the library's file stats on the cards, the artist's own plays, the thumbnail prewarm
The desktop Library's per-record file stats on the phone (library_album_stats,
one call for the whole library): FLAC 24/96 or MP3 320k badges on the cards,
the size on the list rows and the record page. The artist page's own
listening (artist_play_stats): the time played as a chip, the most played
track under "On this phone". The whole-library thumbnail warm a moment after
boot, at the cards' edge, one decode at a time behind the tiles' gate.
2026-09-13 20:37:51 -05:00

178 lines
9.6 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>
/* Your library — five second-tier tabs; Albums, Artists and Playlists
are real. The sort and the view live on the tab's root entry, so they
survive a push and a pop. */
import Icon from "../../components/Icon.svelte";
import Release from "../Release.svelte";
import Cover from "../Cover.svelte";
import Tracks from "./Tracks.svelte";
import PlaylistShelf from "../PlaylistShelf.svelte";
import * as api from "../../lib/api.js";
import { lib, artists, isFollowed, followedEntry, statLabel } from "../lib.svelte.js";
import { fmtBytes } from "../util.js";
import { push, go, patch, openHalf } from "../nav.svelte.js";
import { looseTitle, yearOf } from "../catalog.svelte.js";
import { isUpcoming } from "../disco.js";
import { longDur } from "../util.js";
let { entry, screenEl = null } = $props();
const TABS = [["albums", "Albums"], ["artists", "Artists"], ["tracks", "Tracks"], ["playlists", "Playlists"], ["gaps", "Gaps"]];
const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"]];
const tab = $derived(entry.c.tab || "albums");
const sort = $derived(entry.c.sort || "added");
const view = $derived(entry.c.view || "grid");
const list = $derived.by(() => {
const l = lib.albums.slice();
l.sort((x, y) => {
if (sort === "az") return x.title.localeCompare(y.title);
if (sort === "artist") return x.artist.localeCompare(y.artist) || (x.year || 0) - (y.year || 0);
if (sort === "year") return (y.year || 0) - (x.year || 0) || y.added_at - x.added_at;
return y.added_at - x.added_at;
});
return l;
});
const totalTracks = $derived(lib.albums.reduce((n, a) => n + (a.track_count || 0), 0));
const arts = $derived(artists());
/* ── Gaps — the desktop's (Library.svelte's gapSection): only a shelf
with an ANCHORED identity — a follow — is asked, so a name can never
be guessed onto a namesake; the catalog's released records minus the
ones on the shelf, by loose title; the totals remembered so the line
can say its number before the lookup. */
const GAPS_KEY = "library.gaps.v1";
const foldKey = (x) => String(x ?? "").toLowerCase();
let gapTotals = $state((() => { try { return JSON.parse(localStorage.getItem(GAPS_KEY)) ?? {}; } catch { return {}; } })());
let gapRows = $state({}); // foldKey(artist) → catalog rows not owned
let gapOpen = $state({});
let gapLoading = $state({});
const shelves = $derived(arts.filter((ar) => followedEntry(ar.name)));
const ownedTitles = (name) => new Set(lib.albums.filter((a) => foldKey(a.artist) === foldKey(name)).map((a) => looseTitle(a.title)));
async function toggleGaps(name) {
const key = foldKey(name);
if (gapOpen[key]) { gapOpen = { ...gapOpen, [key]: false }; return; }
const follow = followedEntry(name);
if (!follow) return;
if (!gapRows[key] && !gapLoading[key]) {
gapLoading = { ...gapLoading, [key]: true };
try {
const page = await api.catalogArtist(follow.artist_id);
const released = (page?.albums ?? []).filter((a) => !isUpcoming(a) && !a.appears_on);
const owned = ownedTitles(name);
gapRows = { ...gapRows, [key]: released.filter((r) => !owned.has(looseTitle(r.title))) };
gapTotals = { ...gapTotals, [key]: { total: released.length } };
try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ }
} catch { /* the line stays collapsed; nothing is invented */ }
gapLoading = { ...gapLoading, [key]: false };
}
if (gapRows[key]) gapOpen = { ...gapOpen, [key]: true };
}
const gapCount = (ar) => {
const key = foldKey(ar.name);
const missing = gapRows[key];
const total = gapTotals[key]?.total;
return missing ? missing.length : (total ? Math.max(0, total - ar.albums) : null);
};
</script>
<div class="phead">
<div class="row">
<h1>Your library</h1>
<div class="acts">
<button class="iconbtn" onclick={() => go("search")} aria-label="Search"><Icon name="search" size={21} /></button>
<button class="iconbtn" onclick={() => patch({ tab: "playlists" })} aria-label="Playlists"><Icon name="playlist" size={21} /></button>
<button class="iconbtn" onclick={() => openHalf("librarysort")} aria-label="Sort and filter"><Icon name="layers" size={21} /></button>
</div>
</div>
</div>
<div class="segtabs">
{#each TABS as [k, l] (k)}
<button class:on={tab === k} onclick={() => patch({ tab: k })}>{l}</button>
{/each}
</div>
{#if tab === "albums"}
<div class="chips" style="padding-bottom:12px">
{#each SORTS as [k, l] (k)}
<button class="chip" class:on={sort === k} class:quiet={sort !== k} onclick={() => patch({ sort: k })}>{l}</button>
{/each}
</div>
<div class="countline">
<span class="t-sub">{list.length} album{list.length === 1 ? "" : "s"} · {totalTracks} tracks</span>
<button class="iconbtn sm muted" onclick={() => patch({ view: view === "grid" ? "list" : "grid" })} aria-label="Grid or list">
<Icon name={view === "grid" ? "playlist" : "layers"} size={18} />
</button>
</div>
{#if !list.length}
<div class="stub"><b>Nothing here yet</b>The library on this phone is empty.</div>
{:else if view === "grid"}
<div class="grid2">
{#each list as a (a.id)}
<Release title={a.title} sub={sort === "year" ? String(a.year || "") : a.artist} path={a.art_path} badge={statLabel(lib.stats[a.id]) || null} onclick={() => push("album", { id: a.id }, screenEl)} onmore={() => openHalf("album", { album: a })} />
{/each}
</div>
{:else}
{#each list as a (a.id)}
<button class="row" onclick={() => push("album", { id: a.id }, screenEl)} oncontextmenu={(e) => { e.preventDefault(); openHalf("album", { album: a }); }}>
<span class="art"><Cover path={a.art_path} size={20} /></span>
<span class="b"><span class="t">{a.title}</span><span class="m">{a.artist} · {a.year ?? "—"}{statLabel(lib.stats[a.id]) ? " · " + statLabel(lib.stats[a.id]) : ""}{lib.stats[a.id]?.bytes ? " · " + fmtBytes(lib.stats[a.id].bytes) : ""}</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/each}
{/if}
{:else if tab === "artists"}
<div class="sec-note">{arts.length} artists · {lib.follows.length} followed</div>
{#each arts as ar (ar.name)}
<button class="row" onclick={() => push("artist", { name: ar.name }, screenEl)}>
<span class="art round"><Cover path={ar.art || ar.cover} size={20} /></span>
<span class="b">
<span class="t">{ar.name}</span>
<span class="m">{ar.albums} album{ar.albums === 1 ? "" : "s"} · {ar.tracks} tracks{isFollowed(ar.name) ? " · following" : ""}</span>
</span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/each}
{:else if tab === "tracks"}
<Tracks {entry} />
{:else if tab === "playlists"}
<PlaylistShelf {screenEl} />
{:else if tab === "gaps"}
{#if !shelves.length}
<div class="stub"><b>Follow the artists you collect.</b>The records they released that this library does not have show up here, shelf by shelf — the desktop's discography lookup, only ever for an artist you followed, so a name is never guessed onto a namesake.</div>
{:else}
<div class="sec-note">{shelves.length} followed {shelves.length === 1 ? "artist" : "artists"} on this phone</div>
{#each shelves as ar (ar.name)}
{@const key = foldKey(ar.name)}
{@const count = gapCount(ar)}
<button class="row" onclick={() => push("artist", { name: ar.name }, screenEl)}>
<span class="art round"><Cover path={ar.art || ar.cover} size={20} /></span>
<span class="b">
<span class="t">{ar.name}</span>
<span class="m">{ar.albums} album{ar.albums === 1 ? "" : "s"} here · following</span>
</span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{#if count !== 0}
<button class="gapline" onclick={() => toggleGaps(ar.name)}>
{#if gapLoading[key]}Checking the catalogue…{:else if count != null}<b>{count}</b> more released{:else}See what's missing{/if}
<span class="cta">{gapOpen[key] ? "hide" : "show"}<Icon name={gapOpen[key] ? "chevron-down" : "chevron-right"} size={12} /></span>
</button>
{:else}
<div class="gapline done"><b>Complete</b> — every released record is here</div>
{/if}
{#if gapOpen[key] && gapRows[key]?.length}
<div class="rail gaps">
{#each gapRows[key] as r (r.id)}
<Release title={r.title} sub={[yearOf(r.release_date), r.record_type && r.record_type !== "album" ? r.record_type.toUpperCase() : ""].filter(Boolean).join(" · ")} url={r.cover} onclick={() => push("catalogAlbum", { id: r.id, artist: ar.name, title: r.title }, screenEl)} onmore={() => openHalf("catalogAlbum", { album: { ...r, artist: r.artist || ar.name } })} />
{/each}
</div>
{/if}
{/each}
{/if}
{/if}
<div class="endpad"></div>