forked from Keelrleek/SoulStream
playlists + lyrics: setlists with pages of their own, and the words timed to the voice
This commit is contained in:
1 parent
c4ff6269a3
commit
df3d15553a
49 files changed
+14419
-599
No files matched your search
+162
-29
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user