playlists + lyrics: setlists with pages of their own, and the words timed to the voice

This commit is contained in:
KeelrLeek committed 2026-08-17 18:05:31 -05:00
1 parent c4ff6269a3
commit df3d15553a
49 files changed
+14419 -599

No files matched your search

+162 -29
View File
@@ -22,6 +22,7 @@
import { tick } from "svelte";
import { fade } from "svelte/transition";
import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
import * as api from "../lib/api.js";
import {
libraryStore,
@@ -94,14 +95,32 @@
// the same join the artist page's own "in your library" ticks already
// make (catalogTrackOwnership); identity of RECORDS is never guessed.
let followsByName = $state({}); // normKey(name) -> { artistId, name }
// artist name -> { total } — persisted so counts survive a restart and the
// shelf head can say "9 of 29" without a fetch. Rows are session-only.
/** "Same artist" for filing purposes, and it is not a new rule: the library
* already groups artist portraits with `GROUP BY artist COLLATE NOCASE`
* (crates/library/src/lib.rs:321), so the shelves fold exactly as wide as
* the backend does. Measured on this library, case alone splits 5 artists
* (King Gizzard "& The"/"& the", Lorn/LORN, Mac DeMarco/Demarco, Boards
* Of/of Canada, My Bloody Valentine) and the stricter punctuation-stripping
* normKey merges nothing beyond those — so the wider key would buy no shelf
* and only risk fusing two artists whose names differ by a mark. */
const foldKey = (s) => (s ?? "").toLowerCase();
// foldKey(artist) -> { total } — persisted so counts survive a restart and
// the shelf head can say "9 of 29" without a fetch. Rows are session-only.
const GAPS_KEY = "library.gaps.v1";
let gapTotals = $state((() => {
try { return JSON.parse(localStorage.getItem(GAPS_KEY)) ?? {}; } catch { return {}; }
try {
const saved = JSON.parse(localStorage.getItem(GAPS_KEY)) ?? {};
// Written under the raw shelf name before shelves folded by case,
// so a total banked as "King Gizzard & the Lizard Wizard" is still
// found now the shelf is titled "& The". Folding on load rather
// than at every read keeps one shape in memory.
return Object.fromEntries(Object.entries(saved).map(([k, v]) => [foldKey(k), v]));
} catch { return {}; }
})());
let gapRows = $state({}); // artist name -> catalog rows not owned
let gapOpen = $state({}); // artist name -> line expanded
let gapRows = $state({}); // foldKey(artist) -> catalog rows not owned
let gapOpen = $state({}); // foldKey(artist) -> line expanded
let gapLoading = $state({});
const normKey = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
@@ -138,14 +157,18 @@
}
async function toggleGaps(artistName) {
if (gapOpen[artistName]) {
gapOpen = { ...gapOpen, [artistName]: false };
// Keyed by the FOLD, not by the shelf's title: the total was the one
// thing still filed under whichever spelling happened to be clicked,
// which is why only one of two King Gizzard shelves read "8 of 42".
const key = foldKey(artistName);
if (gapOpen[key]) {
gapOpen = { ...gapOpen, [key]: false };
return;
}
const follow = followFor(artistName);
if (!follow) return;
if (!gapRows[artistName] && !gapLoading[artistName]) {
gapLoading = { ...gapLoading, [artistName]: true };
if (!gapRows[key] && !gapLoading[key]) {
gapLoading = { ...gapLoading, [key]: true };
try {
const page = await getCatalogArtistPage(follow.artistId);
const released = (page?.albums ?? []).filter((a) => !isUpcomingRow(a));
@@ -156,15 +179,15 @@
);
gapRows = {
...gapRows,
[artistName]: released.filter((r) => !ownedTitles.has(normKey(r.title))),
[key]: released.filter((r) => !ownedTitles.has(normKey(r.title))),
};
gapTotals = { ...gapTotals, [artistName]: { total: released.length } };
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, [artistName]: false };
gapLoading = { ...gapLoading, [key]: false };
}
if (gapRows[artistName]?.length || gapLoading[artistName] === false) {
gapOpen = { ...gapOpen, [artistName]: true };
if (gapRows[key]?.length || gapLoading[key] === false) {
gapOpen = { ...gapOpen, [key]: true };
}
}
@@ -309,7 +332,10 @@
// ---- groupings --------------------------------------------------------
const GROUPS = {
artist: { label: "Artist", of: (a) => a.artist || "Unknown Artist" },
// `of` names the shelf, `keyOf` decides which shelf a record lands on.
// They differ for Artist alone: two spellings of one name are one
// artist, so the key folds and shelfName() picks the title.
artist: { label: "Artist", of: (a) => a.artist || "Unknown Artist", keyOf: (a) => foldKey(a.artist || "Unknown Artist") },
decade: { label: "Decade", of: (a) => (a.year ? `${Math.floor(a.year / 10) * 10}s` : "No year") },
format: {
label: "Format",
@@ -360,12 +386,37 @@
};
function completeness(artist) {
const total = gapTotals[artist]?.total || 0;
const total = gapTotals[foldKey(artist)]?.total || 0;
if (!total) return 1;
const own = rows.filter((a) => a.artist === artist).length;
const own = rows.filter((a) => foldKey(a.artist) === foldKey(artist)).length;
return Math.min(1, own / total);
}
/** The one spelling a folded shelf is filed under.
*
* An anchored follow's own name wins, because that is the catalogue's
* spelling and the same anchor the rest of the page already trusts. With
* no anchor there is nothing authoritative to appeal to, so the records
* vote: the spelling on the most albums, ties broken by sort order so a
* shelf never renames itself between loads.
*
* A shelf with ONE spelling keeps it untouched — including when a follow
* disagrees. Renaming a shelf that was never split is a change nobody
* asked for, and this only exists to reunite the ones that were. */
function shelfName(list) {
const counts = new Map();
for (const a of list) {
const n = a.artist || "Unknown Artist";
counts.set(n, (counts.get(n) || 0) + 1);
}
const spellings = [...counts.keys()];
if (spellings.length === 1) return spellings[0];
const follow = followFor(spellings[0]);
if (follow?.name) return follow.name;
return [...counts.entries()]
.sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]))[0][0];
}
function sortList(list) {
const out = [...list].sort(SORTS[sortKey].cmp);
return desc ? out : out.reverse();
@@ -376,13 +427,25 @@
if (group === "none") return [["", sortList(corpus)]];
const by = new Map();
for (const a of corpus) {
const k = g.of(a);
const k = (g.keyOf ?? g.of)(a);
if (!by.has(k)) by.set(k, []);
by.get(k).push(a);
}
// An artist shelf is keyed by the FOLD, which is not a name, so it takes
// the title shelfName() picks. Every other grouping's key already IS its
// heading. Two folds can still title alike — followFor() joins on the
// coarser normKey, so "AC/DC" and "ACDC" would both take one follow's
// name — and that is the same artist by the app's own rule, so the
// shelves join rather than one silently replacing the other.
const named = new Map();
for (const [k, list] of by) {
const title = group === "artist" ? shelfName(list) : k;
const prev = named.get(title);
if (prev) prev.push(...list); else named.set(title, list);
}
// Added is a DATE grouping: newest month first, by the month the key
// names — a string sort put April 2025 above August 2024.
const keys = [...by.keys()].sort(group === "rtype"
const keys = [...named.keys()].sort(group === "rtype"
? (x, y) => CAT_ORDER.findIndex((c) => CAT_LABEL[c] === x)
- CAT_ORDER.findIndex((c) => CAT_LABEL[c] === y)
: group === "artist"
@@ -394,7 +457,7 @@
if (group === "artist" && sortKey === "complete") {
keys.sort((x, y) => completeness(x) - completeness(y));
}
return keys.map((k) => [k, sortList(by.get(k))]);
return keys.map((k) => [k, sortList(named.get(k))]);
});
// Albums, EPs and singles under their own headings INSIDE an artist — the
@@ -414,7 +477,7 @@
// ---- the derived stat row --------------------------------------------
const totals = $derived.by(() => ({
albums: corpus.length,
artists: new Set(corpus.map((a) => a.artist)).size,
artists: new Set(corpus.map((a) => foldKey(a.artist || "Unknown Artist"))).size,
tracks: corpus.reduce((n, a) => n + (a.track_count || 0), 0),
secs: corpus.reduce((n, a) => n + a.secs, 0),
bytes: corpus.reduce((n, a) => n + a.bytes, 0),
@@ -1151,6 +1214,25 @@
if (tracks.length) playAlbum(tracks, `${album.artist} — ${album.title}`, 0, album.art_path);
} catch { /* backend unavailable */ }
}
// Disc-aware view of the open tracklist — the artist page's own treatment
// (CatalogArtist's `openDiscs`), so a multi-disc record reads the same
// wherever you meet it: quiet disc rules, and each disc numbering from 1.
//
// `i` is the track's index in `openTracks`, kept alongside because that is
// what playback counts from: grouping the rows must not renumber the queue.
const openDiscs = $derived.by(() => {
const by = new Map();
openTracks.forEach((t, i) => {
const disc = t.disc_no ?? 1;
if (!by.has(disc)) by.set(disc, []);
by.get(disc).push({ t, i });
});
return [...by.entries()]
.sort((a, b) => a[0] - b[0])
.map(([disc, rows]) => ({ disc, rows }));
});
const multiDisc = $derived(openDiscs.length > 1);
const isPlayingTrack = (t) =>
player.track?.source === "local" && player.track?.path === t.path;
function folderOf(tracks) {
@@ -1571,6 +1653,22 @@
<button class="pill grow" title="Play" onclick={() => playOpen(0)} disabled={!openTracks.length}>
<Icon name="play" size={12} /> Play
</button>
{#if openTracks.length}
<PlaylistMenu
compact
iconSize={14}
title="Add album to a playlist"
tracks={() =>
openTracks.map((t) => ({
title: t.title,
artist: t.artist,
album: openAlbum?.title ?? null,
duration_secs: t.duration_secs,
path: t.path,
art_path: openAlbum?.art_path ?? null,
}))}
/>
{/if}
<button
class="pill ghost"
title="Show in folder"
@@ -1622,7 +1720,11 @@
<span>Title</span>
<span class="tdur-head"><Icon name="clock" size={13} /></span>
</div>
{#each openTracks as t, ti (t.id)}
{#each openDiscs as d (d.disc)}
{#if multiDisc}
<div class="disc-head" in:partIn={{ delay: 40 }}>Disc {d.disc} · {d.rows.length} tracks</div>
{/if}
{#each d.rows as { t, i: ti } (t.id)}
<div
class="trow"
class:now={isPlayingTrack(t)}
@@ -1652,6 +1754,7 @@
onclick={(e) => { e.stopPropagation(); api.showInFolder(t.path); }}
><Icon name="folder-open" size={13} /></button>
</div>
{/each}
{/each}
{:else}
<div class="tracks-loading" role="status">
@@ -1680,12 +1783,13 @@
{#snippet gapSection(name, list)}
{@const follow = followFor(name)}
{#if follow}
{@const missing = gapRows[name]}
{@const total = gapTotals[name]?.total}
{@const key = foldKey(name)}
{@const missing = gapRows[key]}
{@const total = gapTotals[key]?.total}
{@const count = missing ? missing.length : (total ? Math.max(0, total - list.length) : null)}
{#if count !== 0}
<button class="lib-gapline" onclick={() => toggleGaps(name)}>
{#if gapLoading[name]}
{#if gapLoading[key]}
<span class="tl-ring" aria-hidden="true"></span> Checking the catalogue
{:else if count != null}
<b>{count}</b> more released
@@ -1693,12 +1797,12 @@
See what's missing
{/if}
<span class="lib-gapline-cta">
{gapOpen[name] ? "hide" : "show"}
<Icon name={gapOpen[name] ? "chevron-down" : "chevron-right"} size={12} />
{gapOpen[key] ? "hide" : "show"}
<Icon name={gapOpen[key] ? "chevron-down" : "chevron-right"} size={12} />
</span>
</button>
{/if}
{#if gapOpen[name] && missing?.length}
{#if gapOpen[key] && missing?.length}
<!-- Full-size cards, dimmed — the same card as everything else on
the page, readable, full strength on hover (handoff §4d). -->
<div class="lib-gaps" role="list">
@@ -1772,7 +1876,7 @@
{#snippet shelfHead(name, list)}
{@const follow = followFor(name)}
{@const total = group === "artist" ? gapTotals[name]?.total : null}
{@const total = group === "artist" ? gapTotals[foldKey(name)]?.total : null}
{@const secs = list.reduce((n, a) => n + a.secs, 0)}
<!-- Every ARTIST shelf opens its page. Where no id is anchored yet the
click resolves one first (openShelfArtist), so an unfollowed shelf is
@@ -3236,6 +3340,15 @@
.pill:hover { background: var(--accent-hover); }
.pill.ghost { background: var(--bg-highlight); color: var(--text-primary); }
.pill.ghost:hover { background: var(--bg-press, #333); }
/* The add-to-playlist trigger wears the ghost-pill look so the CTA row
reads as one family; the component itself stays unstyled chrome. */
.rail-cta :global(.plm-trigger) {
background: var(--bg-highlight);
color: var(--text-primary);
padding: 6px 11px;
border-radius: 14px;
}
.rail-cta :global(.plm-trigger:hover) { background: var(--bg-press, #333); }
.pill.grow { flex: 1; }
.pill:disabled {
background: var(--bg-highlight);
@@ -3261,6 +3374,26 @@
color: var(--text-muted);
}
.tdur-head { display: flex; justify-content: flex-end; }
/* CatalogArtist's `.disc-head`, verbatim — a multi-disc record has to read
the same here as it does on the artist page. */
.disc-head {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 10px 6px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.disc-head::after {
content: "";
flex: 1;
height: 1px;
background: rgba(255, 255, 255, 0.07);
}
.trow {
position: relative;
display: grid;