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.
178 lines
9.6 KiB
Svelte
178 lines
9.6 KiB
Svelte
<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>
|